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(