diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 98c75385afc51..6485a323bfbff 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -118,6 +118,7 @@ describe("DesktopConnectionCatalogStore", () => { withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + assert.isFalse(yield* store.canSave); assert.isFalse(yield* store.set("{}")); assert.deepStrictEqual(yield* store.get, Option.none()); }), diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 3240df3636e38..01853996b9ee5 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -157,6 +157,7 @@ export class DesktopConnectionCatalogStore extends Context.Service< | DesktopConnectionCatalogStoreMigrationError | DesktopConnectionCatalogStoreProtectionError >; + readonly canSave: Effect.Effect; readonly set: ( catalog: string, ) => Effect.Effect< @@ -486,6 +487,7 @@ export const make = Effect.gen(function* () { ); return Option.some(decrypted); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), + canSave: encryptionAvailable.pipe(Effect.withSpan("desktop.connectionCatalogStore.canSave")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { if (!(yield* encryptionAvailable)) { return false; diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 6a1b1c62e7471..3dd6e747f8560 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -4,7 +4,11 @@ import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./metho import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; -import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; +import { + canSaveConnectionCatalog, + getConnectionCatalog, + setConnectionCatalog, +} from "./methods/connectionCatalog.ts"; import { getHostEnvironmentId, getLocalEnvironmentEnabled, @@ -122,6 +126,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(dismissSnapShotAnimation); yield* ipc.handle(acknowledgeSnapShot); yield* ipc.handle(setConnectionCatalog); + yield* ipc.handle(canSaveConnectionCatalog); yield* ipc.handle(discoverSshHosts); yield* ipc.handle(resolveSshHost); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..d4f596c2a8b18 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -61,6 +61,7 @@ export const DISMISS_SNAP_SHOT_ANIMATION_CHANNEL = "desktop:dismiss-snap-shot-an export const ACKNOWLEDGE_SNAP_SHOT_CHANNEL = "desktop:acknowledge-snap-shot"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; export const SET_CONNECTION_CATALOG_CHANNEL = "desktop:set-connection-catalog"; +export const CAN_SAVE_CONNECTION_CATALOG_CHANNEL = "desktop:can-save-connection-catalog"; export const DISCOVER_SSH_HOSTS_CHANNEL = "desktop:discover-ssh-hosts"; export const RESOLVE_SSH_HOST_CHANNEL = "desktop:resolve-ssh-host"; export const ENSURE_SSH_ENVIRONMENT_CHANNEL = "desktop:ensure-ssh-environment"; diff --git a/apps/desktop/src/ipc/methods/connectionCatalog.ts b/apps/desktop/src/ipc/methods/connectionCatalog.ts index 0f913d0f300a3..aaa1ee8b64ca2 100644 --- a/apps/desktop/src/ipc/methods/connectionCatalog.ts +++ b/apps/desktop/src/ipc/methods/connectionCatalog.ts @@ -25,3 +25,13 @@ export const setConnectionCatalog = DesktopIpc.makeIpcMethod({ return yield* store.set(catalog); }), }); + +export const canSaveConnectionCatalog = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.CAN_SAVE_CONNECTION_CATALOG_CHANNEL, + payload: Schema.Void, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.connectionCatalog.canSave")(function* () { + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + return yield* store.canSave; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..667fcb0121dd3 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -163,6 +163,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { getConnectionCatalog: () => ipcRenderer.invoke(IpcChannels.GET_CONNECTION_CATALOG_CHANNEL), setConnectionCatalog: (catalog) => ipcRenderer.invoke(IpcChannels.SET_CONNECTION_CATALOG_CHANNEL, catalog), + canSaveConnectionCatalog: () => + ipcRenderer.invoke(IpcChannels.CAN_SAVE_CONNECTION_CATALOG_CHANNEL), discoverSshHosts: () => ipcRenderer.invoke(IpcChannels.DISCOVER_SSH_HOSTS_CHANNEL), resolveSshHost: (alias) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_HOST_CHANNEL, alias), ensureSshEnvironment: async (target, options) => diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 91a6ac71919da..57c99b0c8efa5 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -59,6 +59,7 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: Effect.void, register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( (registration, routes) => catalog diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index b25abe442ba11..9ea72c22866b4 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -482,11 +482,19 @@ export interface CatalogBackend { readonly read: Effect.Effect; readonly write: (raw: string) => Effect.Effect; readonly quarantine?: (raw: string) => Effect.Effect; + /** Fails when `write` is known to be refused, without writing anything. */ + readonly assertWritable?: Effect.Effect; } export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { const bridge = window.desktopBridge; if (bridge !== undefined) { + // The pairing failure toast clamps descriptions of 180+ characters, which would hide the fix. + const unsavedReason = navigator.platform.startsWith("Linux") + ? "No unlocked keyring. Unlock GNOME Keyring or KWallet, then restart T3 Code." + : "Secure storage is unavailable. Unlock your keychain, then restart T3 Code."; + const failUnlessSaved = (saved: boolean) => + saved ? Effect.void : Effect.fail(catalogError("save", unsavedReason)); return { read: Effect.tryPromise({ try: () => bridge.getConnectionCatalog(), @@ -496,18 +504,11 @@ export function makeCatalogBackend(database: DatabaseHandle): CatalogBackend { Effect.tryPromise({ try: () => bridge.setConnectionCatalog(raw), catch: (cause) => catalogError("save", cause), - }).pipe( - Effect.flatMap((stored) => - stored - ? Effect.void - : Effect.fail( - catalogError( - "save", - "Desktop secure storage is unavailable in this system context.", - ), - ), - ), - ), + }).pipe(Effect.flatMap(failUnlessSaved)), + assertWritable: Effect.tryPromise({ + try: () => bridge.canSaveConnectionCatalog(), + catch: (cause) => catalogError("save", cause), + }).pipe(Effect.flatMap(failUnlessSaved)), }; } @@ -717,7 +718,8 @@ export const layer = Layer.effectContext( makeDatabaseHandle(), (owned) => owned.close, ); - const catalog = yield* makeCatalogStore(makeCatalogBackend(database)); + const catalogBackend = makeCatalogBackend(database); + const catalog = yield* makeCatalogStore(catalogBackend); const pullRequestRoutingPermissions = makeBrowserPullRequestRoutingPermissions(); // Remembers each thread row's encoding, so a save only encodes rows that changed. const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder({ @@ -740,6 +742,9 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: (catalogBackend.assertWritable ?? Effect.void).pipe( + Effect.mapError((cause) => persistenceError("register-connection", cause)), + ), register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( function (registration, routes) { return catalog diff --git a/docs/user/install.md b/docs/user/install.md index 7b28781e69612..7c9fe176e0bdc 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -105,6 +105,9 @@ The AUR packages currently support x86_64 only; on ARM64, use the AppImage. Update AUR installs with your AUR helper, such as `yay -Syu`, instead of the in-app updater. +The desktop app needs an unlocked Secret Service keyring, such as GNOME +Keyring or KWallet, to save connections to other machines. + ### Nix (nixpkgs) T3 Code is maintained in [nixpkgs](https://github.com/NixOS/nixpkgs/tree/nixos-unstable/pkgs/by-name/t3/t3code). diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts index 2131507e547f7..6ccb8965dd119 100644 --- a/packages/client-runtime/src/connection/onboarding.test.ts +++ b/packages/client-runtime/src/connection/onboarding.test.ts @@ -159,10 +159,18 @@ function layerPairingHttp( return RpcHttp.layerRemoteHttpClient(fetchFn); } +const SAVE_ERROR = new Persistence.ConnectionPersistenceError({ + operation: "register-connection", + message: "No unlocked keyring.", +}); + function layerPairingOnboarding( - options?: Parameters[1], + options?: Parameters[1] & { + readonly saveError?: Persistence.ConnectionPersistenceError; + }, calls: Array<{ readonly url: string; readonly init: RequestInit }> = [], ) { + const saveError = options?.saveError; const dependencies = Layer.mergeAll( layerClientPresentation, layerPairingHttp(calls, options), @@ -171,7 +179,8 @@ function layerPairingOnboarding( listDisabled: Effect.succeed([]), }), Layer.mock(Persistence.ConnectionRegistrationStore)({ - register: () => Effect.void, + assertWritable: saveError === undefined ? Effect.void : Effect.fail(saveError), + register: () => (saveError === undefined ? Effect.void : Effect.fail(saveError)), setEnabled: () => Effect.void, }), Layer.mock(Persistence.EnvironmentCacheStore)({}), @@ -455,11 +464,12 @@ describe("connection onboarding", () => { unsupportedReason: UNSUPPORTED_ERROR.message, }); + // The host owns this entry, so storage that refuses saves must not block pairing. const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT); expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID); expect(yield* SubscriptionRef.get(registry.entries)).toEqual(before); - }).pipe(Effect.provide(layerPairingOnboarding())), + }).pipe(Effect.provide(layerPairingOnboarding({ saveError: SAVE_ERROR }))), ); it.effect("leaves a switched-off environment unchanged when the pairing exchange fails", () => @@ -482,6 +492,20 @@ describe("connection onboarding", () => { }).pipe(Effect.provide(layerPairingOnboarding({ failExchange: true }))), ); + it.effect("does not consume a pairing credential when the connection cannot be saved", () => { + const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; + return Effect.gen(function* () { + const onboarding = yield* Onboarding.ConnectionOnboarding; + + const error = yield* onboarding.registerPairing(PAIRING_INPUT).pipe(Effect.flip); + + expect(error).toEqual(SAVE_ERROR); + expect(calls.map((call) => call.url)).toEqual([ + "https://remote.example.test/.well-known/t3/environment", + ]); + }).pipe(Effect.provide(layerPairingOnboarding({ saveError: SAVE_ERROR }, calls))); + }); + it.effect("rejects an incompatible server without consuming the pairing credential", () => Effect.gen(function* () { const calls: Array<{ readonly url: string; readonly init: RequestInit }> = []; diff --git a/packages/client-runtime/src/connection/onboarding.ts b/packages/client-runtime/src/connection/onboarding.ts index 48e8b614c67c8..c4544d2e47533 100644 --- a/packages/client-runtime/src/connection/onboarding.ts +++ b/packages/client-runtime/src/connection/onboarding.ts @@ -138,6 +138,10 @@ export const preparePairingRegistration = Effect.fn( if (compatibilityError !== null && compatibilityError.serverUpdateRequired !== true) { return yield* compatibilityError; } + // A code redeemed for a connection that cannot be saved is spent for nothing. + yield* (yield* EnvironmentRegistry.EnvironmentRegistry).assertCanRegister( + descriptor.environmentId, + ); const access = yield* bootstrapRemoteBearerSession({ httpBaseUrl: target.httpBaseUrl, credential: target.credential, diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f6c305ab6bb90..ca69ece7fe0f2 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -210,6 +210,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( listDisabled: Ref.get(storedDisabled).pipe(Effect.map((ids) => [...ids])), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ + assertWritable: Effect.void, register: (registration, routes) => Effect.gen(function* () { yield* options?.beforeRegistrationRegister?.(registration) ?? Effect.void; diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 12f8aa5ec505f..562d2cfa50524 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -115,6 +115,13 @@ export class EnvironmentRegistry extends Context.Service< readonly assertKnownAddress: ( route: ConnectionRoute, ) => Effect.Effect; + /** + * Fails when `register` would be refused for lack of writable storage. Platform-managed + * environments pass, since `register` saves nothing for them. + */ + readonly assertCanRegister: ( + environmentId: EnvironmentId, + ) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, ) => Effect.Effect; @@ -558,6 +565,13 @@ export const make = Effect.gen(function* () { } }); + const assertCanRegister = Effect.fn("EnvironmentRegistry.assertCanRegister")(function* ( + environmentId: EnvironmentId, + ) { + if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) return; + yield* registrations.assertWritable; + }); + const register = Effect.fn("EnvironmentRegistry.register")(function* ( registration: ConnectionRegistration, options?: { readonly knownAddressOnly?: boolean }, @@ -1189,6 +1203,7 @@ export const make = Effect.gen(function* () { start, register, assertKnownAddress, + assertCanRegister, reconcilePlatform, remove, removeRoute, diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 6476b80221225..f38c6ad63ae21 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -60,6 +60,11 @@ export class ConnectionTargetStore extends Context.Service< export class ConnectionRegistrationStore extends Context.Service< ConnectionRegistrationStore, { + /** + * Fails when saves are known to be refused, such as when the desktop cannot encrypt them. + * Passing does not guarantee that `register` will succeed. + */ + readonly assertWritable: Effect.Effect; /** * Records of routes missing from `routes` are dropped. */ diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 460f7e593990a..dcd80534969d6 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -133,6 +133,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? start: Effect.void, register: () => Effect.die("Unexpected environment registration"), assertKnownAddress: () => Effect.die("Unexpected address check"), + assertCanRegister: () => Effect.die("Unexpected storage check"), reconcilePlatform: () => Effect.die("Unexpected environment reconciliation"), remove: () => Effect.die("Unexpected environment removal"), removeRoute: () => Effect.die("Unexpected route removal"), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 29be3d6d0219f..c68f25b65e664 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1062,6 +1062,7 @@ export interface DesktopBridge { setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog: () => Promise; setConnectionCatalog: (catalog: string) => Promise; + canSaveConnectionCatalog: () => Promise; discoverSshHosts: () => Promise; resolveSshHost: (alias: string) => Promise; requestSnapShotPermissions: (includeAccessibility: boolean) => Promise;