diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx
index 617962f73321ce59849537db937e40c946ffb245..2f7c942bedcef12261aad9da77a7b483b49a0278 100644
--- a/apps/web/src/components/onboarding/WelcomeWizard.tsx
+++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx
@@ -365,7 +365,10 @@ function ConnectionStep({
{environment.label}
- {presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
+ {/* Same rule as Settings: a switched-off computer reads Off, not Not connected. */}
+ {!environment.entry.enabled && environment.connection.phase !== "unsupported"
+ ? "Off"
+ : presentSavedCloudEnvironmentConnection(environment.connection).buttonLabel}
{environment.displayUrl ? (
diff --git a/packages/client-runtime/src/connection/onboarding.test.ts b/packages/client-runtime/src/connection/onboarding.test.ts
index 77f86b552f4ebf694f2d6e6b8abb0c7bb5daaf39..e53420659c0535e639fbf1a94e872f6e35c327d9 100644
--- a/packages/client-runtime/src/connection/onboarding.test.ts
+++ b/packages/client-runtime/src/connection/onboarding.test.ts
@@ -9,17 +9,36 @@ import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
import * as RpcHttp from "../rpc/http.ts";
import * as ClientCapabilities from "../platform/capabilities.ts";
+import * as Persistence from "../platform/persistence.ts";
import { fetchRemoteSessionState } from "../authorization/remote.ts";
-import { BearerConnectionCredential, BearerConnectionProfile } from "./catalog.ts";
-import { BearerConnectionTarget } from "./model.ts";
+import {
+ BearerConnectionCredential,
+ BearerConnectionProfile,
+ BearerConnectionRegistration,
+ PrimaryConnectionRegistration,
+} from "./catalog.ts";
+import * as Connectivity from "./connectivity.ts";
+import * as ConnectionCredentialStore from "./credentialStore.ts";
+import * as ConnectionDriver from "./driver.ts";
+import {
+ BearerConnectionTarget,
+ ConnectionBlockedError,
+ PrimaryConnectionTarget,
+} from "./model.ts";
+import * as Onboarding from "./onboarding.ts";
import {
prepareBearerConnectionUpdate,
preparePairingRegistration,
prepareSshRegistration,
} from "./onboarding.ts";
+import * as EnvironmentRegistry from "./registry.ts";
+import * as ConnectionProfileStore from "./profileStore.ts";
+import * as ConnectionWakeups from "./wakeups.ts";
const layerClientPresentation = Layer.succeed(
ClientCapabilities.ClientPresentation,
@@ -36,6 +55,7 @@ function layerPairingHttp(
calls: Array<{ readonly url: string; readonly init: RequestInit }>,
options?: {
readonly failDescriptor?: boolean;
+ readonly failExchange?: boolean;
readonly protocolVersion?: number;
readonly selfUpdate?: boolean;
readonly grantScopes?: ReadonlyArray;
@@ -72,6 +92,19 @@ function layerPairingHttp(
}
if (url.endsWith("/oauth/token")) {
+ if (options?.failExchange === true) {
+ return Promise.resolve(
+ Response.json(
+ {
+ _tag: "EnvironmentAuthInvalidError",
+ code: "auth_invalid",
+ reason: "invalid_credential",
+ traceId: "trace-pairing-test",
+ },
+ { status: 401 },
+ ),
+ );
+ }
const body =
init.body instanceof Uint8Array ? new TextDecoder().decode(init.body) : String(init.body);
const requestedScope = new URLSearchParams(body).get("scope");
@@ -122,6 +155,76 @@ function layerPairingHttp(
return RpcHttp.layerRemoteHttpClient(fetchFn);
}
+function layerPairingOnboarding(options?: Parameters[1]) {
+ const dependencies = Layer.mergeAll(
+ layerClientPresentation,
+ layerPairingHttp([], options),
+ Layer.mock(Persistence.ConnectionTargetStore)({
+ list: Effect.succeed([]),
+ listDisabled: Effect.succeed([]),
+ }),
+ Layer.mock(Persistence.ConnectionRegistrationStore)({
+ register: () => Effect.void,
+ setEnabled: () => Effect.void,
+ }),
+ Layer.mock(Persistence.EnvironmentCacheStore)({}),
+ Layer.mock(ConnectionProfileStore.ConnectionProfileStore)({}),
+ Layer.mock(ConnectionCredentialStore.ConnectionCredentialStore)({}),
+ Layer.mock(ClientCapabilities.SshEnvironmentGateway)({}),
+ Layer.succeed(
+ Connectivity.Connectivity,
+ Connectivity.Connectivity.of({ status: Effect.succeed("online"), changes: Stream.never }),
+ ),
+ Layer.mock(ConnectionDriver.ConnectionDriver)({ connect: () => Effect.never }),
+ Layer.succeed(
+ ConnectionWakeups.ConnectionWakeups,
+ ConnectionWakeups.ConnectionWakeups.of({ changes: Stream.never }),
+ ),
+ );
+ return Onboarding.layer.pipe(
+ Layer.provideMerge(EnvironmentRegistry.layer.pipe(Layer.provideMerge(dependencies))),
+ );
+}
+
+const PAIRED_ENVIRONMENT_ID = EnvironmentId.make("environment-paired");
+const PAIRING_INPUT = { host: "remote.example.test", pairingCode: "pairing-token" };
+const PAIRED_PROFILE = new BearerConnectionProfile({
+ connectionId: "bearer:environment-paired:https://remote.example.test",
+ environmentId: PAIRED_ENVIRONMENT_ID,
+ label: "Paired environment",
+ httpBaseUrl: "https://remote.example.test/",
+ wsBaseUrl: "wss://remote.example.test/",
+});
+const UNSUPPORTED_ERROR = new ConnectionBlockedError({
+ reason: "unsupported",
+ detail: "The saved server used an incompatible orchestration protocol.",
+});
+
+const registerSwitchedOffEnvironment = Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const registration = new BearerConnectionRegistration({
+ target: new BearerConnectionTarget({
+ environmentId: PAIRED_ENVIRONMENT_ID,
+ connectionId: PAIRED_PROFILE.connectionId,
+ label: "Old label",
+ }),
+ // Keep the endpoint unchanged: registration alone must retain the stale reason.
+ profile: new BearerConnectionProfile({ ...PAIRED_PROFILE, label: "Old label" }),
+ credential: new BearerConnectionCredential({ token: "old-token" }),
+ });
+ yield* registry.register(registration);
+ yield* registry.setEnabled(PAIRED_ENVIRONMENT_ID, false);
+ yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR);
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ expect(entries.get(PAIRED_ENVIRONMENT_ID)).toEqual({
+ target: registration.target,
+ profile: Option.some(registration.profile),
+ enabled: false,
+ unsupportedReason: UNSUPPORTED_ERROR.message,
+ });
+ return entries;
+});
+
describe("connection onboarding", () => {
it.effect("prepares a persisted bearer registration from pairing details", () =>
Effect.gen(function* () {
@@ -168,6 +271,136 @@ describe("connection onboarding", () => {
}),
);
+ it.effect("turns a switched-off environment back on when it is paired again", () =>
+ Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const onboarding = yield* Onboarding.ConnectionOnboarding;
+ yield* registerSwitchedOffEnvironment;
+
+ const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
+
+ expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ const entry = entries.get(environmentId);
+ expect(entries.size).toBe(1);
+ expect(entry?.enabled).toBe(true);
+ expect(entry?.unsupportedReason).toBeUndefined();
+ expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
+ expect(entry?.target).toEqual(
+ new BearerConnectionTarget({
+ environmentId,
+ connectionId: PAIRED_PROFILE.connectionId,
+ label: PAIRED_PROFILE.label,
+ }),
+ );
+ }).pipe(Effect.provide(layerPairingOnboarding())),
+ );
+
+ it.effect(
+ "re-pairs an outdated self-updatable environment so its connection can be rechecked",
+ () =>
+ Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const onboarding = yield* Onboarding.ConnectionOnboarding;
+ yield* registerSwitchedOffEnvironment;
+ yield* registry.setCompatibility(
+ PAIRED_ENVIRONMENT_ID,
+ new ConnectionBlockedError({
+ reason: "unsupported",
+ detail: "The saved server needs an update.",
+ serverUpdateRequired: true,
+ }),
+ );
+ expect(
+ (yield* SubscriptionRef.get(registry.entries)).get(PAIRED_ENVIRONMENT_ID),
+ ).toMatchObject({
+ enabled: false,
+ serverUpdateRequired: true,
+ });
+
+ const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
+
+ expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
+ const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId);
+ expect(entry?.enabled).toBe(true);
+ expect(entry?.unsupportedReason).toBeUndefined();
+ expect(entry?.serverUpdateRequired).toBeUndefined();
+ expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
+ }).pipe(
+ Effect.provide(
+ layerPairingOnboarding({
+ protocolVersion: ORCHESTRATION_PROTOCOL_VERSION - 1,
+ selfUpdate: true,
+ }),
+ ),
+ ),
+ );
+
+ it.effect("registers an enabled environment on first-time pairing", () =>
+ Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const onboarding = yield* Onboarding.ConnectionOnboarding;
+ expect((yield* SubscriptionRef.get(registry.entries)).size).toBe(0);
+
+ const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
+
+ expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
+ const entries = yield* SubscriptionRef.get(registry.entries);
+ const entry = entries.get(environmentId);
+ expect(entries.size).toBe(1);
+ expect(entry?.enabled).toBe(true);
+ expect(entry?.unsupportedReason).toBeUndefined();
+ expect(entry?.profile).toEqual(Option.some(PAIRED_PROFILE));
+ }).pipe(Effect.provide(layerPairingOnboarding())),
+ );
+
+ it.effect("leaves a platform-managed environment untouched when its id is paired", () =>
+ Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const onboarding = yield* Onboarding.ConnectionOnboarding;
+ yield* registry.registerPlatform(
+ new PrimaryConnectionRegistration({
+ target: new PrimaryConnectionTarget({
+ environmentId: PAIRED_ENVIRONMENT_ID,
+ label: "Host environment",
+ httpBaseUrl: "http://127.0.0.1:3773",
+ wsBaseUrl: "ws://127.0.0.1:3773",
+ }),
+ }),
+ );
+ yield* registry.setCompatibility(PAIRED_ENVIRONMENT_ID, UNSUPPORTED_ERROR);
+ const before = yield* SubscriptionRef.get(registry.entries);
+ expect(before.get(PAIRED_ENVIRONMENT_ID)?.enabled).toBe(false);
+
+ const environmentId = yield* onboarding.registerPairing(PAIRING_INPUT);
+
+ expect(environmentId).toBe(PAIRED_ENVIRONMENT_ID);
+ const after = yield* SubscriptionRef.get(registry.entries);
+ expect(after).toEqual(before);
+ expect(after.get(PAIRED_ENVIRONMENT_ID)?.unsupportedReason).toBe(UNSUPPORTED_ERROR.message);
+ }).pipe(Effect.provide(layerPairingOnboarding())),
+ );
+
+ it.effect("leaves a switched-off environment unchanged when the pairing exchange fails", () =>
+ Effect.gen(function* () {
+ const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
+ const onboarding = yield* Onboarding.ConnectionOnboarding;
+ const before = yield* registerSwitchedOffEnvironment;
+
+ const error = yield* onboarding.registerPairing(PAIRING_INPUT).pipe(Effect.flip);
+
+ expect(error).toMatchObject({
+ _tag: "ConnectionBlockedError",
+ reason: "authentication",
+ traceId: "trace-pairing-test",
+ });
+ const after = yield* SubscriptionRef.get(registry.entries);
+ expect(after).toEqual(before);
+ expect(after.get(PAIRED_ENVIRONMENT_ID)?.enabled).toBe(false);
+ expect(after.get(PAIRED_ENVIRONMENT_ID)?.unsupportedReason).toBe(UNSUPPORTED_ERROR.message);
+ }).pipe(Effect.provide(layerPairingOnboarding({ failExchange: true }))),
+ );
+
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 da2981b48aca72b10e119e4d6271e99e9a4f71b1..a6ac9aa556e3ca03523db64ccd0c2c057d994556 100644
--- a/packages/client-runtime/src/connection/onboarding.ts
+++ b/packages/client-runtime/src/connection/onboarding.ts
@@ -159,7 +159,17 @@ const registerPairingConnection = Effect.fn(
)(function* (input: PairingConnectionInput) {
const registration = yield* preparePairingRegistration(input);
const registry = yield* EnvironmentRegistry.EnvironmentRegistry;
- yield* registry.register(registration);
+ // A platform-managed environment belongs to the host, so pairing leaves it alone.
+ if (!(yield* registry.register(registration))) return registration.target.environmentId;
+ // Registering keeps a previous entry's switched-off flag and unsupported
+ // reason, which is right for label or URL edits. A pairing is an explicit
+ // request to connect, so the stale reason is cleared and the environment
+ // turned on after the pair succeeded; a server that is still incompatible,
+ // such as an outdated one awaiting an update, is flagged again on connect.
+ yield* registry.setCompatibility(registration.target.environmentId, null);
+ yield* registry
+ .setEnabled(registration.target.environmentId, true)
+ .pipe(Effect.catchTags({ EnvironmentNotRegisteredError: () => Effect.void }));
return registration.target.environmentId;
});
diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts
index 9b48e60463036694bee4d92b3dcaca7fc3f757a5..caead65758df899ed3976bc648ceeb0129721e93 100644
--- a/packages/client-runtime/src/connection/registry.ts
+++ b/packages/client-runtime/src/connection/registry.ts
@@ -98,9 +98,13 @@ export class EnvironmentRegistry extends Context.Service<
>;
readonly networkStatus: SubscriptionRef.SubscriptionRef;
readonly start: Effect.Effect;
+ /**
+ * Saves a user registration. Succeeds with `false` and changes nothing when
+ * the environment is platform-managed, since the host owns that entry.
+ */
readonly register: (
registration: ConnectionRegistration,
- ) => Effect.Effect;
+ ) => Effect.Effect;
readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect;
readonly reconcilePlatform: (
registrations: ReadonlyArray,
@@ -557,11 +561,11 @@ export const make = Effect.gen(function* () {
) {
const registered = connectionRegistrationCatalogEntry(registration);
const environmentId = registered.target.environmentId;
- yield* withLeaseLock(
+ return yield* withLeaseLock(
environmentId,
Effect.gen(function* () {
if ((yield* Ref.get(platformEnvironmentIds)).has(environmentId)) {
- return;
+ return false;
}
// A new route joins the environment's saved routes. Registering one it
// already has (the same id, or a pairing to the same address) replaces
@@ -588,6 +592,7 @@ export const make = Effect.gen(function* () {
new Set(current).add(environmentId),
);
yield* installEntryLocked(entry);
+ return true;
}),
);
});