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 @@ -83,6 +83,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 @@ -60,6 +60,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
77 changes: 77 additions & 0 deletions apps/server/src/ws.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import {
AuthOrchestrationReadScope,
AuthSessionId,
EnvironmentId,
ProjectId,
Expand All @@ -9,6 +10,7 @@ import {
ProviderInstanceId,
WS_METHODS,
WsServerRefreshProvidersRpc,
WsRpcGroup,
WsSubscribeServerConfigRpc,
type OrchestrationV2StoredEvent,
type OrchestrationV2ThreadShell,
Expand All @@ -19,6 +21,7 @@ import {
} from "@t3tools/contracts";
import * as Deferred from "effect/Deferred";
import * as DateTime from "effect/DateTime";
import * as Exit from "effect/Exit";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as PubSub from "effect/PubSub";
Expand All @@ -29,6 +32,7 @@ import { Headers } from "effect/http";
import { RpcGroup, RpcMessage, RpcServer } from "effect/rpc";
import * as SqlClient from "effect/sql/SqlClient";

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 @@ -869,6 +873,79 @@ 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 saved = yield* settings.updateSettings({
defaultModelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-haiku-4-5",
},
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,
).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(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));
assert.deepEqual(value.defaultModelSelection, saved.defaultModelSelection);
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,
),
);

// Compaction keeps the oldest and newest event per message, so a cursor older
// than the deleted rows still replays each message where the client would
// first have seen it (reducers append on first sight) and reaches the same
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2496,6 +2496,8 @@ 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(ServerSettings.redactServerSettingsForClient)),
[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 @@ -495,6 +495,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 @@ -793,6 +794,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 @@ -1941,6 +1949,7 @@ export const WsRpcGroup = RpcGroup.make(
WsServerRunStorageCleanupRpc,
WsServerGetStorageCleanupReportRpc,
WsServerGetDatabaseStorageRpc,
WsServerGetSettingsRpc,
WsServerUpdateSettingsRpc,
WsPeerLinksListRpc,
WsPeerLinksLinkRpc,
Expand Down
Loading