From 3ac37aa86f8f75e541f1ce63f16dbe4b00d3ac13 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 29 Jul 2026 08:15:35 -0700 Subject: [PATCH 01/27] feat(connect): let hosts set their display name --- apps/mobile/src/state/environments.ts | 2 +- .../src/environment/ServerEnvironment.test.ts | 30 +++++++++- .../src/environment/ServerEnvironment.ts | 35 ++++++++--- apps/server/src/server.ts | 7 ++- apps/server/src/ws.ts | 14 +++-- .../settings/ConnectionsSettings.tsx | 25 ++++++++ apps/web/src/state/environments.ts | 2 +- docs/user/remote-access.md | 9 +++ .../AgentActivityPublisher.test.ts | 1 + .../agentActivity/MobileRegistrations.test.ts | 1 + .../environments/EnvironmentConnector.test.ts | 59 +++++++++++++++++++ .../src/environments/EnvironmentConnector.ts | 17 ++++++ .../environments/EnvironmentLinker.test.ts | 1 + .../src/environments/EnvironmentLinks.test.ts | 45 ++++++++++++++ .../src/environments/EnvironmentLinks.ts | 50 ++++++++++++++++ infra/relay/src/http/Api.test.ts | 1 + .../src/relay/discovery.test.ts | 17 +++++- .../client-runtime/src/relay/discovery.ts | 6 ++ .../src/state/serverConfigProjection.ts | 1 + packages/contracts/src/server.ts | 1 + packages/contracts/src/settings.test.ts | 13 ++++ packages/contracts/src/settings.ts | 2 + 22 files changed, 320 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 776a615f46afb..375d3088c2e7b 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -25,7 +25,7 @@ export function projectEnvironmentPresentation( return { ...presentation, environmentId, - label: presentation.entry.target.label, + label: presentation.serverConfig?.environment.label ?? presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), relayManaged: hasRelayRoute(presentation.entry), }; diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 7cebccb2a1f7c..a8a3446a2d4c3 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -18,10 +18,12 @@ import { RELAY_URL_SECRET, } from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; const layerServerEnvironment = (baseDir: string) => ServerEnvironment.layer.pipe( + Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(ServerSecretStore.layer), Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), ); @@ -97,6 +99,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe( Effect.provide( ServerEnvironment.layer.pipe( + Layer.provide(ServerSettings.layerTest()), Layer.provide(layerEmptySecretStore), Layer.provide(ServerConfig.layer({ ...config, mode })), ), @@ -235,7 +238,10 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { prefix: "t3-server-environment-publish-test-", }); const layerTest = Layer.mergeAll( - ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)), + ServerEnvironment.layer.pipe( + Layer.provide(ServerSettings.layerTest()), + Layer.provide(ServerSecretStore.layer), + ), ServerSecretStore.layer, ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); @@ -266,4 +272,26 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe(Effect.provide(layerTest)); }), ); + + it.effect("applies configured environment label updates without a restart", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-environment-label-test-", + }); + yield* Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const initial = yield* serverEnvironment.getDescriptor; + + yield* serverSettings.updateSettings({ environmentLabel: "Studio Mac" }); + const renamed = yield* serverEnvironment.getDescriptor; + yield* serverSettings.updateSettings({ environmentLabel: "" }); + const reset = yield* serverEnvironment.getDescriptor; + + expect(renamed).toEqual({ ...initial, label: "Studio Mac" }); + expect(reset).toEqual(initial); + }).pipe(Effect.provide(layerServerEnvironment(baseDir))); + }), + ); }); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index fbb5970560bc6..2208012eb7f15 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -21,6 +21,7 @@ import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.ts"; import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts"; import { detectServerEnvironmentMachineKind } from "./ServerEnvironmentMachine.ts"; @@ -185,12 +186,13 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; const secrets = yield* ServerSecretStore.ServerSecretStore; + const serverSettings = yield* ServerSettings.ServerSettingsService; const identity = yield* ServerEnvironmentIdentity; const hostPlatform = yield* HostProcess.Platform; const hostArchitecture = yield* HostProcess.Architecture; const environmentId = yield* identity.getEnvironmentId; const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const defaultLabel = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const machine = yield* detectServerEnvironmentMachineKind(); const launcher = yield* resolveServiceLauncherMode(); const serverSelfUpdate = resolveServerSelfUpdateCapability({ @@ -205,9 +207,9 @@ export const make = Effect.gen(function* () { const desktopAppUpdate = serverSelfUpdate === "desktop-managed" && serverConfig.desktopTelemetryControlFd !== undefined; - const descriptor: ExecutionEnvironmentDescriptor = { + const defaultDescriptor: ExecutionEnvironmentDescriptor = { environmentId, - label, + label: defaultLabel, platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), @@ -274,11 +276,28 @@ export const make = Effect.gen(function* () { // The publish opt-in and relay link change at runtime (`t3 connect // publish`, the client settings toggle), so the capability is read per // descriptor request rather than baked in at startup. - getDescriptor: readAgentActivityPublishingActive(secrets).pipe( - Effect.map((agentActivityPublishing) => ({ - ...descriptor, - capabilities: { ...descriptor.capabilities, agentActivityPublishing }, - })), + getDescriptor: serverSettings.getSettings.pipe( + Effect.map( + (settings): ExecutionEnvironmentDescriptor => ({ + ...defaultDescriptor, + label: settings.environmentLabel || defaultLabel, + }), + ), + Effect.catch((error) => + Effect.logWarning("Failed to read the configured environment label.", { + settingsPath: error.settingsPath, + operation: error.operation, + cause: error.cause, + }).pipe(Effect.as(defaultDescriptor)), + ), + Effect.flatMap((descriptor) => + readAgentActivityPublishingActive(secrets).pipe( + Effect.map((agentActivityPublishing) => ({ + ...descriptor, + capabilities: { ...descriptor.capabilities, agentActivityPublishing }, + })), + ), + ), ), }); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 48d701103c077..a3d358f528566 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -456,7 +456,10 @@ const layerProjectFaviconResolver = ProjectFaviconResolver.layer.pipe( Layer.provide(T3ProjectFileLoader.layer), ); -const layerServerEnvironment = ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer)); +const layerServerEnvironment = ServerEnvironment.layer.pipe( + Layer.provide(layerServerSettings), + Layer.provide(ServerSecretStore.layer), +); const layerAuth = EnvironmentAuth.layer.pipe( Layer.provideMerge(layerPersistence), @@ -1052,7 +1055,7 @@ const layerMakeServer = Layer.unwrap( Layer.provideMerge(layerRuntimeServices), Layer.provideMerge( McpSessionRegistry.layer.pipe( - Layer.provide(ServerEnvironment.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provide(layerServerEnvironment), ), ), Layer.provide(layerActivation), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 763365ceeddc1..8d76c6f853cd8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3031,11 +3031,15 @@ export const layerWsRpc = ( : Stream.empty; const settingsUpdates = settingsChanges.pipe( Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), - Stream.map((settings) => ({ - version: 1 as const, - type: "settingsUpdated" as const, - payload: { settings }, - })), + Stream.mapEffect((settings) => + serverEnvironment.getDescriptor.pipe( + Effect.map((environment) => ({ + version: 1 as const, + type: "settingsUpdated" as const, + payload: { settings, environment }, + })), + ), + ), ); const liveUpdates = Stream.merge( diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 3639c921f3568..feda4078d610f 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -66,6 +66,7 @@ import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { Button } from "../ui/button"; +import { DraftInput } from "../ui/draft-input"; import { EnvironmentMachineIcon } from "../EnvironmentMachineIcon"; import { revokeOtherServerClientSessions, @@ -103,6 +104,7 @@ import { requestConfirmDialog } from "~/confirmDialog"; import { useAtomCommand } from "../../state/use-atom-command"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentUpdateStatesAtom, serverEnvironment } from "~/state/server"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { ServerUpdateAction, ServerUpdateProgress, @@ -157,6 +159,8 @@ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const environmentLabel = usePrimarySettings((settings) => settings.environmentLabel); + const updatePrimarySettings = useUpdatePrimarySettings(); const removeEnvironment = useAtomCommand(environmentCatalog.remove, { reportFailure: false }); const setEnvironmentEnabled = useAtomCommand(environmentCatalog.setEnabled, { reportFailure: false, @@ -287,6 +291,9 @@ export function ConnectionsSettings() { const canMaintain = useEnvironmentScope(primaryEnvironmentId, AuthEnvironmentMaintainScope); const canManageRelay = useEnvironmentScope(primaryEnvironmentId, AuthRelayWriteScope); const canManageLocalBackend = !isLocalEnvironmentDisabled() && canMaintain; + const canEditEnvironmentLabel = useAtomValue( + serverEnvironment.updateSettings.permissionAtom(primaryEnvironmentId), + ); const authAccessChanges = useEnvironmentQuery( canReadAccess && primaryEnvironmentId !== null ? authEnvironment.accessChanges({ @@ -645,6 +652,23 @@ export function ConnectionsSettings() { aria-label="Enable network access" /> ); + const renderEnvironmentLabelRow = () => ( + updatePrimarySettings({ environmentLabel: next })} + placeholder="Use this computer’s name" + aria-label="Host name" + disabled={!canEditEnvironmentLabel || primaryEnvironment?.connection.phase !== "connected"} + spellCheck={false} + /> + } + /> + ); const renderEndpointRows = (presentation: AccessSectionPresentation) => isAdvertisedEndpointListExpanded ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => { @@ -1121,6 +1145,7 @@ export function ConnectionsSettings() { } /> ) : null} + {renderEnvironmentLabelRow()} {canManageLocalBackend && desktopBridge ? ( <> {renderNetworkAccessRow()} diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 72f5afcf989a4..93dacd043af0e 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -32,7 +32,7 @@ function projectEnvironmentPresentation( return { ...presentation, environmentId, - label: presentation.entry.target.label, + label: presentation.serverConfig?.environment.label ?? presentation.entry.target.label, displayUrl: connectionCatalogDisplayUrl(presentation.entry), relayManaged: hasRelayRoute(presentation.entry), }; diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 493a580d14c5a..325353817c6b4 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -32,6 +32,15 @@ connection. Pull request diffs and provider settings keep working after the previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. +## Name this host + +In the web or desktop app, open **Settings → Connections** and edit **Host name** under +**This environment**. T3 Code uses that name in T3 Connect and as the default label when another +client adds the environment through a manual pairing connection. + +Clear the field to return to the name detected from the host operating system. This setting changes +the environment name shown by T3 Code; it does not change the machine's operating-system hostname. + ## Pair over a LAN or private network Use direct pairing when the other device can reach the host's network address. diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts index 1b7ffe4a0aed2..5ac6dc3bff56b 100644 --- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts +++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts @@ -97,6 +97,7 @@ function makeEnvironmentLinks( getForUser: () => Effect.succeed(null), findActiveManagedForEnvironment: () => Effect.succeed([]), setHoldWebhooksWhileOffline: () => Effect.void, + updateLabelForUser: () => Effect.void, revokeForUser: () => Effect.succeed(false), ...overrides, }; diff --git a/infra/relay/src/agentActivity/MobileRegistrations.test.ts b/infra/relay/src/agentActivity/MobileRegistrations.test.ts index 38840cf02b5b6..1e8f647ceacff 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -119,6 +119,7 @@ function makeEnvironmentLinks( getForUser: () => Effect.succeed(null), findActiveManagedForEnvironment: () => Effect.succeed([]), setHoldWebhooksWhileOffline: () => Effect.void, + updateLabelForUser: () => Effect.void, revokeForUser: () => Effect.succeed(false), ...overrides, }; diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 731cce9c22009..99447abbd65a7 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -229,6 +229,7 @@ function makeLinks( }), findActiveManagedForEnvironment: () => Effect.succeed([]), setHoldWebhooksWhileOffline: () => Effect.void, + updateLabelForUser: () => Effect.void, revokeForUser: () => Effect.succeed(false), }; } @@ -274,6 +275,64 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(layerConnectorTest(execute))); }); + it.effect("persists a label reported by a verified environment health response", () => { + const updates: Array<{ userId: string; environmentId: string; label: string }> = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + signHealthResponse( + healthRequest, + environmentKeyPair.privateKey, + {}, + { + descriptor: { + environmentId: "env-connector-test" as never, + label: "Studio Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + }, + ), + { status: 200 }, + ), + ); + }); + const links = makeLinks(); + + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.status({ + userId: "user_123", + environmentId: "env-connector-test", + }); + + expect(result.descriptor?.label).toBe("Studio Mac"); + expect(updates).toEqual([ + { + userId: "user_123", + environmentId: "env-connector-test", + label: "Studio Mac", + }, + ]); + }).pipe( + Effect.provide( + connectorTestLayer(execute, { + links: { + ...links, + updateLabelForUser: (input) => + Effect.sync(() => { + updates.push(input); + }), + }, + }), + ), + ); + }); + it.effect("rejects manual endpoints before sending a health request", () => { let requestCount = 0; const execute = () => diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index a6a64ba3dfda5..00c161df771f7 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -563,6 +563,23 @@ const make = Effect.gen(function* () { operation: "status", }); } + if (decoded.descriptor.label !== link.label) { + yield* links + .updateLabelForUser({ + userId: input.userId, + environmentId: input.environmentId, + label: decoded.descriptor.label, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("managed environment label persistence failed", { + environmentId: input.environmentId, + errorTag: error._tag, + }), + ), + Effect.ignore, + ); + } return { environmentId: link.environmentId, endpoint, diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 81fbafc55e91e..ae282eb4eb540 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -129,6 +129,7 @@ function layerTest(input?: { getForUser: () => Effect.succeed(null), findActiveManagedForEnvironment: () => Effect.succeed([]), setHoldWebhooksWhileOffline: () => Effect.void, + updateLabelForUser: () => Effect.void, revokeForUser: () => Effect.succeed(false), }), Layer.succeed(EnvironmentCredentials.EnvironmentCredentials, { diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 9707a90034ff6..785c53e32846c 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -8,6 +8,51 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { + it.effect("updates the active link label owned by the requesting user", () => { + const updateValues: Array> = []; + const whereConditions: Array = []; + const fakeDb = { + update: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + set: (values: Record) => { + updateValues.push(values); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return Effect.void; + }, + }; + }, + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + yield* links.updateLabelForUser({ + userId: "user-1", + environmentId: "env-1", + label: "Studio Mac", + }); + + expect(updateValues).toHaveLength(1); + expect(updateValues[0]?.environmentLabel).toBe("Studio Mac"); + expect(typeof updateValues[0]?.updatedAt).toBe("string"); + expect(whereConditions).toHaveLength(1); + + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); + expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.params).toEqual(["user-1", "env-1"]); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("carries the webhook-hold opt-in over only from links with the same key", () => { const inserted: Array> = []; const fakeDb = { diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index a7fdfc09d0353..8ac2d60aea30d 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -87,6 +87,19 @@ export class EnvironmentLinkEnvironmentLookupPersistenceError extends Schema.Tag } } +export class EnvironmentLinkLabelUpdatePersistenceError extends Schema.TaggedError()( + "EnvironmentLinkLabelUpdatePersistenceError", + { + userId: Schema.String, + environmentId: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to update the environment label for user '${this.userId}', environment '${this.environmentId}'`; + } +} + export class EnvironmentLinkRevokePersistenceError extends Schema.TaggedError()( "EnvironmentLinkRevokePersistenceError", { @@ -149,6 +162,11 @@ export class EnvironmentLinks extends Context.Service< readonly environmentPublicKey: string; readonly holdWebhooksWhileOffline: boolean; }) => Effect.Effect; + readonly updateLabelForUser: (input: { + readonly userId: string; + readonly environmentId: string; + readonly label: string; + }) => Effect.Effect; readonly revokeForUser: (input: { readonly userId: string; readonly environmentId: string; @@ -452,6 +470,38 @@ const make = Effect.gen(function* () { ); }), + updateLabelForUser: Effect.fn("relay.environment_links.update_label_for_user")( + function* (input) { + yield* Effect.annotateCurrentSpan({ + "relay.environment_id": input.environmentId, + }); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* db + .update(relayEnvironmentLinks) + .set({ + environmentLabel: input.label, + updatedAt, + }) + .where( + and( + eq(relayEnvironmentLinks.userId, input.userId), + eq(relayEnvironmentLinks.environmentId, input.environmentId), + isNull(relayEnvironmentLinks.revokedAt), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkLabelUpdatePersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause, + }), + ), + ); + }, + ), + revokeForUser: Effect.fn("relay.environment_links.revoke_for_user")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId, diff --git a/infra/relay/src/http/Api.test.ts b/infra/relay/src/http/Api.test.ts index 5e9899edfa075..7d6df47556680 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -532,6 +532,7 @@ function layerRelayUnlinkTest(input?: { getForUser: input?.getForUser ?? (() => Effect.succeed(null)), findActiveManagedForEnvironment: () => Effect.die("unused findActiveManagedForEnvironment"), setHoldWebhooksWhileOffline: () => Effect.die("unused setHoldWebhooksWhileOffline"), + updateLabelForUser: () => Effect.die("unused updateLabelForUser"), revokeForUser: input?.revokeForUser ?? (() => Effect.succeed(false)), }), ), diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index 53ded8855bddb..dd5f860339a92 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -47,12 +47,24 @@ const environments = [ function status( environment: RelayClientEnvironmentRecord, value: "online" | "offline", + label?: string, ): RelayEnvironmentStatusResponse { return { environmentId: environment.environmentId, endpoint: environment.endpoint, status: value, checkedAt: "2026-06-01T00:00:00.000Z", + ...(label + ? { + descriptor: { + environmentId: environment.environmentId, + label, + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + } + : {}), }; } @@ -195,7 +207,7 @@ describe("RelayEnvironmentDiscovery", () => { const requests = yield* Ref.get(harness.statusRequests); yield* Deferred.succeed( requests.get(environments[1]!.environmentId)!, - status(environments[1]!, "online"), + status(environments[1]!, "online", "Renamed Environment"), ); const partiallyResolved = yield* SubscriptionRef.changes(discovery.state).pipe( @@ -209,6 +221,9 @@ describe("RelayEnvironmentDiscovery", () => { expect( partiallyResolved.environments.get(environments[0]!.environmentId)?.availability, ).toBe("checking"); + expect( + partiallyResolved.environments.get(environments[1]!.environmentId)?.environment.label, + ).toBe("Renamed Environment"); yield* Deferred.succeed( requests.get(environments[0]!.environmentId)!, diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index bd7126f7f2b61..54f2cb31a0130 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -187,6 +187,12 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { } yield* updateEnvironment(generation, environment.environmentId, (current) => ({ ...current, + environment: result.success.descriptor + ? { + ...current.environment, + label: result.success.descriptor.label, + } + : current.environment, availability: result.success.status, status: Option.some(result.success), error: Option.none(), diff --git a/packages/client-runtime/src/state/serverConfigProjection.ts b/packages/client-runtime/src/state/serverConfigProjection.ts index 51efd4f7d2186..51f706ba5dc80 100644 --- a/packages/client-runtime/src/state/serverConfigProjection.ts +++ b/packages/client-runtime/src/state/serverConfigProjection.ts @@ -116,6 +116,7 @@ export function applyServerConfigProjection( config: { ...projection.config, settings: reuseUnchanged(projection.config.settings, event.payload.settings), + environment: event.payload.environment ?? projection.config.environment, }, latestEvent: event, source: "live", diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c39a29d6fe776..9bf4de6c95b77 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -705,6 +705,7 @@ export type ServerConfigProviderStatusesPayload = typeof ServerConfigProviderSta export const ServerConfigSettingsUpdatedPayload = Schema.Struct({ settings: ServerSettings, + environment: Schema.optionalKey(ExecutionEnvironmentDescriptor), }); export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 9cce0c7e97034..8bf407700e648 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -201,6 +201,19 @@ describe("ServerSettings worktree defaults", () => { }); }); +describe("ServerSettings environment label", () => { + it("uses an empty override for legacy configs", () => { + expect(decodeServerSettings({}).environmentLabel).toBe(""); + }); + + it("trims environment label updates and allows clearing the override", () => { + expect(decodeServerSettingsPatch({ environmentLabel: " Studio Mac " }).environmentLabel).toBe( + "Studio Mac", + ); + expect(decodeServerSettingsPatch({ environmentLabel: " " }).environmentLabel).toBe(""); + }); +}); + describe("ServerSettings.sourceControlWritingStyle", () => { it.each([ { legacy: "", mode: "repo_conventions", customInstructions: "" }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 620f659befea2..05d91f5b4c2b7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1091,6 +1091,7 @@ export const StorageCleanupSettings = Schema.Struct({ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ + environmentLabel: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Null means no client has migrated this preference yet; an empty value is // authoritative so another device cannot resurrect cleared preferences. favorites: Schema.NullOr(ModelFavorites).pipe(Schema.withDecodingDefault(Effect.succeed(null))), @@ -1433,6 +1434,7 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), worktreesDirectory: Schema.optionalKey(TrimmedString), + environmentLabel: Schema.optionalKey(TrimmedString), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), From cc3e4951c398ba93ae3bfdaf839180d08eb0538f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Wed, 29 Jul 2026 08:56:57 -0700 Subject: [PATCH 02/27] fix(connect): gate host name edits on server readiness --- infra/relay/src/environments/EnvironmentConnector.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 99447abbd65a7..ec2f9966031e0 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -320,7 +320,7 @@ describe("EnvironmentConnector", () => { ]); }).pipe( Effect.provide( - connectorTestLayer(execute, { + layerConnectorTest(execute, { links: { ...links, updateLabelForUser: (input) => From 86dc0f21c55579c4c0c97a83845e9ef01759eca4 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 3 Aug 2026 09:22:46 -0700 Subject: [PATCH 03/27] fix(server): keep environment labels consistent --- .../src/cloud/CloudLink.lifecycle.test.ts | 1 + .../src/environment/ServerEnvironment.test.ts | 5 +++ .../src/environment/ServerEnvironment.ts | 33 +++++++++---------- .../server/src/mcp/McpSessionRegistry.test.ts | 1 + .../ProviderSessionManager.test.ts | 1 + .../src/relay/AgentAwarenessRelay.test.ts | 1 + apps/server/src/ws.test.ts | 30 ++++++++++++----- apps/server/src/ws.ts | 17 +++++++--- 8 files changed, 58 insertions(+), 31 deletions(-) diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index 7264afd6a452b..fd8a344da7109 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -177,6 +177,7 @@ describe("releaseManagedTunnelOnShutdown", () => { ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")), getDescriptor: Effect.die("unused"), + getDescriptorForSettings: () => Effect.die("unused"), }), ), Effect.provideService( diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a8a3446a2d4c3..98fbeb4a43a5d 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -288,7 +288,12 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { const renamed = yield* serverEnvironment.getDescriptor; yield* serverSettings.updateSettings({ environmentLabel: "" }); const reset = yield* serverEnvironment.getDescriptor; + // A queued settings event must describe its own snapshot even after another write. + const previousSnapshot = yield* serverEnvironment.getDescriptorForSettings({ + environmentLabel: "Studio Mac", + }); + expect(previousSnapshot).toEqual(renamed); expect(renamed).toEqual({ ...initial, label: "Studio Mac" }); expect(reset).toEqual(initial); }).pipe(Effect.provide(layerServerEnvironment(baseDir))); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 2208012eb7f15..dfed89c9656b8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -46,6 +46,9 @@ export class ServerEnvironment extends Context.Service< { readonly getEnvironmentId: Effect.Effect; readonly getDescriptor: Effect.Effect; + readonly getDescriptorForSettings: (settings: { + readonly environmentLabel: string; + }) => Effect.Effect; } >()("t3/environment/ServerEnvironment") {} @@ -270,35 +273,29 @@ export const make = Effect.gen(function* () { serverBrowser: true, }, }; + // Relay publishing can change while the server is running; read it for every descriptor. + const getDescriptorForSettings = (settings: { readonly environmentLabel: string }) => + readAgentActivityPublishingActive(secrets).pipe( + Effect.map((agentActivityPublishing): ExecutionEnvironmentDescriptor => ({ + ...defaultDescriptor, + label: settings.environmentLabel || defaultLabel, + capabilities: { ...defaultDescriptor.capabilities, agentActivityPublishing }, + })), + ); return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), - // The publish opt-in and relay link change at runtime (`t3 connect - // publish`, the client settings toggle), so the capability is read per - // descriptor request rather than baked in at startup. getDescriptor: serverSettings.getSettings.pipe( - Effect.map( - (settings): ExecutionEnvironmentDescriptor => ({ - ...defaultDescriptor, - label: settings.environmentLabel || defaultLabel, - }), - ), Effect.catch((error) => Effect.logWarning("Failed to read the configured environment label.", { settingsPath: error.settingsPath, operation: error.operation, cause: error.cause, - }).pipe(Effect.as(defaultDescriptor)), - ), - Effect.flatMap((descriptor) => - readAgentActivityPublishingActive(secrets).pipe( - Effect.map((agentActivityPublishing) => ({ - ...descriptor, - capabilities: { ...descriptor.capabilities, agentActivityPublishing }, - })), - ), + }).pipe(Effect.as({ environmentLabel: "" })), ), + Effect.flatMap(getDescriptorForSettings), ), + getDescriptorForSettings, }); }); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 282c3dffd6d64..cd215da792e61 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -18,6 +18,7 @@ const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), + getDescriptorForSettings: () => Effect.die("unused"), }); const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index b60758e80c728..f999fef5ad9be 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -578,6 +578,7 @@ const fakeHttpServer = HttpServer.HttpServer.of({ const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-provider-session-manager")), getDescriptor: Effect.die("unused"), + getDescriptorForSettings: () => Effect.die("unused"), }); const layerTestMcpRegistry = Layer.effect( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 64b71140fdcf1..b49f02f11b6f1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -256,6 +256,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( Effect.provideService(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(EnvironmentId.make("relay-environment")), getDescriptor: unused(), + getDescriptorForSettings: unused, }), Effect.provideService(ProjectService.ProjectService, { create: unused, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index a9a028ec94bdf..9073f9d4e9651 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -126,8 +126,17 @@ it.effect("resends late editors without rolling back updates already sent", () = Effect.gen(function* () { const settingsSent = yield* Deferred.make(); const events = yield* withLateEditorConfig( - snapshotConfig({ settings: { enableProviderUpdateChecks: true } as never }), - Stream.make(settingsUpdated({ enableProviderUpdateChecks: false })), + snapshotConfig({ + settings: { enableProviderUpdateChecks: true } as never, + environment: { label: "Initial host" } as never, + }), + Stream.make({ + ...settingsUpdated({ enableProviderUpdateChecks: false }), + payload: { + settings: { enableProviderUpdateChecks: false } as never, + environment: { label: "Renamed host" } as never, + }, + }), { resolveAvailableEditors: () => Effect.succeed(["file-manager"]), resolveFileManagerRevealKind: () => @@ -148,6 +157,7 @@ it.effect("resends late editors without rolling back updates already sent", () = assert.deepEqual(second.config.availableEditors, ["file-manager"]); assert.equal(second.config.shellRevealInFileManagerKind, "file-explorer"); assert.deepEqual(second.config.settings, { enableProviderUpdateChecks: false } as never); + assert.equal(second.config.environment.label, "Renamed host"); } }), ); @@ -374,6 +384,13 @@ it.effect( "preserves settings during discovery and keybindings during snapshot ACK at the config RPC boundary", () => Effect.gen(function* () { + const configTestDescriptor = { + environmentId: EnvironmentId.make("config-test"), + label: "Config test", + platform: { os: "linux" as const, arch: "x64" as const }, + serverVersion: "0.0.0", + capabilities: { repositoryIdentity: false }, + }; const keybindings = yield* Keybindings.Keybindings; const settings = yield* ServerSettings.ServerSettingsService; const keybindingsSubscribed = yield* Deferred.make(); @@ -412,13 +429,8 @@ it.effect( namedProjectsRoot: process.cwd(), }), Layer.mock(ServerEnvironment.ServerEnvironment)({ - getDescriptor: Effect.succeed({ - environmentId: EnvironmentId.make("config-test"), - label: "Config test", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.0", - capabilities: { repositoryIdentity: false }, - }), + getDescriptor: Effect.succeed(configTestDescriptor), + getDescriptorForSettings: () => Effect.succeed(configTestDescriptor), }), Layer.mock(EnvironmentAuth.EnvironmentAuth)({ getDescriptor: () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8d76c6f853cd8..cb22b45e47114 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -343,7 +343,14 @@ export const withLateEditorConfig = ( case "providerStatuses": return [{ ...current, providers: event.payload.providers }, [event]]; case "settingsUpdated": - return [{ ...current, settings: event.payload.settings }, [event]]; + return [ + { + ...current, + settings: event.payload.settings, + environment: event.payload.environment ?? current.environment, + }, + [event], + ]; // Themes and usage-limit sources never ride in a snapshot; clients // carry their projected values across one. default: @@ -3030,13 +3037,15 @@ export const layerWsRpc = ( ) : Stream.empty; const settingsUpdates = settingsChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.mapEffect((settings) => - serverEnvironment.getDescriptor.pipe( + serverEnvironment.getDescriptorForSettings(settings).pipe( Effect.map((environment) => ({ version: 1 as const, type: "settingsUpdated" as const, - payload: { settings, environment }, + payload: { + settings: ServerSettings.redactServerSettingsForClient(settings), + environment, + }, })), ), ), From 21ae62c4d5326af5fe32dd804a4e8c64c848dc69 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 20 Aug 2026 07:55:33 -0700 Subject: [PATCH 04/27] fix(relay): order environment label updates --- infra/relay/src/environments/EnvironmentConnector.test.ts | 1 + infra/relay/src/environments/EnvironmentConnector.ts | 1 + infra/relay/src/environments/EnvironmentLinks.test.ts | 6 ++++-- infra/relay/src/environments/EnvironmentLinks.ts | 7 ++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index ec2f9966031e0..b20b06407e39b 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -316,6 +316,7 @@ describe("EnvironmentConnector", () => { userId: "user_123", environmentId: "env-connector-test", label: "Studio Mac", + checkedAt: expect.any(String), }, ]); }).pipe( diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 00c161df771f7..cd6194b9119f3 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -569,6 +569,7 @@ const make = Effect.gen(function* () { userId: input.userId, environmentId: input.environmentId, label: decoded.descriptor.label, + checkedAt: decoded.checkedAt, }) .pipe( Effect.tapError((error) => diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 785c53e32846c..2e3dc365afc1d 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -34,18 +34,20 @@ describe("EnvironmentLinks", () => { userId: "user-1", environmentId: "env-1", label: "Studio Mac", + checkedAt: "2026-08-20T12:00:00.000Z", }); expect(updateValues).toHaveLength(1); expect(updateValues[0]?.environmentLabel).toBe("Studio Mac"); - expect(typeof updateValues[0]?.updatedAt).toBe("string"); + expect(updateValues[0]?.updatedAt).toBe("2026-08-20T12:00:00.000Z"); expect(whereConditions).toHaveLength(1); const query = new PgDialect().sqlToQuery(whereConditions[0] as never); expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); - expect(query.params).toEqual(["user-1", "env-1"]); + expect(query.sql).toContain('"relay_environment_links"."updated_at" <= $3'); + expect(query.params).toEqual(["user-1", "env-1", "2026-08-20T12:00:00.000Z"]); }).pipe( Effect.provide( EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 8ac2d60aea30d..1eac6ba4a6248 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -9,7 +9,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; -import { and, eq, isNull, or, sql } from "drizzle-orm"; +import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -166,6 +166,7 @@ export class EnvironmentLinks extends Context.Service< readonly userId: string; readonly environmentId: string; readonly label: string; + readonly checkedAt: string; }) => Effect.Effect; readonly revokeForUser: (input: { readonly userId: string; @@ -475,18 +476,18 @@ const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ "relay.environment_id": input.environmentId, }); - const updatedAt = DateTime.formatIso(yield* DateTime.now); yield* db .update(relayEnvironmentLinks) .set({ environmentLabel: input.label, - updatedAt, + updatedAt: input.checkedAt, }) .where( and( eq(relayEnvironmentLinks.userId, input.userId), eq(relayEnvironmentLinks.environmentId, input.environmentId), isNull(relayEnvironmentLinks.revokedAt), + lte(relayEnvironmentLinks.updatedAt, input.checkedAt), ), ) .pipe( From 49a79b0e0114d687f4ef3434ca90919fccd521ca Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 20:49:24 -0700 Subject: [PATCH 05/27] fix(connect): adapt host naming to current clients --- .../src/environment/ServerEnvironment.test.ts | 5 + .../src/environment/ServerEnvironment.ts | 1 + apps/server/src/preview/ServerBrowser.test.ts | 1 + apps/server/src/server.ts | 6 +- apps/server/src/ws.test.ts | 20 +-- .../settings/ConnectionsSettings.tsx | 32 +++-- .../src/components/settings/settingsSearch.ts | 7 ++ docs/user/remote-access.md | 5 +- .../src/agentActivity/FcmDeliveries.test.ts | 1 + .../environments/EnvironmentConnector.test.ts | 114 ++++++++++-------- .../src/state/presentation.test.ts | 42 +++++++ .../client-runtime/src/state/presentation.ts | 6 +- .../client-runtime/src/state/server.test.ts | 29 +++++ .../src/state/sharedSettings.test.ts | 10 ++ packages/contracts/src/environment.ts | 2 + 15 files changed, 200 insertions(+), 81 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 98fbeb4a43a5d..aeb9e6c36da1b 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -265,6 +265,11 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { yield* secrets.set(RELAY_URL_SECRET, encode("https://relay.example")); const linked = yield* serverEnvironment.getDescriptor; expect(linked.capabilities.agentActivityPublishing).toBe(true); + const snapshot = yield* serverEnvironment.getDescriptorForSettings({ + environmentLabel: "Studio Mac", + }); + expect(snapshot.label).toBe("Studio Mac"); + expect(snapshot.capabilities).toEqual(linked.capabilities); yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, encode("false")); const disabled = yield* serverEnvironment.getDescriptor; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index dfed89c9656b8..095b31aca696a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -262,6 +262,7 @@ export const make = Effect.gen(function* () { serverResolvedCommandContext: true, threadStop: true, environmentIcon: true, + environmentLabel: true, projectCloneTracking: true, ignoredFileListing: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index ac4a785f207f3..6867f82fb073d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -255,6 +255,7 @@ const dependencies = Layer.mergeAll( Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(scope.environmentId), getDescriptor: Effect.die("unused descriptor"), + getDescriptorForSettings: () => Effect.die("unused descriptor"), }), Layer.succeed(PreviewBrowser.PreviewBrowser, { executable: Effect.die("mock Chromium does not need an executable"), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index a3d358f528566..49617265da30b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1053,11 +1053,7 @@ const layerMakeServer = Layer.unwrap( return layerServerApplication.pipe( Layer.provide(CloudLink.layer.pipe(Layer.provideMerge(ConnectReachability.layer))), Layer.provideMerge(layerRuntimeServices), - Layer.provideMerge( - McpSessionRegistry.layer.pipe( - Layer.provide(layerServerEnvironment), - ), - ), + Layer.provideMerge(McpSessionRegistry.layer.pipe(Layer.provide(layerServerEnvironment))), Layer.provide(layerActivation), Layer.provideMerge(RelayTracing.layerServerRelayBroker), Layer.provideMerge(layerHttpServer), diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 9073f9d4e9651..d1a1266bce327 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -116,10 +116,16 @@ import { const snapshotConfig = (fields: Partial) => ({ availableEditors: [], settings: {}, ...fields }) as unknown as ServerConfig; -const settingsUpdated = (settings: object): ServerConfigStreamEvent => ({ +const settingsUpdated = ( + settings: object, + environment?: ServerConfig["environment"], +): ServerConfigStreamEvent => ({ version: 1, type: "settingsUpdated", - payload: { settings: settings as ServerConfig["settings"] }, + payload: { + settings: settings as ServerConfig["settings"], + ...(environment === undefined ? {} : { environment }), + }, }); it.effect("resends late editors without rolling back updates already sent", () => @@ -130,13 +136,9 @@ it.effect("resends late editors without rolling back updates already sent", () = settings: { enableProviderUpdateChecks: true } as never, environment: { label: "Initial host" } as never, }), - Stream.make({ - ...settingsUpdated({ enableProviderUpdateChecks: false }), - payload: { - settings: { enableProviderUpdateChecks: false } as never, - environment: { label: "Renamed host" } as never, - }, - }), + Stream.make( + settingsUpdated({ enableProviderUpdateChecks: false }, { label: "Renamed host" } as never), + ), { resolveAvailableEditors: () => Effect.succeed(["file-manager"]), resolveFileManagerRevealKind: () => diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index feda4078d610f..f8d7f7311f892 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -655,17 +655,29 @@ export function ConnectionsSettings() { const renderEnvironmentLabelRow = () => ( updatePrimarySettings({ environmentLabel: next })} - placeholder="Use this computer’s name" - aria-label="Host name" - disabled={!canEditEnvironmentLabel || primaryEnvironment?.connection.phase !== "connected"} - spellCheck={false} - /> +
+ updatePrimarySettings({ environmentLabel: next })} + placeholder="Use this computer’s name" + aria-label="Host name" + disabled={ + !canEditEnvironmentLabel || + primaryEnvironment?.connection.phase !== "connected" || + primaryServerConfig?.environment.capabilities.environmentLabel !== true + } + spellCheck={false} + /> +
} /> ); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 38737b2627b06..ce0ccca41c144 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -893,6 +893,13 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/projects", searchTerms: ["commands scripts setup run dev server checkout worktree t3.json import"], }, + { + id: "environment-label", + title: "Host name", + to: "/settings/connections", + targetId: "connections-environment", + searchTerms: ["rename environment machine name hostname t3 connect"], + }, { id: "environment-icon", title: "Environment icon", diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 325353817c6b4..6cf5f8c5d7818 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -34,9 +34,8 @@ disconnect an otherwise healthy conversation. ## Name this host -In the web or desktop app, open **Settings → Connections** and edit **Host name** under -**This environment**. T3 Code uses that name in T3 Connect and as the default label when another -client adds the environment through a manual pairing connection. +In the web or desktop app, open Settings → Connections and edit Host name. Connected clients and +T3 Connect use the new name without a server restart. Clear the field to return to the name detected from the host operating system. This setting changes the environment name shown by T3 Code; it does not change the machine's operating-system hostname. diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index ba20315c97878..494681da056ad 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -156,6 +156,7 @@ function harness() { }), Layer.succeed(EnvironmentLinks.EnvironmentLinks, { upsert: () => Effect.void, + updateLabelForUser: () => Effect.void, listDeliveryUsersForEnvironment: (input) => Effect.sync(() => current.linked && !current.revokedEnvironments.includes(input.environmentId) diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index b20b06407e39b..1ebfe6e8353cf 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -275,64 +275,74 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(layerConnectorTest(execute))); }); - it.effect("persists a label reported by a verified environment health response", () => { - const updates: Array<{ userId: string; environmentId: string; label: string }> = []; - const execute = (request: HttpClientRequest.HttpClientRequest) => - Effect.sync(() => { - const healthRequest = decodeHealthRequestBody(requestBodyText(request)); - return HttpClientResponse.fromWeb( - request, - Response.json( - signHealthResponse( - healthRequest, - environmentKeyPair.privateKey, - {}, - { - descriptor: { - environmentId: "env-connector-test" as never, - label: "Studio Mac", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, + it.effect.each([false, true])( + "returns verified health when label persistence fails: %s", + (persistenceFails) => { + const updates: Array<{ userId: string; environmentId: string; label: string }> = []; + const execute = (request: HttpClientRequest.HttpClientRequest) => + Effect.sync(() => { + const healthRequest = decodeHealthRequestBody(requestBodyText(request)); + return HttpClientResponse.fromWeb( + request, + Response.json( + signHealthResponse( + healthRequest, + environmentKeyPair.privateKey, + {}, + { + descriptor: { + environmentId: "env-connector-test" as never, + label: "Studio Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, }, - }, + ), + { status: 200 }, ), - { status: 200 }, - ), - ); - }); - const links = makeLinks(); + ); + }); + const links = makeLinks(); - return Effect.gen(function* () { - const connector = yield* EnvironmentConnector.EnvironmentConnector; - const result = yield* connector.status({ - userId: "user_123", - environmentId: "env-connector-test", - }); - - expect(result.descriptor?.label).toBe("Studio Mac"); - expect(updates).toEqual([ - { + return Effect.gen(function* () { + const connector = yield* EnvironmentConnector.EnvironmentConnector; + const result = yield* connector.status({ userId: "user_123", environmentId: "env-connector-test", - label: "Studio Mac", - checkedAt: expect.any(String), - }, - ]); - }).pipe( - Effect.provide( - layerConnectorTest(execute, { - links: { - ...links, - updateLabelForUser: (input) => - Effect.sync(() => { - updates.push(input); - }), + }); + + expect(result.descriptor?.label).toBe("Studio Mac"); + expect(updates).toEqual([ + { + userId: "user_123", + environmentId: "env-connector-test", + label: "Studio Mac", + checkedAt: expect.any(String), }, - }), - ), - ); - }); + ]); + }).pipe( + Effect.provide( + layerConnectorTest(execute, { + links: { + ...links, + updateLabelForUser: (input) => + Effect.gen(function* () { + updates.push(input); + if (persistenceFails) { + return yield* new EnvironmentLinks.EnvironmentLinkLabelUpdatePersistenceError({ + userId: input.userId, + environmentId: input.environmentId, + cause: new Error("database unavailable"), + }); + } + }), + }, + }), + ), + ); + }, + ); it.effect("rejects manual endpoints before sending a health request", () => { let requestCount = 0; diff --git a/packages/client-runtime/src/state/presentation.test.ts b/packages/client-runtime/src/state/presentation.test.ts index 0240ee9be05c0..aa3381f4ae2b2 100644 --- a/packages/client-runtime/src/state/presentation.test.ts +++ b/packages/client-runtime/src/state/presentation.test.ts @@ -53,6 +53,48 @@ function harness() { } describe("environment summary subscriptions", () => { + it("updates labels from the live descriptor while keeping unrelated subscribers stable", () => { + const h = harness(); + const initialIds = h.registry.get(h.environmentIdsAtom); + const initialConnected = h.registry.get(h.connectedEnvironmentIdsAtom); + const initialMachines = h.registry.get(h.machineByIdAtom); + const counts = { labels: 0, connections: 0, ids: 0, connected: 0, machines: 0 }; + const stops = [ + h.registry.subscribe(h.identitiesAtom, () => counts.labels++), + h.registry.subscribe(h.environmentsAtom, () => counts.connections++), + h.registry.subscribe(h.environmentIdsAtom, () => counts.ids++), + h.registry.subscribe(h.connectedEnvironmentIdsAtom, () => counts.connected++), + h.registry.subscribe(h.machineByIdAtom, () => counts.machines++), + ]; + try { + const previous = config(); + h.registry.set(h.configs(FIRST), { + ...previous, + environment: { ...previous.environment, label: "Studio Mac" }, + }); + expect(h.registry.get(h.identitiesAtom)[0]?.label).toBe("Studio Mac"); + expect(h.registry.get(h.environmentsAtom)[0]?.environmentLabel).toBe("Studio Mac"); + expect(h.registry.get(h.environmentIdsAtom)).toBe(initialIds); + expect(h.registry.get(h.connectedEnvironmentIdsAtom)).toBe(initialConnected); + expect(h.registry.get(h.machineByIdAtom)).toBe(initialMachines); + expect(counts).toEqual({ labels: 1, connections: 1, ids: 0, connected: 0, machines: 0 }); + + // Clearing the override publishes the server's detected name. + h.registry.set(h.configs(FIRST), { + ...previous, + environment: { ...previous.environment, label: "Detected Mac" }, + }); + expect(h.registry.get(h.identitiesAtom)[0]?.label).toBe("Detected Mac"); + expect(h.registry.get(h.environmentsAtom)[0]?.environmentLabel).toBe("Detected Mac"); + h.registry.set(h.configs(FIRST), null); + expect(h.registry.get(h.identitiesAtom)[0]?.label).toBe("first"); + expect(h.registry.get(h.environmentsAtom)[0]?.environmentLabel).toBe("first"); + } finally { + stops.forEach((stop) => stop()); + h.registry.dispose(); + } + }); + it("tracks capabilities across environments, config loss and removal", () => { const h = harness(); const stop = h.registry.subscribe(h.pullRequestsSupportedAtom, () => {}); diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 29782aacfa613..bc991f865c6e1 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -200,7 +200,7 @@ export function projectEnvironmentConnectionSummary( ): EnvironmentConnectionSummary { return { environmentId, - environmentLabel: environment.entry.target.label, + environmentLabel: environment.serverConfig?.environment.label ?? environment.entry.target.label, displayUrl: connectionCatalogDisplayUrl(environment.entry) ?? "", isRelayManaged: hasRelayRoute(environment.entry), isEnabled: environment.entry.enabled, @@ -228,7 +228,9 @@ export function createEnvironmentSummaryAtoms(input: { const identitiesAtom = Atom.make((get) => { const next = [...get(input.catalogValueAtom).entries].map(([environmentId, entry]) => ({ environmentId, - label: entry.target.label, + label: + get(input.presentationAtom(environmentId))?.serverConfig?.environment.label ?? + entry.target.label, })); const previous = Option.getOrNull( get.self>(), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 931884a92c1bc..969a80c702011 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -381,6 +381,35 @@ describe("server state projection", () => { ).toBe(true); }); + it("carries the live environment name through settings updates", () => { + const snapshot = applyServerConfigProjection(Option.none(), { + version: 1, + type: "snapshot", + config: CONFIG, + }); + const settings = { ...CONFIG.settings }; + const environment = { + ...CONFIG.environment, + label: "Renamed environment", + } as ServerConfig["environment"]; + const projected = applyServerConfigProjection(snapshot, { + version: 1, + type: "settingsUpdated", + payload: { settings, environment }, + }); + + expect(Option.getOrThrow(projected).config.environment).toBe(environment); + + const legacyUpdate = Option.getOrThrow( + applyServerConfigProjection(projected, { + version: 1, + type: "settingsUpdated", + payload: { settings }, + }), + ); + expect(legacyUpdate.config.environment).toBe(environment); + }); + it("keeps published themes across a reconnect snapshot", () => { const themes = [ { diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 923462281d466..628fe431d2e37 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -10,6 +10,7 @@ import { filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, + splitSharedServerPatch, } from "./sharedSettings.ts"; const primaryId = EnvironmentId.make("env-primary"); @@ -17,6 +18,15 @@ const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); const restartCapabilities = { threadRestartContinuation: true }; +describe("splitSharedServerPatch", () => { + it("keeps host names on the environment being edited", () => { + expect(splitSharedServerPatch({ environmentLabel: "Studio Mac" })).toEqual({ + sharedPatch: {}, + localPatch: { environmentLabel: "Studio Mac" }, + }); + }); +}); + describe("filterSharedServerPatch", () => { it.each([true, false])( "resets a disabled default provider only on the originating environment (%s)", diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 4272c9d12d94b..ffba427b98012 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -144,6 +144,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), + /** Server persists a host name and streams its updated descriptor to connected clients. */ + environmentLabel: Schema.optionalKey(Schema.Boolean), desktopAppUpdate: Schema.optionalKey(Schema.Boolean), /** Server answers orchestration.subscribeCommandOutput. Absent on older servers, which never send command output, so clients must not subscribe. */ From ab40b7b014a906c26eba850e7ff540f9244cb8a0 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 21:23:24 -0700 Subject: [PATCH 06/27] fix(client): show live host names throughout settings --- .../features/connection/GitHubRoutingSettings.tsx | 13 ++++++------- .../features/files/WorkspaceFilePreviewError.tsx | 6 +++++- apps/mobile/src/features/review/ReviewSheet.tsx | 6 +++++- .../features/terminal/ThreadTerminalRouteScreen.tsx | 1 + .../src/features/usage/UsageLimitsSection.tsx | 8 +++++++- .../settings/ChatGptWelcomeCoordinator.tsx | 3 ++- .../src/components/usage/UsagePriceOverrides.tsx | 2 +- 7 files changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx index 199174208fd84..416efa4785d90 100644 --- a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx +++ b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx @@ -13,6 +13,7 @@ import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/ui/AppText"; import { SymbolView } from "../../components/ui/AppSymbol"; import { environmentCatalog } from "../../connection/catalog"; +import { useEnvironments } from "../../state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "../settings/components/SettingsSection"; @@ -35,33 +36,31 @@ const options: ReadonlyArray<{ ]; export function GitHubRoutingSettings() { + const { environments } = useEnvironments(); const catalog = useAtomValue(environmentCatalog.catalogValueAtom); const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); const [expanded, setExpanded] = useState(null); const [saving, setSaving] = useState(false); - if (catalog.entries.size === 0) return null; + if (environments.length === 0) return null; return ( - {[...catalog.entries.values()].map((entry) => { - const environmentId = entry.target.environmentId; + {environments.map(({ environmentId, entry, label }) => { const selected = gitHubRoutingPermissionFor(entry, permissions); const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; return ( setExpanded(expanded === environmentId ? null : environmentId)} > - - {entry.target.label} - + {label} {connectionCatalogDisplayUrl(entry) ?? "T3 Connect"} diff --git a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx index 79df6d05c700f..02e941c5a153e 100644 --- a/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx +++ b/apps/mobile/src/features/files/WorkspaceFilePreviewError.tsx @@ -26,7 +26,11 @@ export function WorkspaceFilePreviewError(props: { return ( [ ...previous.filter((failed) => failed.environmentId !== environmentId), ...(result._tag === "Failure" - ? [{ environmentId, label: presentation.entry.target.label }] + ? [ + { + environmentId, + label: + presentation.serverConfig?.environment.label ?? presentation.entry.target.label, + }, + ] : []), ]); }), diff --git a/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx index 6b449407abb26..71b77fb44b361 100644 --- a/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx +++ b/apps/web/src/components/settings/ChatGptWelcomeCoordinator.tsx @@ -41,7 +41,8 @@ export function ChatGptWelcomeCoordinator() { provider.instanceId, provider.auth.profileId ?? provider.auth.email ?? "default", ]), - environmentLabel: presentation.entry.target.label, + environmentLabel: + presentation.serverConfig?.environment.label ?? presentation.entry.target.label, providerName: provider.displayName ?? "Codex", })), ); diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index efa0f4535338f..19a9402a9ba11 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -59,7 +59,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => : "denied"; return { environmentId, - label: environment.entry.target.label, + label: environment.serverConfig?.environment.label ?? environment.entry.target.label, prices: settings?.usagePriceOverrides ?? null, aliases: environment.serverConfig?.environment.capabilities.usageModelAliases === true From a16d31fe32adfce71ca9960e39181e3d663367fa Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 21:37:01 -0700 Subject: [PATCH 07/27] fix(relay): retain ordering when a host name is cleared --- .../environments/EnvironmentConnector.test.ts | 13 ++++--- .../src/environments/EnvironmentConnector.ts | 36 +++++++++---------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index 1ebfe6e8353cf..db71918dae6ef 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -275,9 +275,14 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(layerConnectorTest(execute))); }); - it.effect.each([false, true])( - "returns verified health when label persistence fails: %s", - (persistenceFails) => { + it.effect.each([ + { persistenceFails: false, storedLabel: "Connector Test Environment" }, + { persistenceFails: true, storedLabel: "Connector Test Environment" }, + { persistenceFails: false, storedLabel: "Studio Mac" }, + { persistenceFails: true, storedLabel: "Studio Mac" }, + ])( + "records the verified label timestamp and retains health on persistence failure: %s", + ({ persistenceFails, storedLabel }) => { const updates: Array<{ userId: string; environmentId: string; label: string }> = []; const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { @@ -303,7 +308,7 @@ describe("EnvironmentConnector", () => { ), ); }); - const links = makeLinks(); + const links = makeLinks({ label: storedLabel }); return Effect.gen(function* () { const connector = yield* EnvironmentConnector.EnvironmentConnector; diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index cd6194b9119f3..6bda3dd545c68 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -563,24 +563,24 @@ const make = Effect.gen(function* () { operation: "status", }); } - if (decoded.descriptor.label !== link.label) { - yield* links - .updateLabelForUser({ - userId: input.userId, - environmentId: input.environmentId, - label: decoded.descriptor.label, - checkedAt: decoded.checkedAt, - }) - .pipe( - Effect.tapError((error) => - Effect.logWarning("managed environment label persistence failed", { - environmentId: input.environmentId, - errorTag: error._tag, - }), - ), - Effect.ignore, - ); - } + // Record unchanged labels too: a newer probe after clearing a name must + // prevent an older rename response from overwriting that name again. + yield* links + .updateLabelForUser({ + userId: input.userId, + environmentId: input.environmentId, + label: decoded.descriptor.label, + checkedAt: decoded.checkedAt, + }) + .pipe( + Effect.tapError((error) => + Effect.logWarning("managed environment label persistence failed", { + environmentId: input.environmentId, + errorTag: error._tag, + }), + ), + Effect.ignore, + ); return { environmentId: link.environmentId, endpoint, From dadae398a96aa2afd350ab983b8c39c1b9d48d33 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 21:50:09 -0700 Subject: [PATCH 08/27] test(mobile): include descriptors in connection fixtures --- .../src/state/workspace-connection-atoms.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/state/workspace-connection-atoms.test.ts b/apps/mobile/src/state/workspace-connection-atoms.test.ts index 64c7256ca89fb..77ad87be50a76 100644 --- a/apps/mobile/src/state/workspace-connection-atoms.test.ts +++ b/apps/mobile/src/state/workspace-connection-atoms.test.ts @@ -83,7 +83,16 @@ describe("workspace connection subscriptions", () => { expect(broadChanges).toBe(20); expect(connectionChanges).toBe(0); for (let index = 0; index < 4; index++) { - const config = { cwd: `/workspace-${index}` } as ServerConfig; + const config = { + cwd: `/workspace-${index}`, + environment: { + environmentId: ID, + label: "Mac", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + } as ServerConfig; h.registry.set(h.source, { ...presentation(), serverConfig: config }); expect(h.registry.get(h.source)?.serverConfig).toBe(config); expect(h.registry.get(h.stateAtom)).toBe(initial); From 6dec5a2e5b94b550814fa3da21dc36b84761e818 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 22:52:09 -0700 Subject: [PATCH 09/27] fix(connect): bind host label updates to the verified key --- .../src/environments/EnvironmentConnector.test.ts | 7 +++++-- .../relay/src/environments/EnvironmentConnector.ts | 1 + .../relay/src/environments/EnvironmentLinks.test.ts | 13 ++++++++++--- infra/relay/src/environments/EnvironmentLinks.ts | 2 ++ 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/infra/relay/src/environments/EnvironmentConnector.test.ts b/infra/relay/src/environments/EnvironmentConnector.test.ts index db71918dae6ef..ce3062b183396 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -281,9 +281,11 @@ describe("EnvironmentConnector", () => { { persistenceFails: false, storedLabel: "Studio Mac" }, { persistenceFails: true, storedLabel: "Studio Mac" }, ])( - "records the verified label timestamp and retains health on persistence failure: %s", + "records the verified label, timestamp and captured key, retaining health on persistence failure: %s", ({ persistenceFails, storedLabel }) => { - const updates: Array<{ userId: string; environmentId: string; label: string }> = []; + const updates: Array< + Parameters[0] + > = []; const execute = (request: HttpClientRequest.HttpClientRequest) => Effect.sync(() => { const healthRequest = decodeHealthRequestBody(requestBodyText(request)); @@ -322,6 +324,7 @@ describe("EnvironmentConnector", () => { { userId: "user_123", environmentId: "env-connector-test", + environmentPublicKey: environmentKeyPair.publicKey, label: "Studio Mac", checkedAt: expect.any(String), }, diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index 6bda3dd545c68..c1e511c13ffef 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -569,6 +569,7 @@ const make = Effect.gen(function* () { .updateLabelForUser({ userId: input.userId, environmentId: input.environmentId, + environmentPublicKey: link.environmentPublicKey, label: decoded.descriptor.label, checkedAt: decoded.checkedAt, }) diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 2e3dc365afc1d..f5c641fee97c8 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -8,7 +8,7 @@ import { relayEnvironmentLinks } from "../persistence/schema.ts"; import * as EnvironmentLinks from "./EnvironmentLinks.ts"; describe("EnvironmentLinks", () => { - it.effect("updates the active link label owned by the requesting user", () => { + it.effect("updates labels only for the user’s verified active link", () => { const updateValues: Array> = []; const whereConditions: Array = []; const fakeDb = { @@ -33,6 +33,7 @@ describe("EnvironmentLinks", () => { yield* links.updateLabelForUser({ userId: "user-1", environmentId: "env-1", + environmentPublicKey: "verified-public-key", label: "Studio Mac", checkedAt: "2026-08-20T12:00:00.000Z", }); @@ -45,9 +46,15 @@ describe("EnvironmentLinks", () => { const query = new PgDialect().sqlToQuery(whereConditions[0] as never); expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); expect(query.sql).toContain('"relay_environment_links"."environment_id" = $2'); + expect(query.sql).toContain('"relay_environment_links"."environment_public_key" = $3'); expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); - expect(query.sql).toContain('"relay_environment_links"."updated_at" <= $3'); - expect(query.params).toEqual(["user-1", "env-1", "2026-08-20T12:00:00.000Z"]); + expect(query.sql).toContain('"relay_environment_links"."updated_at" <= $4'); + expect(query.params).toEqual([ + "user-1", + "env-1", + "verified-public-key", + "2026-08-20T12:00:00.000Z", + ]); }).pipe( Effect.provide( EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 1eac6ba4a6248..8af1cf1a6f933 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -165,6 +165,7 @@ export class EnvironmentLinks extends Context.Service< readonly updateLabelForUser: (input: { readonly userId: string; readonly environmentId: string; + readonly environmentPublicKey: string; readonly label: string; readonly checkedAt: string; }) => Effect.Effect; @@ -486,6 +487,7 @@ const make = Effect.gen(function* () { and( eq(relayEnvironmentLinks.userId, input.userId), eq(relayEnvironmentLinks.environmentId, input.environmentId), + eq(relayEnvironmentLinks.environmentPublicKey, input.environmentPublicKey), isNull(relayEnvironmentLinks.revokedAt), lte(relayEnvironmentLinks.updatedAt, input.checkedAt), ), From 3af176da9b15ed214787526de5e7fed1026cd3ff Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Mon, 12 Oct 2026 22:52:48 -0700 Subject: [PATCH 10/27] feat(mobile): rename Connect hosts from the connection editor --- .../connection/ConnectionEnvironmentRow.tsx | 323 +++++++++++------- .../connection/environmentUpdate.test.ts | 118 +++++++ .../features/connection/environmentUpdate.ts | 30 ++ .../connection/useConnectionController.ts | 19 +- .../files/WorkspaceFilePreviewError.tsx | 10 +- .../src/features/review/ReviewSheet.tsx | 10 +- .../terminal/ThreadTerminalRouteScreen.tsx | 11 +- .../src/features/usage/UsageLimitsSection.tsx | 4 +- apps/mobile/src/state/environments.ts | 3 +- .../remote-environment-projections.test.ts | 56 ++- .../state/remote-environment-projections.ts | 14 +- apps/server/src/auth/RpcAuthorization.test.ts | 31 ++ apps/server/src/auth/RpcAuthorization.ts | 19 -- .../settings/ChatGptWelcomeCoordinator.tsx | 7 +- .../components/usage/UsagePriceOverrides.tsx | 3 +- apps/web/src/state/environments.ts | 3 +- docs/user/remote-access.md | 4 + .../src/connection/presentation.test.ts | 50 ++- .../src/connection/presentation.ts | 11 + .../src/state/commandPermissions.test.ts | 104 ++++++ .../src/state/presentation.test.ts | 71 +++- .../client-runtime/src/state/presentation.ts | 10 +- .../contracts/src/clientRpcPermissions.ts | 19 ++ 23 files changed, 746 insertions(+), 184 deletions(-) create mode 100644 apps/mobile/src/features/connection/environmentUpdate.test.ts create mode 100644 apps/mobile/src/features/connection/environmentUpdate.ts diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index aa332b407b2e3..752bd73e8c70a 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -3,11 +3,15 @@ import { ConnectionTraceId } from "./ConnectionTraceId"; import { SymbolView } from "../../components/ui/AppSymbol"; import { hasSavedBearerRoute, hostFreezeNotice } from "@t3tools/client-runtime/connection"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; +import { + type EnvironmentId, + type ServerConfig, + resolveEnvironmentMachineKind, +} from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import { Platform, Pressable, View } from "react-native"; import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"; @@ -24,6 +28,7 @@ import { ConnectionDiagnosisDetails, useConnectionDiagnosis } from "./Connection import { ConnectionFormField } from "./ConnectionFormField"; import { ConnectionStatusDot } from "./ConnectionStatusDot"; import { environmentStatusText } from "./environmentStatusText"; +import { environmentCatalog } from "../../connection/catalog"; function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string | null { if (!environment.isEnabled && environment.connectionState !== "unsupported") { @@ -36,7 +41,7 @@ function connectionStatusLabel(environment: ConnectedEnvironmentSummary): string }); } -export function ConnectionEnvironmentRow(props: { +interface ConnectionEnvironmentRowProps { readonly environment: ConnectedEnvironmentSummary; readonly expanded: boolean; readonly opensDetails?: boolean; @@ -48,9 +53,12 @@ export function ConnectionEnvironmentRow(props: { environmentId: EnvironmentId, updates: { readonly label: string; readonly displayUrl: string }, ) => Promise>; -}) { - const [label, setLabel] = useState(props.environment.environmentLabel); - const [url, setUrl] = useState(props.environment.displayUrl); +} + +export function ConnectionEnvironmentRow(props: ConnectionEnvironmentRowProps) { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const isHostName = + catalog.entries.get(props.environment.environmentId)?.target._tag === "RelayConnectionTarget"; const serverConfig = useAtomValue( serverEnvironment.configValueAtom(props.environment.environmentId), ); @@ -74,21 +82,6 @@ export function ConnectionEnvironmentRow(props: { enabled && (props.environment.connectionState === "connecting" || props.environment.connectionState === "reconnecting"); - const handleSave = useCallback(async () => { - const result = await props.onUpdate(props.environment.environmentId, { - label: label.trim(), - displayUrl: url.trim(), - }); - if (AsyncResult.isSuccess(result)) { - props.onToggle(); - return; - } - const error = Cause.squash(result.cause); - Dialog.alert( - "Could not update environment", - error instanceof Error ? error.message : "The environment could not be updated.", - ); - }, [label, url, props]); return ( @@ -121,7 +114,7 @@ export function ConnectionEnvironmentRow(props: { {props.environment.environmentLabel} - {!props.environment.isRelayManaged && props.environment.displayUrl.trim() ? ( + {!isHostName && props.environment.displayUrl.trim() ? ( {props.environment.displayUrl} @@ -197,119 +190,187 @@ export function ConnectionEnvironmentRow(props: { {props.expanded ? ( - - {diagnosis ? ( - - ) : null} - {props.environment.isRelayManaged ? ( - - Managed by T3 Connect. Tunnel details update automatically. - - ) : ( - <> - + + ) : null} + + ); +} - - - )} +/** Mounted only while expanded, so each opening starts from the latest saved values. */ +function ConnectionEnvironmentEditor( + props: ConnectionEnvironmentRowProps & { + readonly isHostName: boolean; + readonly enabled: boolean; + readonly serverConfig: ServerConfig | null; + readonly diagnosis: ReturnType["diagnosis"]; + }, +) { + const { isHostName, enabled, serverConfig, diagnosis } = props; + const [draft, setDraft] = useState<{ readonly label?: string; readonly url?: string }>({}); + const [saving, setSaving] = useState(false); + const label = + draft.label ?? + (isHostName + ? (serverConfig?.settings.environmentLabel ?? "") + : props.environment.environmentLabel); + const url = draft.url ?? props.environment.displayUrl; + const canUpdateSettings = useAtomValue( + serverEnvironment.updateSettings.permissionAtom(props.environment.environmentId, { + patch: { environmentLabel: label.trim() }, + }), + ); + const canEditHost = + props.environment.connectionState === "connected" && + serverConfig?.environment.capabilities.environmentLabel === true && + canUpdateSettings; + const canSave = !saving && (!isHostName || canEditHost); + async function handleSave() { + if (!canSave) return; + setSaving(true); + const result = await props + .onUpdate(props.environment.environmentId, { + label: label.trim(), + displayUrl: url.trim(), + }) + .finally(() => setSaving(false)); + if (AsyncResult.isSuccess(result)) { + props.onToggle(); + return; + } + const error = Cause.squash(result.cause); + Dialog.alert( + "Could not update environment", + error instanceof Error ? error.message : "The environment could not be updated.", + ); + } - {Platform.OS === "android" ? ( - - {props.environment.isRelayManaged ? null : ( - -