From 2646b649d7b457b3f1c15bfa36b7254a06d9ab36 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 4 Oct 2026 13:55:55 -0500 Subject: [PATCH] fix: remote desktop logins missing from shared hosts on desktop --- RELEASE_NOTES.md | 2 +- src/backend/sync/entities.ts | 4 +- src/backend/sync/host-protocol-auth.ts | 42 ++++++++++++ .../tests/sync/host-protocol-auth.test.ts | 68 +++++++++++++++++++ 4 files changed, 114 insertions(+), 2 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index d6fbb8a11..1d63ad78d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -44,7 +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 +- Tunnels, remote desktop logins 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 77e4dc673..131be6f1e 100644 --- a/src/backend/sync/entities.ts +++ b/src/backend/sync/entities.ts @@ -30,6 +30,7 @@ import { } from "./host-plugin-settings.js"; import { exportProtocolLogins, + exportSharedProtocolLogins, importProtocolLogins, } from "./host-protocol-auth.js"; import { @@ -477,7 +478,7 @@ async function writeAccountProfile(userId: string, wire: SyncRow) { } } -/** Host columns a shared copy never carries. */ +/** Host columns a shared copy never carries as the owner has them. */ const SHARED_HOST_DROP = [ "id", "userId", @@ -550,6 +551,7 @@ async function loadSharedHosts(userId: string): Promise { userId, permissionLevel, }); + wire.protocolAuth = await exportSharedProtocolLogins(host, userId); wire.syncId = host.syncId; wire.shared = { hostId, diff --git a/src/backend/sync/host-protocol-auth.ts b/src/backend/sync/host-protocol-auth.ts index 09c6c7593..ea195269f 100644 --- a/src/backend/sync/host-protocol-auth.ts +++ b/src/backend/sync/host-protocol-auth.ts @@ -6,12 +6,15 @@ import type { SyncRow } from "@termix/plugin-sdk/backend"; import type { HostProtocolLogin } from "../database/repositories/host-protocol-auth-repository.js"; +import { createCurrentHostProtocolAuthRepository } from "../database/repositories/factory.js"; import { fromPortableLogin, listProtocolLogins, replaceProtocolLogins, + resolveRecipientProtocolLogin, toPortableLogins, } from "../hosts/protocol-auth/protocol-auth.js"; +import { findHostProtocol } from "../hosts/protocol-auth/registry.js"; import { syncLogger } from "../utils/logger.js"; type ResolveSyncId = (entityType: string, id: number) => Promise; @@ -38,6 +41,45 @@ export async function exportProtocolLogins( return out; } +/** + * The logins a user a host is shared with may use, for their desktop's copy: + * their own override or the owner's shared snapshot, never the owner's own + * secret. Each goes as a direct login since its credential is not theirs. + */ +export async function exportSharedProtocolLogins( + host: Record, + userId: string, +): Promise { + const out: SyncRow = {}; + const hostId = Number(host.id); + const rows = + await createCurrentHostProtocolAuthRepository().listRowsForHost(hostId); + for (const row of rows) { + const declared = findHostProtocol(row.protocol); + if (!declared) continue; + try { + const login = await resolveRecipientProtocolLogin(host, userId, declared); + out[row.protocol] = { + authType: login.authType === "none" ? "none" : "direct", + username: login.username || null, + password: login.password || null, + fields: Object.fromEntries( + Object.entries(login.fields).filter(([, value]) => value), + ), + credentialSyncId: null, + }; + } catch (error) { + syncLogger.warn("Could not include a shared host's protocol login", { + operation: "sync_shared_host_protocol_auth", + hostId, + protocol: row.protocol, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return out; +} + /** * Writes what a synced host carried. A row from a peer that sends no * protocolAuth leaves the stored logins alone. diff --git a/src/backend/tests/sync/host-protocol-auth.test.ts b/src/backend/tests/sync/host-protocol-auth.test.ts index 896641771..088975131 100644 --- a/src/backend/tests/sync/host-protocol-auth.test.ts +++ b/src/backend/tests/sync/host-protocol-auth.test.ts @@ -8,6 +8,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ logins: [] as Array>, replaced: null as null | { ownerId: string; hostId: number; logins: unknown }, + rows: [] as Array<{ protocol: string }>, + recipient: new Map | Error>(), })); vi.mock( @@ -20,6 +22,15 @@ vi.mock( return { ...actual, listProtocolLogins: async () => state.logins, + resolveRecipientProtocolLogin: async ( + _host: unknown, + _userId: string, + declared: { id: string }, + ) => { + const login = state.recipient.get(declared.id); + if (login instanceof Error) throw login; + return login; + }, replaceProtocolLogins: async ( ownerId: string, hostId: number, @@ -30,6 +41,11 @@ vi.mock( }; }, ); +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentHostProtocolAuthRepository: () => ({ + listRowsForHost: async () => state.rows, + }), +})); vi.mock("../../database/db/index.js", () => ({ getDb: () => null, getSqlite: () => null, @@ -37,12 +53,15 @@ vi.mock("../../database/db/index.js", () => ({ import { exportProtocolLogins, + exportSharedProtocolLogins, importProtocolLogins, } from "../../sync/host-protocol-auth.js"; import { setHostProtocolSource } from "../../hosts/protocol-auth/registry.js"; beforeEach(() => { state.replaced = null; + state.rows = []; + state.recipient.clear(); state.logins = [ { protocol: "spice", @@ -129,3 +148,52 @@ describe("host protocol logins over sync", () => { expect(state.replaced).toBeNull(); }); }); + +describe("shared host protocol logins over sync", () => { + it("sends what the recipient may use as direct logins", async () => { + state.rows = [{ protocol: "spice" }, { protocol: "unknown" }]; + state.recipient.set("spice", { + authType: "credential", + username: "alice", + password: "pw", + fields: { display: "2", ticket: "" }, + }); + expect( + await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"), + ).toEqual({ + spice: { + authType: "direct", + username: "alice", + password: "pw", + fields: { display: "2" }, + credentialSyncId: null, + }, + }); + }); + + it("keeps a none login and skips one that fails", async () => { + setHostProtocolSource(() => [ + { id: "spice", credentialFields: [], pluginId: "p", pluginName: "P" }, + { id: "vnc", credentialFields: [], pluginId: "p", pluginName: "P" }, + ]); + state.rows = [{ protocol: "spice" }, { protocol: "vnc" }]; + state.recipient.set("spice", { + authType: "none", + username: "", + password: "", + fields: {}, + }); + state.recipient.set("vnc", new Error("locked")); + expect( + await exportSharedProtocolLogins({ id: 1, userId: "owner" }, "viewer"), + ).toEqual({ + spice: { + authType: "none", + username: null, + password: null, + fields: {}, + credentialSyncId: null, + }, + }); + }); +});