diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts index 88d80631ad31..836a021769da 100644 --- a/apps/mobile/src/state/environments.ts +++ b/apps/mobile/src/state/environments.ts @@ -24,7 +24,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: presentation.entry.target._tag === "RelayConnectionTarget", }; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index ef15e650a6f2..8ef50fa0f241 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -44,6 +44,7 @@ import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts"; import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; @@ -431,6 +432,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* , options?: { readonly quietLogs?: boolean; @@ -447,7 +449,9 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* { ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: unusedSecretStoreOperation(), getDescriptor: unusedSecretStoreOperation(), + getDescriptorForSettings: () => { + throw new Error("unused"); + }, }), ), Effect.provideService( @@ -295,6 +298,9 @@ describe("releaseManagedTunnelOnShutdown", () => { ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(EnvironmentId.make("env_123")), getDescriptor: Effect.die("unused"), + getDescriptorForSettings: () => { + throw new Error("unused"); + }, }), ), Effect.provideService( diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index a7aea90f826c..c182e763c8d3 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -7,6 +7,7 @@ import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; const isServerEnvironmentIdPersistenceError = Schema.is( @@ -14,7 +15,10 @@ const isServerEnvironmentIdPersistenceError = Schema.is( ); const makeServerEnvironmentLayer = (baseDir: string) => - ServerEnvironment.layer.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir))); + ServerEnvironment.layer.pipe( + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + ); const makeServerConfig = Effect.fn(function* (baseDir: string) { const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); @@ -73,6 +77,32 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }), ); + 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; + const renamedFromSnapshot = serverEnvironment.getDescriptorForSettings({ + environmentLabel: "Studio Mac", + }); + yield* serverSettings.updateSettings({ environmentLabel: "" }); + const reset = yield* serverEnvironment.getDescriptor; + + expect(renamed).toEqual({ ...initial, label: "Studio Mac" }); + expect(renamedFromSnapshot).toEqual(renamed); + expect(reset).toEqual(initial); + }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir))); + }), + ); + it.effect("structures persisted environment id filesystem failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -112,6 +142,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { }).pipe( Effect.provide( ServerEnvironment.layer.pipe( + Layer.provideMerge(ServerSettings.layerTest()), Layer.provide(Layer.merge(ServerConfig.layer(serverConfig), failingFileSystemLayer)), ), ), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c697b4bd98f5..a8fe0c47460b 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -13,6 +13,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"; export class ServerEnvironmentIdPersistenceError extends Schema.TaggedErrorClass()( @@ -33,6 +34,9 @@ export class ServerEnvironment extends Context.Service< { readonly getEnvironmentId: Effect.Effect; readonly getDescriptor: Effect.Effect; + readonly getDescriptorForSettings: (settings: { + readonly environmentLabel: string; + }) => ExecutionEnvironmentDescriptor; } >()("t3/environment/ServerEnvironment") {} @@ -66,6 +70,7 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const hostPlatform = yield* HostProcessPlatform; const hostArchitecture = yield* HostProcessArchitecture; @@ -125,16 +130,16 @@ export const make = Effect.gen(function* () { const environmentId = EnvironmentId.make(environmentIdRaw); const cwdBaseName = path.basename(serverConfig.cwd).trim(); - const label = yield* resolveServerEnvironmentLabel({ cwdBaseName }); + const defaultLabel = yield* resolveServerEnvironmentLabel({ cwdBaseName }); const launcher = yield* resolveServiceLauncherMode(); const serverSelfUpdate = resolveServerSelfUpdateCapability({ desktopManaged: serverConfig.mode === "desktop", launcherManaged: launcher.managed, }); - const descriptor: ExecutionEnvironmentDescriptor = { + const defaultDescriptor: ExecutionEnvironmentDescriptor = { environmentId, - label, + label: defaultLabel, platform: { os: platformOs(hostPlatform), arch: platformArch(hostArchitecture), @@ -152,10 +157,24 @@ export const make = Effect.gen(function* () { ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, }; + const getDescriptorForSettings = (settings: { readonly environmentLabel: string }) => ({ + ...defaultDescriptor, + label: settings.environmentLabel || defaultLabel, + }); return ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), - getDescriptor: Effect.succeed(descriptor), + getDescriptor: serverSettings.getSettings.pipe( + Effect.map(getDescriptorForSettings), + 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)), + ), + ), + getDescriptorForSettings, }); }); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 1d8aead99d0d..b28430622f7b 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -17,6 +17,9 @@ const fakeHttpServer = makeFakeHttpServer("127.0.0.1"); const fakeEnvironment = ServerEnvironment.ServerEnvironment.of({ getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.die("unused"), + getDescriptorForSettings: () => { + throw new Error("unused"); + }, }); const makeRegistry = (now: () => number, httpServer = fakeHttpServer) => diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 74a4de594a15..adede8cd4c6e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -511,6 +511,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), + getDescriptorForSettings: (settings) => ({ + ...descriptor, + label: settings.environmentLabel || descriptor.label, + }), }), Layer.succeed(OrchestrationEngineService, orchestrationEngine), Layer.succeed(ProjectionSnapshotQuery, snapshotQuery), @@ -661,6 +665,10 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.succeed(ServerEnvironment.ServerEnvironment, { getEnvironmentId: Effect.succeed(environmentId), getDescriptor: Effect.succeed(descriptor), + getDescriptorForSettings: (settings) => ({ + ...descriptor, + label: settings.environmentLabel || descriptor.label, + }), }), Layer.succeed(OrchestrationEngineService, { readEvents: () => Stream.empty, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cb64c6a4802d..547dc309076a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -906,6 +906,12 @@ const buildAppUnderTest = (options?: { getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId), getDescriptor: Effect.succeed(testEnvironmentDescriptor), ...options?.layers?.serverEnvironment, + getDescriptorForSettings: + options?.layers?.serverEnvironment?.getDescriptorForSettings ?? + ((settings) => ({ + ...testEnvironmentDescriptor, + label: settings.environmentLabel || testEnvironmentDescriptor.label, + })), }), ), Layer.provide( @@ -4583,6 +4589,43 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("uses the emitted settings snapshot for environment label updates", () => + Effect.gen(function* () { + const updatedSettings = { + ...DEFAULT_SERVER_SETTINGS, + environmentLabel: "Studio Mac", + }; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + streamChanges: Stream.succeed(updatedSettings), + }, + serverEnvironment: { + getDescriptor: Effect.succeed(testEnvironmentDescriptor), + getDescriptorForSettings: (settings) => ({ + ...testEnvironmentDescriptor, + label: settings.environmentLabel || testEnvironmentDescriptor.label, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const update = Array.from(events)[1]; + assert.equal(update?.type, "settingsUpdated"); + if (update?.type === "settingsUpdated") { + assert.equal(update.payload.settings.environmentLabel, "Studio Mac"); + assert.equal(update.payload.environment?.label, "Studio Mac"); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5cbe64cd413a..eb4db0c69c65 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2096,11 +2096,13 @@ const makeWsRpcLayer = ( Stream.debounce(Duration.millis(PROVIDER_STATUS_DEBOUNCE_MS)), ); const settingsUpdates = serverSettings.streamChanges.pipe( - Stream.map((settings) => ServerSettings.redactServerSettingsForClient(settings)), Stream.map((settings) => ({ version: 1 as const, type: "settingsUpdated" as const, - payload: { settings }, + payload: { + settings: ServerSettings.redactServerSettingsForClient(settings), + environment: serverEnvironment.getDescriptorForSettings(settings), + }, })), ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 300c71a338f4..6ac1fcbf0a96 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -83,6 +83,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 { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { AnimatedHeight } from "../AnimatedHeight"; import { Textarea } from "../ui/textarea"; @@ -126,6 +127,7 @@ import { } from "~/state/environments"; import { useAtomCommand } from "../../state/use-atom-command"; import { serverEnvironment } from "~/state/server"; +import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { ConnectionStatusDot } from "../ConnectionStatusDot"; import { ServerUpdateAction, ServerUpdateProgress } from "../ServerUpdateAction"; import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; @@ -1728,6 +1730,8 @@ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); + const environmentLabel = usePrimarySettings((settings) => settings.environmentLabel); + const updatePrimarySettings = useUpdatePrimarySettings(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); const connectSshEnvironment = useAtomCommand(connectSshEnvironmentAtom, { reportFailure: false, @@ -1861,6 +1865,9 @@ export function ConnectionsSettings() { ); const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; + const canEditEnvironmentLabel = + primaryEnvironmentId !== null && + (currentSessionScopes?.includes(AuthOrchestrationOperateScope) ?? false); const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null ? authEnvironment.accessChanges({ @@ -2556,6 +2563,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} + spellCheck={false} + /> + } + /> + ); const renderEndpointRows = (presentation: AccessSectionPresentation) => isAdvertisedEndpointListExpanded ? visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => { @@ -3041,6 +3065,7 @@ export function ConnectionsSettings() { } /> ) : null} + {renderEnvironmentLabelRow()} {desktopBridge ? ( <> {renderNetworkAccessRow()} @@ -3345,6 +3370,7 @@ export function ConnectionsSettings() { ) : ( + {renderEnvironmentLabelRow()} Effect.succeed([]), listForUser: () => Effect.succeed([]), getForUser: () => Effect.succeed(null), + 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 ca39484373e9..4178575b297a 100644 --- a/infra/relay/src/agentActivity/MobileRegistrations.test.ts +++ b/infra/relay/src/agentActivity/MobileRegistrations.test.ts @@ -108,6 +108,7 @@ function makeEnvironmentLinks( listPublicKeysForEnvironment: () => Effect.succeed([]), listForUser: () => Effect.succeed([]), getForUser: () => Effect.succeed(null), + 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 7f536bafb375..73225c522e59 100644 --- a/infra/relay/src/environments/EnvironmentConnector.test.ts +++ b/infra/relay/src/environments/EnvironmentConnector.test.ts @@ -226,6 +226,7 @@ function makeLinks( environmentPublicKey: environmentKeyPair.publicKey, ...overrides, }), + updateLabelForUser: () => Effect.void, revokeForUser: () => Effect.succeed(false), }; } @@ -323,6 +324,64 @@ describe("EnvironmentConnector", () => { }).pipe(Effect.provide(connectorTestLayer(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 d840f809e5af..abf4d5790e59 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -530,6 +530,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 c0811e82d923..fa019f5bb426 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -128,6 +128,7 @@ function testLayer(input?: { listPublicKeysForEnvironment: () => Effect.succeed([]), listForUser: () => Effect.succeed([]), getForUser: () => Effect.succeed(null), + 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 dccb9e39f60f..a77cd0213dbc 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -42,6 +42,51 @@ 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("identifies delivery-user list failures without retaining key material", () => { const cause = new Error("database unavailable"); const fakeDb = { diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 6630af0a11bf..6b2d309b8984 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -88,6 +88,19 @@ export class EnvironmentLinkLookupPersistenceError extends Schema.TaggedErrorCla } } +export class EnvironmentLinkLabelUpdatePersistenceError extends Schema.TaggedErrorClass()( + "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.TaggedErrorClass()( "EnvironmentLinkRevokePersistenceError", { @@ -133,6 +146,11 @@ export class EnvironmentLinks extends Context.Service< readonly userId: string; readonly environmentId: string; }) => 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; @@ -396,6 +414,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 daf756a2b7cc..c69aa8974a7f 100644 --- a/infra/relay/src/http/Api.test.ts +++ b/infra/relay/src/http/Api.test.ts @@ -185,6 +185,7 @@ function relayUnlinkTestLayer(input?: { listPublicKeysForEnvironment: () => Effect.die("unused listPublicKeysForEnvironment"), listForUser: () => Effect.die("unused listForUser"), getForUser: input?.getForUser ?? (() => Effect.succeed(null)), + 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 8cb963000bd3..a65e9f53282d 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -46,12 +46,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 }, + }, + } + : {}), }; } @@ -190,7 +202,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( @@ -204,6 +216,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 855bb2654edb..e4d613887fd2 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -186,6 +186,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(), @@ -240,7 +246,7 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { })); return; } - return yield* Effect.fail(failure); + return yield* failure; } const clerkToken = tokenResult.success; if ((yield* Ref.get(accountGeneration)) !== generation) { diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 8edecae5646e..1b268f8fff04 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -271,14 +271,19 @@ describe("server state projection", () => { 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 }, + payload: { settings, environment }, }); const result = Option.getOrThrow(projected); expect(result.config.settings).toBe(settings); + expect(result.config.environment).toBe(environment); expect(result.latestEvent.type).toBe("settingsUpdated"); }); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..cac4bcf3f154 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -289,6 +289,7 @@ export function applyServerConfigProjection( config: { ...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 d7bc4c5c1898..878f003c45ca 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -490,6 +490,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 46705837afa4..a5efd1044923 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -179,6 +179,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("defaults all style settings for legacy configs", () => { const settings = decodeServerSettings({}); @@ -239,6 +252,7 @@ describe("ServerSettingsPatch.providerInstances", () => { describe("ServerSettingsPatch string normalization", () => { it("trims string settings while decoding patches", () => { const patch = decodeServerSettingsPatch({ + environmentLabel: " Studio Mac ", addProjectBaseDirectory: " ~/Development ", textGenerationModelSelection: { model: " gpt-5.4-mini " }, observability: { @@ -260,6 +274,7 @@ describe("ServerSettingsPatch string normalization", () => { }, }); + expect(patch.environmentLabel).toBe("Studio Mac"); expect(patch.addProjectBaseDirectory).toBe("~/Development"); expect(patch.textGenerationModelSelection?.model).toBe("gpt-5.4-mini"); expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 388205649c85..dc0961353df2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -538,6 +538,7 @@ export const BackgroundActivitySettings = Schema.Struct({ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type; export const ServerSettings = Schema.Struct({ + environmentLabel: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), // Legacy token-by-token assistant output. Deliberately a fresh key (was // `enableAssistantStreaming`): decoding drops the old key, so everyone, // including prior opt-ins, resets to the buffered default. @@ -706,6 +707,7 @@ const OpenCodeSettingsPatch = Schema.Struct({ export const ServerSettingsPatch = Schema.Struct({ // Server settings + environmentLabel: Schema.optionalKey(TrimmedString), enableLegacyTokenStreaming: Schema.optionalKey(Schema.Boolean), enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey(