From 0e7eebc2662e5dce9ebdc9f39af4157aec398e59 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 31 Aug 2026 19:54:01 -0500 Subject: [PATCH] fix(desktop): scope remote model catalog and primary-label REST to the focused profile A shared dashboard's launch HERMES_HOME is not the selected profile. model.options now runs under @_profile_scoped, and global-remote REST keeps ?profile= even for the primary label. Co-authored-by: fangliquanflq --- .../electron/connection-config.test.ts | 8 ++--- apps/desktop/electron/connection-config.ts | 16 ++++++++-- .../test_model_options_profile_scope.py | 29 +++++++++++++++++++ tui_gateway/methods_complete.py | 1 + 4 files changed, 48 insertions(+), 6 deletions(-) create mode 100644 tests/tui_gateway/test_model_options_profile_scope.py diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 9cc7cf66cf..97d006c6cf 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -334,10 +334,10 @@ const ROUTES = [ expected: { backend: 'primary', descriptorProfile: null, scopePath: false } }, { - name: 'a renamed primary profile still owns the window backend', + name: 'a renamed primary profile on a global remote is still scoped on the wire', profile: ' coder ', opts: { primaryProfile: 'coder', globalRemote: true }, - expected: { backend: 'primary', descriptorProfile: null, scopePath: false } + expected: { backend: 'primary', descriptorProfile: 'coder', scopePath: true } }, { name: 'an unset profile resolves to the primary', @@ -526,14 +526,14 @@ test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => ) }) -test('pathWithGlobalRemoteProfile skips the primary profile, which the remote already serves', () => { +test('pathWithGlobalRemoteProfile scopes the primary label because the dashboard launch home may differ', () => { assert.equal( pathWithGlobalRemoteProfile('/api/model/info', 'coder', { globalRemote: true, primaryProfile: 'coder', profileRemoteOverride: false }), - '/api/model/info' + '/api/model/info?profile=coder' ) }) diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 986758a598..512a7eccc2 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -653,7 +653,9 @@ function localPrimaryRequestScope(opts: ProfileRouteOptions): boolean | null { * The one place that answers "which backend serves profile P, and does its * REST path need a profile scope?". Six routes, in precedence order: * - * 1. The primary profile owns the window backend outright. + * 1. The primary profile owns a local/window backend outright; on a global + * remote its label is still carried per request because launch home can + * differ from the selected profile. * 2. A profile with its own remote override gets a pooled descriptor for that * host, which is already scoped to it. * 3. A profile inheriting the app-global remote shares the primary backend — @@ -673,10 +675,20 @@ function resolveProfileBackendRoute(profile, opts: ProfileRouteOptions = {}): Pr const scopedProfile = connectionScopeKey(profile) const primaryProfile = connectionScopeKey(opts.primaryProfile) || 'default' - if (!scopedProfile || scopedProfile === primaryProfile) { + if (!scopedProfile) { return { backend: 'primary', descriptorProfile: null, scopePath: false } } + if (scopedProfile === primaryProfile) { + // A global remote is a multi-profile dashboard, not a backend process + // launched for this Desktop label. Even its "primary" label must travel on + // the wire: the dashboard's process HERMES_HOME can belong to a different + // launch profile, so a bare request silently reads that profile instead. + return opts.globalRemote + ? { backend: 'primary', descriptorProfile: scopedProfile, scopePath: true } + : { backend: 'primary', descriptorProfile: null, scopePath: false } + } + if (opts.profileRemoteOverride) { return { backend: 'pool', descriptorProfile: null, scopePath: false } } diff --git a/tests/tui_gateway/test_model_options_profile_scope.py b/tests/tui_gateway/test_model_options_profile_scope.py new file mode 100644 index 0000000000..53779f32d5 --- /dev/null +++ b/tests/tui_gateway/test_model_options_profile_scope.py @@ -0,0 +1,29 @@ +from pathlib import Path + +import tui_gateway.server as server + + +def test_model_options_binds_requested_profile_home(monkeypatch, tmp_path): + profile_home = tmp_path / "profiles" / "fred-work" + profile_home.mkdir(parents=True) + seen = {} + + monkeypatch.setattr( + server, + "_profile_home", + lambda name: profile_home if name == "fred-work" else None, + ) + monkeypatch.setattr(server, "_model_picker_context", lambda agent: object()) + + def build_payload(ctx, **kwargs): + from hermes_constants import get_hermes_home + + seen["home"] = Path(get_hermes_home()) + return {"providers": []} + + monkeypatch.setattr("hermes_cli.inventory.build_model_options_payload", build_payload) + + response = server._methods["model.options"](1, {"profile": "fred-work"}) + + assert response["result"] == {"providers": []} + assert seen["home"] == profile_home \ No newline at end of file diff --git a/tui_gateway/methods_complete.py b/tui_gateway/methods_complete.py index 08b1c02b3f..d32dc407ed 100644 --- a/tui_gateway/methods_complete.py +++ b/tui_gateway/methods_complete.py @@ -467,6 +467,7 @@ def _(rid, params: dict) -> dict: @method("model.options") +@_profile_scoped def _(rid, params: dict) -> dict: try: from hermes_cli.inventory import build_model_options_payload