From dbe825993760127f83cf65b7d96c83c35b401d39 Mon Sep 17 00:00:00 2001 From: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:32:51 -0700 Subject: [PATCH 1/3] feat(tui_gateway): client-identity source so desktop/dashboard/mobile aren't all "tui" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app, the in-browser dashboard chat, and the Ink stdio TUI all drive the same tui_gateway JSON-RPC server, which hardcoded platform="tui" on every agent it built. So every desktop-app turn recorded platform="tui" in blackbox (turns.platform) — the source dimension on the token/cost dashboard couldn't tell the desktop client from the terminal, and a future mobile app would fold in too. Let a client self-declare a `source` on session.create/resume; thread it through _make_agent -> AIAgent(platform=...) (which already feeds the blackbox on_session_end hook), and store it on the session dict so the deferred/lazy build, /new reset, resume, and branch paths all preserve it. `source` is untrusted client input feeding a persisted DB dimension, so it is sanitized to a conservative slug (`^[a-z][a-z0-9_-]{0,23}$`) with a `tui` fallback — the label stays open-ended (new clients name themselves; no central enum to keep in sync, per the existing "fixed set of platform names goes stale" note). Desktop app: stamp source:'desktop' once, centrally, in the requestGateway wrapper for the session-originating methods (create/resume) — never overriding an explicit caller source (e.g. the dashboard sidecar's 'tool'). No schema/blackbox change (the platform pipe already existed); stdio Ink and internal rebuilds keep the historical "tui" default so nothing regresses. Historical desktop turns are indistinguishable from real TUI turns, so the clean split starts at deploy (no backfill possible). Tests: tui_gateway/test_client_source_platform.py (sanitizer table + _make_agent platform derivation); desktop use-gateway-request.test.tsx (injection contract: stamps create/resume, skips other methods, never overrides explicit source). --- .../hooks/use-gateway-request.test.tsx | 94 +++++++++++++++ .../app/gateway/hooks/use-gateway-request.ts | 26 ++++- .../test_client_source_platform.py | 107 ++++++++++++++++++ tests/tui_gateway/test_protocol.py | 10 +- tui_gateway/server.py | 72 +++++++++++- 5 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx create mode 100644 tests/tui_gateway/test_client_source_platform.py diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx new file mode 100644 index 0000000000000..ebb960fa5ed05 --- /dev/null +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.test.tsx @@ -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 } + +function mountHook() { + const calls: RequestCall[] = [] + const fakeGateway = { + request: vi.fn(async (method: string, params: Record) => { + calls.push({ method, params }) + return { ok: true } + }) + } + + let requestGateway!: ReturnType['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() + + 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' }) + }) +}) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index d6c9ab0e029bc..e568a4c1808b5 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -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(null) @@ -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(method, params, timeoutMs, signal) + return await gateway.request(method, outbound, timeoutMs, signal) } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -128,7 +150,7 @@ export function useGatewayRequest() { throw error } - return recovered.request(method, params, timeoutMs, signal) + return recovered.request(method, outbound, timeoutMs, signal) } }, [ensureGatewayOpen] diff --git a/tests/tui_gateway/test_client_source_platform.py b/tests/tui_gateway/test_client_source_platform.py new file mode 100644 index 0000000000000..01dd95d71148f --- /dev/null +++ b/tests/tui_gateway/test_client_source_platform.py @@ -0,0 +1,107 @@ +"""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 + ("