diff --git a/apps/mobile/src/state/environments.ts b/apps/mobile/src/state/environments.ts
index 88d80631ad3..836a021769d 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 0369d47e4d7..2102c6b8cc3 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 {
@@ -430,6 +431,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* ,
options?: {
readonly quietLogs?: boolean;
@@ -446,7 +448,9 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function*
- 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);
@@ -72,6 +76,28 @@ 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;
+ yield* serverSettings.updateSettings({ environmentLabel: "" });
+ const reset = yield* serverEnvironment.getDescriptor;
+
+ expect(renamed).toEqual({ ...initial, label: "Studio Mac" });
+ 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;
@@ -111,6 +137,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 0eaf5a7c16a..28b78fb578e 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -12,6 +12,7 @@ import packageJson from "../../package.json" with { type: "json" };
import { resolveServerSelfUpdateCapability } from "../cloud/selfUpdate.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()(
@@ -65,6 +66,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;
@@ -124,14 +126,14 @@ 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 serverSelfUpdate = yield* resolveServerSelfUpdateCapability({
desktopManaged: serverConfig.mode === "desktop",
});
- const descriptor: ExecutionEnvironmentDescriptor = {
+ const defaultDescriptor: ExecutionEnvironmentDescriptor = {
environmentId,
- label,
+ label: defaultLabel,
platform: {
os: platformOs(hostPlatform),
arch: platformArch(hostArchitecture),
@@ -148,7 +150,21 @@ export const make = Effect.gen(function* () {
return ServerEnvironment.of({
getEnvironmentId: Effect.succeed(environmentId),
- getDescriptor: Effect.succeed(descriptor),
+ 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)),
+ ),
+ ),
});
});
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 6c021c9af80..eb79c9c5f57 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1926,11 +1926,15 @@ const makeWsRpcLayer = (
);
const settingsUpdates = serverSettings.streamChanges.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 },
+ })),
+ ),
+ ),
);
yield* providerRegistry
diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx
index 52b89530127..a147002a569 100644
--- a/apps/web/src/components/settings/ConnectionsSettings.tsx
+++ b/apps/web/src/components/settings/ConnectionsSettings.tsx
@@ -79,6 +79,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 { Group, GroupSeparator } from "../ui/group";
import { AnimatedHeight } from "../AnimatedHeight";
@@ -131,6 +132,7 @@ import {
usePrimaryEnvironment,
} from "~/state/environments";
import { useAtomCommand } from "../../state/use-atom-command";
+import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
import { ConnectionStatusDot } from "../ConnectionStatusDot";
import { ServerUpdateAction } from "../ServerUpdateAction";
import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList";
@@ -1713,6 +1715,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,
@@ -1843,6 +1847,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({
@@ -2538,6 +2545,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) => {
@@ -3003,6 +3027,7 @@ export function ConnectionsSettings() {
}
/>
) : null}
+ {renderEnvironmentLabelRow()}
{desktopBridge ? (
<>
{renderNetworkAccessRow()}
@@ -3307,6 +3332,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 ca39484373e..4178575b297 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 7f536bafb37..73225c522e5 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 db662aee94d..dd542eeeebd 100644
--- a/infra/relay/src/environments/EnvironmentConnector.ts
+++ b/infra/relay/src/environments/EnvironmentConnector.ts
@@ -542,6 +542,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 c0811e82d92..fa019f5bb42 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 dccb9e39f60..a77cd0213db 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 6630af0a11b..6b2d309b898 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 daf756a2b7c..c69aa8974a7 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 8cb963000bd..a65e9f53282 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 855bb2654ed..e4d613887fd 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 ddc8813316f..aa5f628f913 100644
--- a/packages/client-runtime/src/state/server.test.ts
+++ b/packages/client-runtime/src/state/server.test.ts
@@ -69,14 +69,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 5f93a3edb6e..21036ae0cda 100644
--- a/packages/client-runtime/src/state/server.ts
+++ b/packages/client-runtime/src/state/server.ts
@@ -68,6 +68,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 8e42c938ca4..f2be9d8bd18 100644
--- a/packages/contracts/src/server.ts
+++ b/packages/contracts/src/server.ts
@@ -474,6 +474,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 2bc61d72f21..c5d12edef50 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 7edda2e52e5..3e4a221915d 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -468,6 +468,7 @@ export const BackgroundActivitySettings = Schema.Struct({
export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
export const ServerSettings = Schema.Struct({
+ environmentLabel: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
enableProviderUpdateChecks: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
backgroundActivity: BackgroundActivitySettings,
@@ -625,6 +626,7 @@ const OpenCodeSettingsPatch = Schema.Struct({
export const ServerSettingsPatch = Schema.Struct({
// Server settings
+ environmentLabel: Schema.optionalKey(TrimmedString),
enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
backgroundActivity: Schema.optionalKey(