Skip to content
Merged
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
94 changes: 94 additions & 0 deletions apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $gateway } from '@/store/gateway'
import { $gatewayState } from '@/store/session'

import { useGatewayRequest } from './use-gateway-request'

// The desktop app, dashboard SPA, and Ink stdio TUI all drive the same backend
// JSON-RPC server, which historically stamped every turn platform="tui". This
// hook now tags SESSION-ORIGINATING calls with source:'desktop' so the backend
// attributes turns to the desktop client (→ blackbox turns.platform →
// tokens.ace source chart). These tests pin that contract.

type RequestCall = { method: string; params: Record<string, unknown> }

function mountHook() {
const calls: RequestCall[] = []
const fakeGateway = {
request: vi.fn(async (method: string, params: Record<string, unknown>) => {
calls.push({ method, params })
return { ok: true }
})
}

let requestGateway!: ReturnType<typeof useGatewayRequest>['requestGateway']
function Probe() {
requestGateway = useGatewayRequest().requestGateway
return null
}

// The hook reads the active gateway from the $gateway store.
$gatewayState.set('open')
$gateway.set(fakeGateway as never)
render(<Probe />)

return { calls, fakeGateway, requestGateway: () => requestGateway }
}

describe('useGatewayRequest — client source tagging', () => {
beforeEach(() => {
$gatewayState.set('open')
})

afterEach(() => {
cleanup()
$gateway.set(null)
vi.restoreAllMocks()
})

it("stamps source:'desktop' on session.create", async () => {
const { calls, requestGateway } = mountHook()

await act(async () => {
await requestGateway()('session.create', { cols: 96 })
})

expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
method: 'session.create',
params: { cols: 96, source: 'desktop' }
})
})

it("stamps source:'desktop' on session.resume", async () => {
const { calls, requestGateway } = mountHook()

await act(async () => {
await requestGateway()('session.resume', { session_id: 's1' })
})

expect(calls[0].params).toMatchObject({ session_id: 's1', source: 'desktop' })
})

it('does NOT add source to non-session-originating methods', async () => {
const { calls, requestGateway } = mountHook()

await act(async () => {
await requestGateway()('session.steer', { session_id: 's1', text: 'hi' })
})

expect(calls[0].params).not.toHaveProperty('source')
})

it('never overrides an explicit caller-provided source', async () => {
const { calls, requestGateway } = mountHook()

await act(async () => {
await requestGateway()('session.create', { source: 'tool', close_on_disconnect: true })
})

expect(calls[0].params).toMatchObject({ source: 'tool' })
})
})
26 changes: 24 additions & 2 deletions apps/desktop/src/app/gateway/hooks/use-gateway-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gate
import { $activeGatewayProfile } from '@/store/profile'
import { $gatewayState, setConnection } from '@/store/session'

// This client's source label, threaded to the backend on session-originating
// RPCs so turns from the desktop app are attributed to "desktop" rather than
// the shared "tui" default. The backend sanitizes it (see
// tui_gateway.server._sanitize_client_source).
const CLIENT_SOURCE = 'desktop'

// RPC methods that MINT or (RE)ATTACH a session and therefore build an agent
// whose platform should carry the client label. Other methods (steer, title,
// usage, …) act on an existing session and ignore a `source` field.
const SESSION_ORIGIN_METHODS = new Set(['session.create', 'session.resume'])

export function useGatewayRequest() {
const gatewayState = useStore($gatewayState)
const gatewayRef = useRef<HermesGateway | null>(null)
Expand Down Expand Up @@ -101,8 +112,19 @@ export function useGatewayRequest() {
throw new Error('Hermes gateway unavailable')
}

// Tag session-originating calls with this client so the backend records
// the turn's platform/source as "desktop" instead of the shared "tui"
// default (the desktop app, dashboard, and Ink TUI all drive the same
// JSON-RPC server). Only stamp when the caller hasn't set an explicit
// source, and only on the methods that mint/attach a session — every
// other RPC ignores the field.
const outbound =
SESSION_ORIGIN_METHODS.has(method) && params.source === undefined
? { ...params, source: CLIENT_SOURCE }
: params

try {
return await gateway.request<T>(method, params, timeoutMs, signal)
return await gateway.request<T>(method, outbound, timeoutMs, signal)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)

Expand All @@ -128,7 +150,7 @@ export function useGatewayRequest() {
throw error
}

return recovered.request<T>(method, params, timeoutMs, signal)
return recovered.request<T>(method, outbound, timeoutMs, signal)
}
},
[ensureGatewayOpen]
Expand Down
6 changes: 3 additions & 3 deletions tests/test_tui_gateway_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5372,7 +5372,7 @@ def __init__(self):
release_build = threading.Event()
build_entered = threading.Event()

def _slow_make_agent(sid, key, session_id=None, session_db=None):
def _slow_make_agent(sid, key, session_id=None, session_db=None, **_kw):
build_started.set()
build_entered.set()
release_build.wait(timeout=3.0)
Expand Down Expand Up @@ -5494,7 +5494,7 @@ def __init__(self):
self.base_url = ""
self.api_key = ""

monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
monkeypatch.setattr(
server,
Expand Down Expand Up @@ -5611,7 +5611,7 @@ def __init__(self):

emits = []

monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None: _FakeAgent())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_db=None, **_kw: _FakeAgent())
monkeypatch.setattr(server, "_SlashWorker", _FakeWorker)
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(server, "_session_info", lambda _a, *a2: {"model": "x"})
Expand Down
110 changes: 110 additions & 0 deletions tests/tui_gateway/test_client_source_platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""Client-source → platform attribution for the shared tui_gateway server.

The desktop app, dashboard SPA, Ink stdio TUI, and (future) mobile client all
drive the same JSON-RPC ``tui_gateway.server``. Before this feature every one of
them recorded turns as ``platform="tui"``; a client now self-declares a
``source`` on session.create/resume and it must:

* flow through ``_make_agent`` to ``AIAgent(platform=...)`` (→ blackbox
``turns.platform`` → tokens.ace source chart), and
* be sanitized, because it is untrusted client input feeding a persisted DB
dimension.

These are behavior contracts (how source relates to the agent's platform), not
snapshots of a frozen value. Mirrors test_make_agent_provider.py's import style
(no sys.modules patching — that stubs get_hermes_home to a str and breaks the
tool-registry imports _make_agent pulls in).
"""

from unittest.mock import MagicMock, patch

import pytest


# ---------------------------------------------------------------------------
# _sanitize_client_source — the untrusted-input guard
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
"raw,expected",
[
("desktop", "desktop"),
("dashboard", "dashboard"),
("mobile", "mobile"),
("tui", "tui"),
("DESKTOP", "desktop"), # case-normalized
(" desktop ", "desktop"), # trimmed
("web-app_2", "web-app_2"), # allowed slug chars
# Rejected → fall back to the stdio default, never raw-through:
("", "tui"),
(None, "tui"),
("tui; DROP TABLE turns", "tui"), # spaces/punctuation rejected
("<script>", "tui"),
("has spaces", "tui"),
("1leading-digit", "tui"), # must start with a letter
("x" * 40, "tui"), # over length cap
(123, "tui"), # non-string
(True, "tui"), # a JSON-RPC `true` must NOT coerce to the slug "true"
(0, "tui"), # non-string falsy
([], "tui"), # non-string container
],
)
def test_sanitize_client_source(raw, expected):
from tui_gateway.server import _sanitize_client_source

assert _sanitize_client_source(raw) == expected


# ---------------------------------------------------------------------------
# _make_agent — source drives the agent's platform
# ---------------------------------------------------------------------------


def _make_agent_platform_for_source(source):
"""Invoke _make_agent with the heavy deps stubbed; return AIAgent's platform."""
fake_runtime = {
"provider": "anthropic",
"base_url": "https://api.anthropic.com",
"api_key": "sk-test",
"api_mode": "anthropic_messages",
"command": None,
"args": None,
"credential_pool": None,
}
fake_cfg = {
"model": {"default": "claude-opus-4-8", "provider": "anthropic"},
"agent": {"system_prompt": "test"},
}
with (
patch("tui_gateway.server._load_cfg", return_value=fake_cfg),
patch("tui_gateway.server._get_db", return_value=MagicMock()),
patch("tui_gateway.server._load_tool_progress_mode", return_value="compact"),
patch("tui_gateway.server._load_reasoning_config", return_value=None),
patch("tui_gateway.server._load_service_tier", return_value=None),
patch("tui_gateway.server._load_enabled_toolsets", return_value=None),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value=fake_runtime,
),
patch("run_agent.AIAgent") as mock_agent,
):
from tui_gateway.server import _make_agent

_make_agent("sid-1", "key-1", source=source)
return mock_agent.call_args.kwargs["platform"]


def test_make_agent_source_becomes_platform():
"""A declared client source is the agent's platform verbatim."""
assert _make_agent_platform_for_source("desktop") == "desktop"


def test_make_agent_default_source_is_tui():
"""No source (stdio Ink / internal rebuild) keeps the historical default."""
assert _make_agent_platform_for_source(None) == "tui"


def test_make_agent_sanitizes_hostile_source():
"""_make_agent doesn't trust its caller: a malformed source → tui."""
assert _make_agent_platform_for_source("not a slug!") == "tui"
10 changes: 5 additions & 5 deletions tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):
]

monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kw: object())
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"})

Expand Down Expand Up @@ -509,7 +509,7 @@ def get_messages_as_conversation(self, _sid, include_ancestors=False):
return [multimodal_user, text_only_assistant]

monkeypatch.setattr(server, "_get_db", lambda: _DB())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None: object())
monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None, session_db=None, **_kw: object())
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None)
monkeypatch.setattr(server, "_session_info", lambda _agent, _session=None: {"model": "test/model"})

Expand Down Expand Up @@ -795,7 +795,7 @@ def __init__(self, sid, session_id):
def close(self):
closed_sids.append(self.sid)

def make_agent(sid, key, session_id=None, session_db=None):
def make_agent(sid, key, session_id=None, session_db=None, **_kw):
created_sids.append(sid)
first_agent_started.set()
assert agent_can_finish.wait(timeout=1)
Expand Down Expand Up @@ -1006,7 +1006,7 @@ def close(self):
monkeypatch.setattr(
server,
"_make_agent",
lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace(
lambda _sid, key, session_id=None, session_db=None, **_kw: types.SimpleNamespace(
model="test/model", session_id=session_id or key
),
)
Expand Down Expand Up @@ -1144,7 +1144,7 @@ def set_session_title(self, _key, _title):
monkeypatch.setattr(
server,
"_make_agent",
lambda _sid, key, session_id=None, session_db=None: types.SimpleNamespace(
lambda _sid, key, session_id=None, session_db=None, **_kw: types.SimpleNamespace(
model="test/model", session_id=session_id or key
),
)
Expand Down
Loading
Loading