Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/app/DesktopConnectionCatalogStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class DesktopConnectionCatalogStore extends Context.Service<
| DesktopConnectionCatalogStoreMigrationError
| DesktopConnectionCatalogStoreProtectionError
>;
readonly canSave: Effect.Effect<boolean, DesktopConnectionCatalogStoreProtectionError>;
readonly set: (
catalog: string,
) => Effect.Effect<
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/connectionCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}),
});
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 18 additions & 13 deletions apps/web/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,11 +482,19 @@ export interface CatalogBackend {
readonly read: Effect.Effect<string | null, ConnectionTransientError>;
readonly write: (raw: string) => Effect.Effect<void, ConnectionTransientError>;
readonly quarantine?: (raw: string) => Effect.Effect<void, ConnectionTransientError>;
/** Fails when `write` is known to be refused, without writing anything. */
readonly assertWritable?: Effect.Effect<void, ConnectionTransientError>;
}

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(),
Expand All @@ -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)),
};
}

Expand Down Expand Up @@ -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({
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
30 changes: 27 additions & 3 deletions packages/client-runtime/src/connection/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof layerPairingHttp>[1],
options?: Parameters<typeof layerPairingHttp>[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),
Expand All @@ -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)({}),
Expand Down Expand Up @@ -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", () =>
Expand All @@ -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 }> = [];
Expand Down
4 changes: 4 additions & 0 deletions packages/client-runtime/src/connection/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/connection/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions packages/client-runtime/src/connection/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,13 @@ export class EnvironmentRegistry extends Context.Service<
readonly assertKnownAddress: (
route: ConnectionRoute,
) => Effect.Effect<void, ConnectionBlockedError>;
/**
* 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<void, Persistence.ConnectionPersistenceError>;
readonly reconcilePlatform: (
registrations: ReadonlyArray<PlatformConnectionRegistration>,
) => Effect.Effect<void>;
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -1189,6 +1203,7 @@ export const make = Effect.gen(function* () {
start,
register,
assertKnownAddress,
assertCanRegister,
reconcilePlatform,
remove,
removeRoute,
Expand Down
5 changes: 5 additions & 0 deletions packages/client-runtime/src/platform/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void, ConnectionPersistenceError>;
/**
* Records of routes missing from `routes` are dropped.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/state/threads-atoms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1062,6 +1062,7 @@ export interface DesktopBridge {
setClientSettings: (settings: ClientSettings) => Promise<void>;
getConnectionCatalog: () => Promise<string | null>;
setConnectionCatalog: (catalog: string) => Promise<boolean>;
canSaveConnectionCatalog: () => Promise<boolean>;
discoverSshHosts: () => Promise<readonly DesktopDiscoveredSshHost[]>;
resolveSshHost: (alias: string) => Promise<DesktopSshEnvironmentTarget>;
requestSnapShotPermissions: (includeAccessibility: boolean) => Promise<void>;
Expand Down
Loading