diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 3ba7af66de98a..028089bcb8dd1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -87,6 +87,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetDatabaseStorage]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.peerLinksList]: AuthAccessReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 89f93a9df2c81..774ba7da53ee2 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -64,6 +64,7 @@ const RPC_AGGREGATES = { [WS_METHODS.peerLinksLink]: "peerLinks", [WS_METHODS.peerLinksUnlink]: "peerLinks", [WS_METHODS.peerLinksAnswerRequest]: "peerLinks", + [WS_METHODS.serverGetSettings]: "server", [WS_METHODS.serverUpdateSettings]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", [WS_METHODS.serverPrepareAcpRegistryAgent]: "server", diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2be9e61c7c916..07365e3088e62 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -3,6 +3,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { + AuthOrchestrationReadScope, AuthSessionId, AuthSourceControlWriteScope, EnvironmentId, @@ -1346,6 +1347,97 @@ it.effect("on-demand clients get provider config without workspace snapshots", ( ), ); +it.effect("server.getSettings returns redacted settings to installed native clients", () => + Effect.gen(function* () { + const settings = yield* ServerSettings.ServerSettingsService; + const provider = ProviderInstanceId.make("claudeAgent"); + const favorites = [{ provider, model: "claude-sonnet-4-5" }]; + const providerModelPreferences = { + [provider]: { + hiddenModels: ["claude-opus-4-5"], + modelOrder: ["claude-sonnet-4-5", "claude-haiku-4-5"], + }, + }; + const saved = yield* settings.updateSettings({ + defaultModelSelection: { + instanceId: provider, + model: "claude-haiku-4-5", + }, + favorites, + providerModelPreferences, + sourceControlHosts: { gitcafe: { token: "gitcafe-secret" } }, + }); + const exit = yield* Deferred.make>(); + const server = yield* RpcServer.makeNoSerialization(WsRpcGroup, { + ...WS_RPC_SERVER_OPTIONS, + onFromServer: (response) => + response._tag === "Exit" ? Deferred.succeed(exit, response.exit) : Effect.void, + }).pipe( + Effect.provide( + layerWsRpc( + { + sessionId: AuthSessionId.make("settings-test"), + subject: "settings-test", + method: "browser-session-cookie", + scopes: [], + }, + {}, + yield* ServerBrowser.ServerBrowser, + () => Effect.void, + { secretReferences: false }, + ).pipe( + Layer.provide( + Layer.mergeAll( + layerUnusedConfigRpcServices, + Layer.mock(ExternalLauncher.ExternalLauncher)({}), + Layer.mock(ProviderRegistry.ProviderRegistry)({}), + Layer.mock(UsageLimitSources.UsageLimitSources)({}), + Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({}), + Layer.mock(DirectEndpoints.DirectEndpoints)({}), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + scratchRoot: Effect.succeedNone, + namedProjectsRoot: process.cwd(), + }), + Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({}), + Layer.mock(ServerEnvironment.ServerEnvironment)({}), + Layer.mock(EnvironmentAuth.EnvironmentAuth)({}), + ), + ), + ), + ), + ); + // Swift TestFlight builds send this tag on connect and from the composer. + yield* server.write(0, { + _tag: "Request", + id: RpcMessage.RequestId("settings"), + tag: "server.getSettings", + payload: {}, + headers: Headers.empty, + }); + const result = yield* Deferred.await(exit); + if (!Exit.isSuccess(result)) return yield* Effect.die(result.cause); + const value = result.value as typeof saved; + assert.deepEqual( + value, + ServerSettings.redactServerSettingsForClient(saved, { secretReferences: false }), + ); + assert.deepEqual(value.defaultModelSelection, saved.defaultModelSelection); + assert.deepEqual(value.favorites, favorites); + assert.deepEqual(value.providerModelPreferences, providerModelPreferences); + assert.notInclude(JSON.stringify(value), "gitcafe-secret"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.fresh(layerConfigSources), + Layer.mock(PreviewAutomationBroker.PreviewAutomationBroker)({}), + Layer.mock(ServerBrowser.ServerBrowser)({}), + RpcAuthorization.layer([AuthOrchestrationReadScope]), + ), + ), + Effect.scoped, + ), +); + it.effect("a publish whose push fails still refreshes the new remote", () => Effect.gen(function* () { const refreshes = yield* Queue.unbounded(); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 035f8b1688d44..98baf2cf3edb3 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2681,6 +2681,12 @@ export const layerWsRpc = ( Effect.annotateCurrentSpan({ "orchestration_v2.thread_id": input.threadId }).pipe( Effect.andThen(peerLinkRequests.answer(input)), ), + [WS_METHODS.serverGetSettings]: (_input) => + serverSettings.getSettings.pipe( + Effect.map((settings) => + ServerSettings.redactServerSettingsForClient(settings, settingsClient), + ), + ), [WS_METHODS.serverUpdateSettings]: /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ({ patch, providerInstanceMutation }) { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6465558d5fe03..4713690247497 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -513,6 +513,7 @@ export const WS_METHODS = { peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", peerLinksAnswerRequest: "peerLinks.answerRequest", + serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", serverSearchAcpRegistry: "server.searchAcpRegistry", @@ -823,6 +824,13 @@ const WsPeerLinksAnswerRequestRpc = Rpc.make(WS_METHODS.peerLinksAnswerRequest, error: Schema.Union([PeerLinkRequestError, EnvironmentAuthorizationError]), }); +// No in-tree caller; installed Swift TestFlight builds still request it. +const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { + payload: Schema.Struct({}), + success: ServerSettings, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); + const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch, @@ -2028,6 +2036,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRunStorageCleanupRpc, WsServerGetStorageCleanupReportRpc, WsServerGetDatabaseStorageRpc, + WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsPeerLinksListRpc, WsPeerLinksLinkRpc,