diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 180f053f111f4..305a8e004af68 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -90,6 +90,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 a8620e9e7ae7d..1998328d7c26d 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -68,6 +68,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 7f52d5963a07d..d5adf9a24ab95 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -1743,6 +1743,97 @@ it.effect("a git action that fails partway still refreshes the repository status ), ); +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 5e81541ff3530..d5f2d198eaf24 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2648,6 +2648,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 01e064722bc80..94dbbe00fbfcb 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -523,6 +523,7 @@ export const WS_METHODS = { peerLinksLink: "peerLinks.link", peerLinksUnlink: "peerLinks.unlink", peerLinksAnswerRequest: "peerLinks.answerRequest", + serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", serverCheckSecretSource: "server.checkSecretSource", @@ -863,6 +864,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, @@ -2120,6 +2128,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRunStorageCleanupRpc, WsServerGetStorageCleanupReportRpc, WsServerGetDatabaseStorageRpc, + WsServerGetSettingsRpc, WsServerReclaimDatabaseSpaceRpc, WsServerUpdateSettingsRpc, WsPeerLinksListRpc,