From 08e1eea2f3c1240effe61e4eb0178ab2fed322bd Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 4 Oct 2026 13:54:09 -0500 Subject: [PATCH] fix: tunnels and host settings missing from shared hosts on desktop --- RELEASE_NOTES.md | 1 + src/backend/sync/entities.ts | 4 ++ src/backend/sync/host-plugin-settings.ts | 37 +++++++++++++--- .../tests/sync/host-plugin-settings.test.ts | 44 +++++++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 2b7d53d82..d6fbb8a11 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,6 +44,7 @@ https://youtu.be/lngaePO96tM - Host editor tabs being hidden when they did not fit - SSH host keys being accepted without a check when the host was missing from the database - The "last login failed" host status showing in English or mistranslated in several languages +- Tunnels and other host settings missing from shared hosts in the desktop app - SSO and LDAP provider dialogs overflowing the screen and using mismatched toggles - Plugin audit log entries failing to save when no user was signed in diff --git a/src/backend/sync/entities.ts b/src/backend/sync/entities.ts index acf5e1a54..77e4dc673 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -546,6 +546,10 @@ async function loadSharedHosts(userId: string): Promise { : value; } wire.jumpHosts = await jumpHostsToSyncIds(host.jumpHosts); + wire.pluginSettings = await exportHostPluginSettings(hostId, { + userId, + permissionLevel, + }); wire.syncId = host.syncId; wire.shared = { hostId, diff --git a/src/backend/sync/host-plugin-settings.ts b/src/backend/sync/host-plugin-settings.ts index 110d7527a..e68e05c28 100644 --- a/src/backend/sync/host-plugin-settings.ts +++ b/src/backend/sync/host-plugin-settings.ts @@ -15,6 +15,7 @@ import { databaseLogger } from "../utils/logger.js"; import type { DefaultOverrides } from "../../types/host-defaults.js"; import { hostSettingsPlugins, + withHostPluginSettings, type HostPluginSettings, } from "../database/routes/host-plugin-settings.js"; @@ -23,19 +24,45 @@ export interface PluginHostSettingsSync { importValue?: (key: string, value: unknown) => unknown | Promise; } -/** What a host carries to the other side of a sync pair. */ +/** Who a shared host's copy is for, and at what share level. */ +export interface SharedHostViewer { + userId: string; + permissionLevel: string; +} + +/** + * What a host carries to the other side of a sync pair. With a viewer, only + * what that user sees of a host shared with them, as the host routes show it. + */ export async function exportHostPluginSettings( hostId: number, + viewer?: SharedHostViewer, ): Promise { + const visible = viewer + ? ((( + await withHostPluginSettings( + { + id: hostId, + isShared: true, + permissionLevel: viewer.permissionLevel, + }, + viewer.userId, + ) + ).pluginSettings as HostPluginSettings | undefined) ?? {}) + : null; const result: HostPluginSettings = {}; for (const manifest of hostSettingsPlugins()) { const fields = declaredFields(manifest, "host").filter( - (field) => field.type !== "secret", + (field) => + field.type !== "secret" && + (!visible || field.key in (visible[manifest.id] ?? {})), ); if (fields.length === 0) continue; - const values = await getAllSettings(manifest, "host", hostId, { - redactSecrets: true, - }); + const values = visible + ? visible[manifest.id] + : await getAllSettings(manifest, "host", hostId, { + redactSecrets: true, + }); const hook = consume( `${manifest.id}.hostSettingsSync`, ); diff --git a/src/backend/tests/sync/host-plugin-settings.test.ts b/src/backend/tests/sync/host-plugin-settings.test.ts index bb2a887d5..424d94574 100644 --- a/src/backend/tests/sync/host-plugin-settings.test.ts +++ b/src/backend/tests/sync/host-plugin-settings.test.ts @@ -11,10 +11,19 @@ const h = vi.hoisted(() => ({ writes: [] as Array<[string, number, string, unknown]>, hooks: new Map(), manifests: [] as PluginManifest[], + visible: null as Record> | null, + viewerCalls: [] as Array<[Record, string | undefined]>, })); vi.mock("../../database/routes/host-plugin-settings.js", () => ({ hostSettingsPlugins: () => h.manifests, + withHostPluginSettings: async ( + host: Record, + viewerId?: string, + ) => { + h.viewerCalls.push([host, viewerId]); + return h.visible ? { ...host, pluginSettings: h.visible } : host; + }, })); vi.mock("../../plugins/registry.js", () => ({ consume: (key: string) => h.hooks.get(key), @@ -65,11 +74,27 @@ const VAULT = { }, } as unknown as PluginManifest; +const TUNNELS = { + id: "tunnels", + contributes: { + settings: { + host: { + fields: [ + { key: "enableTunnel", type: "boolean", labelKey: "k" }, + { key: "tunnelConnections", type: "custom", labelKey: "k" }, + ], + }, + }, + }, +} as unknown as PluginManifest; + beforeEach(() => { h.stored.clear(); h.writes.length = 0; h.hooks.clear(); h.manifests = [VAULT]; + h.visible = null; + h.viewerCalls.length = 0; h.hooks.set("vault.hostSettingsSync", { exportValue: (key: string, value: unknown) => key === "profileId" && value === 4 ? "profile-sync-4" : value, @@ -87,6 +112,25 @@ describe("host plugin settings over sync", () => { }); }); + it("exports only what a shared host's viewer sees", async () => { + h.manifests = [VAULT, TUNNELS]; + h.visible = { + vault: { token: { set: true } }, + tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] }, + }; + expect( + await exportHostPluginSettings(1, { + userId: "viewer", + permissionLevel: "connect", + }), + ).toEqual({ + tunnels: { enableTunnel: true, tunnelConnections: [{ sourcePort: 22 }] }, + }); + expect(h.viewerCalls).toEqual([ + [{ id: 1, isShared: true, permissionLevel: "connect" }, "viewer"], + ]); + }); + it("imports them back to local ids", async () => { await importHostPluginSettings(9, { vault: { profileId: "profile-sync-4", token: "ignored" },