Skip to content
Closed
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.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ const migrateSavedEnvironmentRecords = Effect.fn(
return {
schemaVersion: 1,
targets,
routes: targets,
profiles,
credentials,
remoteDpopTokens: [],
Expand Down
27 changes: 26 additions & 1 deletion apps/mobile/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {
ConnectionRegistrationStore,
ConnectionTargetStore,
registerConnectionInCatalog,
removeConnectionRouteFromCatalog,
removeConnectionFromCatalog,
removeCatalogValue,
replaceCatalogValue,
selectConnectionRouteInCatalog,
connectionRoutes,
} from "@t3tools/client-runtime/platform";
import { TokenStore } from "@t3tools/client-runtime/authorization";
import {
Expand All @@ -20,7 +23,13 @@ import * as Option from "effect/Option";
import * as CatalogStore from "./catalog-store";

function targetPersistenceError(
operation: "list-targets" | "register-connection" | "remove-connection",
operation:
| "list-targets"
| "list-routes"
| "register-connection"
| "select-connection-route"
| "remove-connection-route"
| "remove-connection",
error: ConnectionTransientError,
) {
return new ConnectionPersistenceError({
Expand All @@ -38,12 +47,28 @@ export const connectionStorageLayer = Layer.effectContext(
Effect.map((document) => document.targets),
Effect.mapError((error) => targetPersistenceError("list-targets", error)),
),
listRoutes: catalog.read.pipe(
Effect.map(connectionRoutes),
Effect.mapError((error) => targetPersistenceError("list-routes", error)),
),
});
const registrationStore = ConnectionRegistrationStore.of({
register: (registration) =>
catalog
.update((document) => registerConnectionInCatalog(document, registration))
.pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),
select: (target) =>
catalog
.update((document) => selectConnectionRouteInCatalog(document, target))
.pipe(
Effect.mapError((error) => targetPersistenceError("select-connection-route", error)),
),
removeRoute: (target, fallback) =>
catalog
.update((document) => removeConnectionRouteFromCatalog(document, target, fallback))
.pipe(
Effect.mapError((error) => targetPersistenceError("remove-connection-route", error)),
),
remove: (target) =>
catalog
.update((document) => removeConnectionFromCatalog(document, target))
Expand Down
101 changes: 100 additions & 1 deletion apps/web/src/components/settings/ConnectionsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
type DesktopWslState,
type EnvironmentId,
} from "@t3tools/contracts";
import { connectionStatusText } from "@t3tools/client-runtime/connection";
import {
type ConnectionCatalogEntry,
connectionRouteId,
connectionStatusText,
} from "@t3tools/client-runtime/connection";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
Expand Down Expand Up @@ -1350,16 +1354,37 @@ function NetworkAccessDescription({

type SavedBackendListRowProps = {
environment: EnvironmentPresentation;
routes: ReadonlyArray<ConnectionCatalogEntry>;
removingEnvironmentId: EnvironmentId | null;
switchingRouteEnvironmentIds: ReadonlySet<EnvironmentId>;
onConnect: (environmentId: EnvironmentId) => void;
onRemove: (environmentId: EnvironmentId) => void;
onSelectRoute: (environmentId: EnvironmentId, routeId: string) => void;
};

function connectionRouteLabel(entry: ConnectionCatalogEntry): string {
switch (entry.target._tag) {
case "RelayConnectionTarget":
return "T3 Connect";
case "SshConnectionTarget":
return Option.isSome(entry.profile) && entry.profile.value._tag === "SshConnectionProfile"
? `SSH ${formatDesktopSshTarget(entry.profile.value.target)}`
: "SSH";
case "BearerConnectionTarget":
return "Direct";
case "PrimaryConnectionTarget":
return "Local";
}
}

function SavedBackendListRow({
environment,
routes,
removingEnvironmentId,
switchingRouteEnvironmentIds,
onConnect,
onRemove,
onSelectRoute,
}: SavedBackendListRowProps) {
const environmentId = environment.environmentId;
const connectionState = environment.connection.phase;
Expand Down Expand Up @@ -1478,6 +1503,43 @@ function SavedBackendListRow({
) : null}
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
{routes.length > 1 ? (
<Select
value={connectionRouteId(environment.entry.target)}
onValueChange={(value) => {
if (typeof value === "string") {
onSelectRoute(environmentId, value);
}
}}
>
<SelectTrigger
size="xs"
className="w-full min-w-0 sm:w-48"
aria-label={`Connection method for ${environment.label}`}
disabled={
switchingRouteEnvironmentIds.has(environmentId) ||
removingEnvironmentId === environmentId
}
>
<SelectValue>
{switchingRouteEnvironmentIds.has(environmentId)
? "Switching…"
: connectionRouteLabel(environment.entry)}
</SelectValue>
</SelectTrigger>
<SelectPopup align="end" alignItemWithTrigger={false}>
{routes.map((route) => (
<SelectItem
hideIndicator
key={connectionRouteId(route.target)}
value={connectionRouteId(route.target)}
>
{connectionRouteLabel(route)}
</SelectItem>
))}
</SelectPopup>
</Select>
) : null}
{versionMismatch &&
(serverUpdateState.status === "idle" || serverUpdateState.status === "failed") ? (
<ServerUpdateAction
Expand Down Expand Up @@ -1753,6 +1815,10 @@ export function ConnectionsSettings() {
});
const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false });
const retryEnvironment = useAtomCommand(environmentCatalog.retryNow, { reportFailure: false });
const selectEnvironmentRoute = useAtomCommand(environmentCatalog.selectRoute, {
reportFailure: false,
});
const environmentRoutes = useAtomValue(environmentCatalog.routesValueAtom);
const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null;
const primarySessionState = usePrimarySessionState();
const currentSessionScopes = desktopBridge
Expand Down Expand Up @@ -1804,6 +1870,9 @@ export function ConnectionsSettings() {
return keys;
}, [savedEnvironments]);
const [sshConnectionError, setSshConnectionError] = useState<string | null>(null);
const [switchingRouteEnvironmentIds, setSwitchingRouteEnvironmentIds] = useState<

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSH dedup ignores inactive routes

Medium Severity

savedDesktopSshEnvironmentKeys and savedDesktopSshEnvironmentsByAlias only inspect the selected catalog entry. When relay is selected but an SSH route already exists, that host still appears in unsaved discovered hosts. Choosing it re-runs SSH provisioning and forces a route switch instead of treating the host as already saved.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 236484a. Configure here.

ReadonlySet<EnvironmentId>
>(new Set());
const [connectingSshHostAlias, setConnectingSshHostAlias] = useState<string | null>(null);

const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState<
Expand Down Expand Up @@ -2265,6 +2334,33 @@ export function ConnectionsSettings() {
[retryEnvironment],
);

const handleSelectSavedBackendRoute = useCallback(
async (environmentId: EnvironmentId, routeId: string) => {
setSwitchingRouteEnvironmentIds((current) => new Set(current).add(environmentId));
setSavedBackendError(null);
const result = await selectEnvironmentRoute({ environmentId, routeId });
setSwitchingRouteEnvironmentIds((current) => {
const next = new Set(current);
next.delete(environmentId);
return next;
});
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
const message =
error instanceof Error ? error.message : "Failed to switch connection method.";
setSavedBackendError(message);
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not switch connection method",
description: message,
}),
);
}
},
[selectEnvironmentRoute],
);

const handleRemoveSavedBackend = useCallback(
async (environmentId: EnvironmentId) => {
setRemovingSavedEnvironmentId(environmentId);
Expand Down Expand Up @@ -3448,9 +3544,12 @@ export function ConnectionsSettings() {
<SavedBackendListRow
key={environment.environmentId}
environment={environment}
routes={desktopBridge ? (environmentRoutes.get(environment.environmentId) ?? []) : []}
removingEnvironmentId={removingSavedEnvironmentId}
switchingRouteEnvironmentIds={switchingRouteEnvironmentIds}
onConnect={handleConnectSavedBackend}
onRemove={handleRemoveSavedBackend}
onSelectRoute={handleSelectSavedBackendRoute}
/>
))}
<CloudRemoteEnvironmentRows
Expand Down
47 changes: 45 additions & 2 deletions apps/web/src/connection/platform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Effect from "effect/Effect";
import {
canRetainCachedPlatformRegistrationAfterRefreshFailure,
canReuseCachedPlatformRegistration,
prepareDesktopSshEnvironment,
primaryRegistrationToRetainAfterTopologyRead,
provisionDesktopSshEnvironment,
readPrimaryEnvironmentTargetResult,
Expand All @@ -28,11 +29,18 @@ const TARGET: DesktopSshEnvironmentTarget = {

function makeBridge(
calls: string[],
options?: { readonly failDescriptor?: boolean },
options?: {
readonly failDescriptor?: boolean;
readonly ensurePairingOptions?: Array<boolean | null>;
},
): DesktopBridge {
return {
ensureSshEnvironment: async (target: DesktopSshEnvironmentTarget) => {
ensureSshEnvironment: async (
target: DesktopSshEnvironmentTarget,
ensureOptions?: { readonly issuePairingToken?: boolean },
) => {
calls.push("ensure");
options?.ensurePairingOptions?.push(ensureOptions?.issuePairingToken ?? null);
return {
target,
httpBaseUrl: "http://127.0.0.1:3201/",
Expand Down Expand Up @@ -95,6 +103,41 @@ describe("desktop SSH pairing", () => {
expect(calls).toEqual(["ensure", "descriptor"]);
}),
);

it.effect("reuses a cached bearer without requesting or bootstrapping a pairing token", () =>
Effect.gen(function* () {
const calls: string[] = [];
const ensurePairingOptions: Array<boolean | null> = [];

const prepared = yield* prepareDesktopSshEnvironment(
makeBridge(calls, { ensurePairingOptions }),
{
target: TARGET,
bearerToken: "cached-bearer",
},
);

expect(prepared.bearerToken).toBe("cached-bearer");
expect(calls).toEqual(["ensure"]);
expect(ensurePairingOptions).toEqual([false]);
}),
);

it.effect("requests and bootstraps a pairing token when no bearer is cached", () =>
Effect.gen(function* () {
const calls: string[] = [];
const ensurePairingOptions: Array<boolean | null> = [];

const prepared = yield* prepareDesktopSshEnvironment(
makeBridge(calls, { ensurePairingOptions }),
{ target: TARGET },
);

expect(prepared.bearerToken).toBe("bearer-token");
expect(calls).toEqual(["ensure", "token"]);
expect(ensurePairingOptions).toEqual([true]);
}),
);
});

describe("desktop-local bearer cache", () => {
Expand Down
61 changes: 39 additions & 22 deletions apps/web/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,44 @@ export const provisionDesktopSshEnvironment = Effect.fn(
};
});

export const prepareDesktopSshEnvironment = Effect.fn("web.connectionPlatform.ssh.prepareDesktop")(
function* (
bridge: DesktopBridge,
input: {
readonly target: DesktopSshEnvironmentTarget;
readonly bearerToken?: string;
},
) {
const bootstrap = yield* Effect.tryPromise({
try: () =>
bridge.ensureSshEnvironment(input.target, {
issuePairingToken: input.bearerToken === undefined,
}),
catch: sshPreparationError,
});
if (input.bearerToken !== undefined) {
return {
bootstrap,
bearerToken: input.bearerToken,
};
}
if (bootstrap.pairingToken === null) {
return yield* new ConnectionBlockedError({
reason: "authentication",
detail: "The SSH environment did not issue a pairing credential.",
});
}
const access = yield* Effect.tryPromise({
try: () => bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!),
catch: sshPreparationError,
});
return {
bootstrap,
bearerToken: access.access_token,
};
},
);

const capabilitiesLayer = Layer.effectContext(
Effect.sync(() => {
const presentation = ClientPresentation.of({
Expand Down Expand Up @@ -238,28 +276,7 @@ const capabilitiesLayer = Layer.effectContext(
detail: "SSH environments are only available in the desktop app.",
});
}
const bootstrap = yield* Effect.tryPromise({
try: () =>
bridge.ensureSshEnvironment(input.target, {
issuePairingToken: true,
}),
catch: sshPreparationError,
});
if (bootstrap.pairingToken === null) {
return yield* new ConnectionBlockedError({
reason: "authentication",
detail: "The SSH environment did not issue a pairing credential.",
});
}
const access = yield* Effect.tryPromise({
try: () =>
bridge.bootstrapSshBearerSession(bootstrap.httpBaseUrl, bootstrap.pairingToken!),
catch: sshPreparationError,
});
return {
bootstrap,
bearerToken: access.access_token,
};
return yield* prepareDesktopSshEnvironment(bridge, input);
}),
disconnect: Effect.fn("web.connectionPlatform.ssh.disconnect")(function* (target) {
const bridge = window.desktopBridge;
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/connection/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { makeCatalogBackend, makeCatalogStore } from "./storage";
const emptyCatalog = {
schemaVersion: 1,
targets: [],
routes: [],
profiles: [],
credentials: [],
remoteDpopTokens: [],
Expand Down
Loading
Loading