authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:56:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:56:22-07:00
log0ae1091d019484d474fce0fe0a89195a82134041
tree6710242287565c6891ca6aea1f5bdc78b068431b
parent0425c32571e033c068b8caebfd984acce9f8735b
signature Signed by PGP key B5690EEEBB952194

fix(clients): pairing a switched-off computer again turns it back on (#19354)

Co-authored-by: Mnigos <makowskiigor@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 258 insertions(+), 7 deletions(-)

apps/web/src/components/onboarding/WelcomeWizard.tsx+4-1
......@@ -365,7 +365,10 @@ function ConnectionStep({
365365 {environment.label}
366366 </span>
367367 <span className="shrink-0 text-xs text-muted-foreground">
368 {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
368 {/* Same rule as Settings: a switched-off computer reads Off, not Not connected. */}
369 {!environment.entry.enabled && environment.connection.phase !== "unsupported"
370 ? "Off"
371 : presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
369372 </span>
370373 </span>
371374 {environment.displayUrl ? (
packages/client-runtime/src/connection/onboarding.test.ts+235-2
......@@ -9,17 +9,36 @@ import { describe, expect, it } from "@effect/vitest";
99import * as Effect from "effect/Effect";
1010import * as Layer from "effect/Layer";
1111import * as Option from "effect/Option";
12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
1214
1315import * as RpcHttp from "../rpc/http.ts";
1416import * as ClientCapabilities from "../platform/capabilities.ts";
17import * as Persistence from "../platform/persistence.ts";
1518import { fetchRemoteSessionState } from "../authorization/remote.ts";
16import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts";
17import { BearerConnectionTarget } from "./model.ts";
19import {
20 BearerConnectionCredential,
21 BearerConnectionProfile,
22 BearerConnectionRegistration,
23 PrimaryConnectionRegistration,
24} from "./catalog.ts";
25import * as Connectivity from "./connectivity.ts";
26import * as ConnectionCredentialStore from "./credentialStore.ts";
27import * as ConnectionDriver from "./driver.ts";
28import {
29 BearerConnectionTarget,
30 ConnectionBlockedError,
31 PrimaryConnectionTarget,
32} from "./model.ts";
33import * as Onboarding from "./onboarding.ts";
1834import {
1935 prepareBearerConnectionUpdate,
2036 preparePairingRegistration,
2137 prepareSshRegistration,
2238} from "./onboarding.ts";
39import * as EnvironmentRegistry from "./registry.ts";
40import * as ConnectionProfileStore from "./profileStore.ts";
41import * as ConnectionWakeups from "./wakeups.ts";
2342
2443const layerClientPresentation = Layer.succeed(
2544 ClientCapabilities.ClientPresentation,
......@@ -36,6 +55,7 @@ function layerPairingHttp(
3655 calls: Array<{ readonly url: string; readonly init: RequestInit }>,
3756 options?: {
3857 readonly failDescriptor?: boolean;
58 readonly failExchange?: boolean;
3959 readonly protocolVersion?: number;
4060 readonly selfUpdate?: boolean;
4161 readonly grantScopes?: ReadonlyArray<AuthEnvironmentScope>;
......@@ -72,6 +92,19 @@ function layerPairingHttp(
7292 }
7393
7494 if (url.endsWith("/oauth/token")) {
95 if (options?.failExchange === true) {
96 return Promise.resolve(
97 Response.json(
98 {
99 _tag: "EnvironmentAuthInvalidError",
100 code: "auth_invalid",
101 reason: "invalid_credential",
102 traceId: "trace-pairing-test",
103 },
104 { status: 401 },
105 ),
106 );
107 }
75108 const body =
76109 init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init.body);
77110 const requestedScope = new URLSearchParams(body).get("scope");
......@@ -122,6 +155,76 @@ function layerPairingHttp(
122155 return RpcHttp.layerRemoteHttpClient(fetchFn);
123156}
124157
158function layerPairingOnboarding(options?: Parameters<typeof layerPairingHttp>[1]) {
159 const dependencies = Layer.mergeAll(
160 layerClientPresentation,
161 layerPairingHttp([], options),
162 Layer.mock(Persistence.ConnectionTargetStore)({
163 list: Effect.succeed([]),
164 listDisabled: Effect.succeed([]),
165 }),
166 Layer.mock(Persistence.ConnectionRegistrationStore)({
167 register: () => Effect.void,
168 setEnabled: () => Effect.void,
169 }),
170 Layer.mock(Persistence.EnvironmentCacheStore)({}),
171 Layer.mock(ConnectionProfileStore.ConnectionProfileStore)({}),
172 Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({}),
173 Layer.mock(ClientCapabilities.SshEnvironmentGateway)({}),
174 Layer.succeed(
175 Connectivity.Connectivity,
176 Connectivity.Connectivity.of({ status: Effect.succeed("online"), changes: Stream.never }),
177 ),
178 Layer.mock(ConnectionDriver.ConnectionDriver)({ connect: () => Effect.never }),
179 Layer.succeed(
180 ConnectionWakeups.ConnectionWakeups,
181 ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
182 ),
183 );
184 return Onboarding.layer.pipe(
185 Layer.provideMerge(EnvironmentRegistry.layer.pipe(Layer.provideMerge(dependencies))),
186 );
187}
188
189const PAIRED_ENVIRONMENT_ID = EnvironmentId.make("environment-paired");
190const PAIRING_INPUT = { host: "remote.example.test", pairingCode: "pairing-token" };
191const PAIRED_PROFILE = new BearerConnectionProfile({
192 connectionId: "bearer:environment-paired:https://remote.example.test",
193 environmentId: PAIRED_ENVIRONMENT_ID,
194 label: "Paired environment",
195 httpBaseUrl: "https://remote.example.test/",
196 wsBaseUrl: "wss://remote.example.test/",
197});
198const UNSUPPORTED_ERROR = new ConnectionBlockedError({
199 reason: "unsupported",
200 detail: "The saved server used an incompatible orchestration protocol.",
201});
202
203const registerSwitchedOffEnvironment = Effect.gen(function* () {
204 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
205 const registration = new BearerConnectionRegistration({
206 target: new BearerConnectionTarget({
207 environmentId: PAIRED_ENVIRONMENT_ID,
208 connectionId: PAIRED_PROFILE.connectionId,
209 label: "Old label",
210 }),
211 // Keep the endpoint unchanged: registration alone must retain the stale reason.
212 profile: new BearerConnectionProfile({ ...PAIRED_PROFILE, label: "Old label" }),
213 credential: new BearerConnectionCredential({ token: "old-token" }),
214 });
215 yield* registry.register(registration);
216 yield* registry.setEnabled(PAIRED_ENVIRONMENT_ID, false);
217 yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR);
218 const entries = yield* SubscriptionRef.get(registry.entries);
219 expect(entries.get(PAIRED_ENVIRONMENT_ID)).toEqual({
220 target: registration.target,
221 profile: Option.some(registration.profile),
222 enabled: false,
223 unsupportedReason: UNSUPPORTED_ERROR.message,
224 });
225 return entries;
226});
227
125228describe("connection onboarding", () => {
126229 it.effect("prepares a persisted bearer registration from pairing details", () =>
127230 Effect.gen(function* () {
......@@ -168,6 +271,136 @@ describe("connection onboarding", () => {
168271 }),
169272 );
170273
274 it.effect("turns a switched-off environment back on when it is paired again", () =>
275 Effect.gen(function* () {
276 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
277 const onboarding = yield* Onboarding.ConnectionOnboarding;
278 yield* registerSwitchedOffEnvironment;
279
280 const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
281
282 expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
283 const entries = yield* SubscriptionRef.get(registry.entries);
284 const entry = entries.get(environmentId);
285 expect(entries.size).toBe(1);
286 expect(entry?.enabled).toBe(true);
287 expect(entry?.unsupportedReason).toBeUndefined();
288 expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
289 expect(entry?.target).toEqual(
290 new BearerConnectionTarget({
291 environmentId,
292 connectionId: PAIRED_PROFILE.connectionId,
293 label: PAIRED_PROFILE.label,
294 }),
295 );
296 }).pipe(Effect.provide(layerPairingOnboarding())),
297 );
298
299 it.effect(
300 "re-pairs an outdated self-updatable environment so its connection can be rechecked",
301 () =>
302 Effect.gen(function* () {
303 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
304 const onboarding = yield* Onboarding.ConnectionOnboarding;
305 yield* registerSwitchedOffEnvironment;
306 yield* registry.setCompatibility(
307 PAIRED_ENVIRONMENT_ID,
308 new ConnectionBlockedError({
309 reason: "unsupported",
310 detail: "The saved server needs an update.",
311 serverUpdateRequired: true,
312 }),
313 );
314 expect(
315 (yield* SubscriptionRef.get(registry.entries)).get(PAIRED_ENVIRONMENT_ID),
316 ).toMatchObject({
317 enabled: false,
318 serverUpdateRequired: true,
319 });
320
321 const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
322
323 expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
324 const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId);
325 expect(entry?.enabled).toBe(true);
326 expect(entry?.unsupportedReason).toBeUndefined();
327 expect(entry?.serverUpdateRequired).toBeUndefined();
328 expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
329 }).pipe(
330 Effect.provide(
331 layerPairingOnboarding({
332 protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1,
333 selfUpdate: true,
334 }),
335 ),
336 ),
337 );
338
339 it.effect("registers an enabled environment on first-time pairing", () =>
340 Effect.gen(function* () {
341 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
342 const onboarding = yield* Onboarding.ConnectionOnboarding;
343 expect((yield* SubscriptionRef.get(registry.entries)).size).toBe(0);
344
345 const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
346
347 expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
348 const entries = yield* SubscriptionRef.get(registry.entries);
349 const entry = entries.get(environmentId);
350 expect(entries.size).toBe(1);
351 expect(entry?.enabled).toBe(true);
352 expect(entry?.unsupportedReason).toBeUndefined();
353 expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
354 }).pipe(Effect.provide(layerPairingOnboarding())),
355 );
356
357 it.effect("leaves a platform-managed environment untouched when its id is paired", () =>
358 Effect.gen(function* () {
359 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
360 const onboarding = yield* Onboarding.ConnectionOnboarding;
361 yield* registry.registerPlatform(
362 new PrimaryConnectionRegistration({
363 target: new PrimaryConnectionTarget({
364 environmentId: PAIRED_ENVIRONMENT_ID,
365 label: "Host environment",
366 httpBaseUrl: "http://127.0.0.1:3773",
367 wsBaseUrl: "ws://127.0.0.1:3773",
368 }),
369 }),
370 );
371 yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR);
372 const before = yield* SubscriptionRef.get(registry.entries);
373 expect(before.get(PAIRED_ENVIRONMENT_ID)?.enabled).toBe(false);
374
375 const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
376
377 expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
378 const after = yield* SubscriptionRef.get(registry.entries);
379 expect(after).toEqual(before);
380 expect(after.get(PAIRED_ENVIRONMENT_ID)?.unsupportedReason).toBe(UNSUPPORTED_ERROR.message);
381 }).pipe(Effect.provide(layerPairingOnboarding())),
382 );
383
384 it.effect("leaves a switched-off environment unchanged when the pairing exchange fails", () =>
385 Effect.gen(function* () {
386 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
387 const onboarding = yield* Onboarding.ConnectionOnboarding;
388 const before = yield* registerSwitchedOffEnvironment;
389
390 const error = yield* onboarding.registerPairing(PAIRING_INPUT).pipe(Effect.flip);
391
392 expect(error).toMatchObject({
393 _tag: "ConnectionBlockedError",
394 reason: "authentication",
395 traceId: "trace-pairing-test",
396 });
397 const after = yield* SubscriptionRef.get(registry.entries);
398 expect(after).toEqual(before);
399 expect(after.get(PAIRED_ENVIRONMENT_ID)?.enabled).toBe(false);
400 expect(after.get(PAIRED_ENVIRONMENT_ID)?.unsupportedReason).toBe(UNSUPPORTED_ERROR.message);
401 }).pipe(Effect.provide(layerPairingOnboarding({ failExchange: true }))),
402 );
403
171404 it.effect("rejects an incompatible server without consuming the pairing credential", () =>
172405 Effect.gen(function* () {
173406 const calls: Array<{ readonly url: string; readonly init: RequestInit }> = [];
packages/client-runtime/src/connection/onboarding.ts+11-1
......@@ -159,7 +159,17 @@ const registerPairingConnection = Effect.fn(
159159)(function* (input: PairingConnectionInput) {
160160 const registration = yield* preparePairingRegistration(input);
161161 const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
162 yield* registry.register(registration);
162 // A platform-managed environment belongs to the host, so pairing leaves it alone.
163 if (!(yield* registry.register(registration))) return registration.target.environmentId;
164 // Registering keeps a previous entry's switched-off flag and unsupported
165 // reason, which is right for label or URL edits. A pairing is an explicit
166 // request to connect, so the stale reason is cleared and the environment
167 // turned on after the pair succeeded; a server that is still incompatible,
168 // such as an outdated one awaiting an update, is flagged again on connect.
169 yield* registry.setCompatibility(registration.target.environmentId, null);
170 yield* registry
171 .setEnabled(registration.target.environmentId, true)
172 .pipe(Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }));
163173 return registration.target.environmentId;
164174});
165175
packages/client-runtime/src/connection/registry.ts+8-3
......@@ -98,9 +98,13 @@ export class EnvironmentRegistry extends Context.Service<
9898 >;
9999 readonly networkStatus: SubscriptionRef.SubscriptionRef<NetworkStatus>;
100100 readonly start: Effect.Effect<void>;
101 /**
102 * Saves a user registration. Succeeds with `false` and changes nothing when
103 * the environment is platform-managed, since the host owns that entry.
104 */
101105 readonly register: (
102106 registration: ConnectionRegistration,
103 ) => Effect.Effect<void, Persistence.ConnectionPersistenceError>;
107 ) => Effect.Effect<boolean, Persistence.ConnectionPersistenceError>;
104108 readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect<void>;
105109 readonly reconcilePlatform: (
106110 registrations: ReadonlyArray<PlatformConnectionRegistration>,
......@@ -557,11 +561,11 @@ export const make = Effect.gen(function* () {
557561 ) {
558562 const registered = connectionRegistrationCatalogEntry(registration);
559563 const environmentId = registered.target.environmentId;
560 yield* withLeaseLock(
564 return yield* withLeaseLock(
561565 environmentId,
562566 Effect.gen(function* () {
563567 if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
564 return;
568 return false;
565569 }
566570 // A new route joins the environment's saved routes. Registering one it
567571 // already has (the same id, or a pairing to the same address) replaces
......@@ -588,6 +592,7 @@ export const make = Effect.gen(function* () {
588592 new Set(current).add(environmentId),
589593 );
590594 yield* installEntryLocked(entry);
595 return true;
591596 }),
592597 );
593598 });