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(