Skip to content
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,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",
Expand Down
94 changes: 94 additions & 0 deletions apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ 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,
EnvironmentId,
ProjectId,
ThreadId,
ProviderDriverKind,
ProviderInstanceId,
WS_METHODS,
WsRpcGroup,
WsServerRefreshProvidersRpc,
WsSubscribeServerConfigRpc,
type OrchestrationV2StoredEvent,
Expand All @@ -35,6 +37,7 @@ import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc";
import * as SqlClient from "effect/sql/SqlClient";
import * as Statement from "effect/sql/Statement";

import * as RpcAuthorization from "./auth/RpcAuthorization.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
import * as ServerBrowser from "./preview/ServerBrowser.ts";
import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator";
Expand Down Expand Up @@ -1165,6 +1168,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<Exit.Exit<unknown, unknown>>();
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,
),
);

// Pruned history cannot be replayed: a cursor below the boundary must resync from a snapshot.
it.effect.each(["thread", "shell"] as const)(
"%s resume from a cursor below the pruned boundary loads a snapshot",
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2626,6 +2626,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 }) {
Expand Down
9 changes: 9 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,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",
Expand Down Expand Up @@ -820,6 +821,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,
Expand Down Expand Up @@ -2016,6 +2024,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerRunStorageCleanupRpc,
WsServerGetStorageCleanupReportRpc,
WsServerGetDatabaseStorageRpc,
WsServerGetSettingsRpc,
WsServerUpdateSettingsRpc,
WsPeerLinksListRpc,
WsPeerLinksLinkRpc,
Expand Down
Loading