Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ const PtyAdapterLive = Layer.unwrap(
}),
);

const ServerSettingsLayerLive = ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer));
const ServerSettingsLayerLive = ServerSettings.layer.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provideMerge(SqlitePersistenceLayerLive),
);

const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe(
Layer.provide(ResourceMonitorBinary.layer),
Expand Down
86 changes: 86 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,45 @@ import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as ServerConfig from "./config.ts";
import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts";
import * as ServerSettingsModule from "./serverSettings.ts";

const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch);
const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings);

const recordProviderUsage = (
providerName: string,
providerInstanceId: string | null = providerName,
) =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
yield* sql`
INSERT INTO provider_session_runtime (
thread_id,
provider_name,
provider_instance_id,
adapter_key,
status,
last_seen_at
)
VALUES (
${`thread-${providerInstanceId ?? providerName}`},
${providerName},
${providerInstanceId},
${providerName},
${"ready"},
${"2026-08-31T00:00:00.000Z"}
)
`;
});

const makeServerSettingsLayer = () =>
ServerSettingsModule.layer.pipe(
Layer.provide(ServerSecretStore.layer),
Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
Layer.provideMerge(
Layer.fresh(
ServerConfig.layerTest(process.cwd(), {
Expand Down Expand Up @@ -67,6 +96,7 @@ it.layer(NodeServices.layer)("server settings", (it) => {
);
const settingsLayer = ServerSettingsModule.layer.pipe(
Layer.provide(makeFailingSecretStoreLayer(cause)),
Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
Layer.provideMerge(configLayer),
);

Expand Down Expand Up @@ -588,6 +618,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
codex: {
binaryPath: "/opt/homebrew/bin/codex",
},
cursor: {
enabled: false,
},
opencode: {
serverUrl: "http://127.0.0.1:4096",
serverPassword: "secret-password",
Expand All @@ -610,6 +643,9 @@ it.layer(NodeServices.layer)("server settings", (it) => {
codex: {
binaryPath: "/opt/homebrew/bin/codex",
},
cursor: {
enabled: false,
},
opencode: {
serverUrl: "http://127.0.0.1:4096",
serverPassword: "secret-password",
Expand All @@ -628,6 +664,56 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("restores used custom provider instances without enabling legacy providers", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providerInstances":{"cursor_work":{"driver":"cursor","config":{}}}}',
);
yield* recordProviderUsage("cursor", "cursor_work");

const settings = yield* serverSettings.getSettings;

assert.isFalse(settings.providers.cursor.enabled);
assert.isTrue(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("restores legacy providers from default runtime history", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");
yield* recordProviderUsage("cursor");

const settings = yield* serverSettings.getSettings;

assert.isTrue(settings.providers.cursor.enabled);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("keeps explicit provider disables authoritative over runtime history", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"providers":{"cursor":{"enabled":false}},"providerInstances":{"cursor_work":{"driver":"cursor","enabled":false,"config":{}}}}',
);
yield* recordProviderUsage("cursor", "cursor_work");

const settings = yield* serverSettings.getSettings;

assert.isFalse(settings.providers.cursor.enabled);
assert.isFalse(settings.providerInstances[ProviderInstanceId.make("cursor_work")]?.enabled);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("stores sensitive provider instance environment values outside settings.json", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
Expand Down
149 changes: 135 additions & 14 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { writeFileStringAtomically } from "./atomicWrite.ts";
import * as ServerConfig from "./config.ts";
import { type DeepPartial, deepMerge } from "@t3tools/shared/Struct";
Expand Down Expand Up @@ -181,6 +182,79 @@ export const layerTest = (overrides: DeepPartial<ServerSettings> = {}) =>

const ServerSettingsJson = fromLenientJson(ServerSettings);
const decodeServerSettingsJsonExit = Schema.decodeUnknownExit(ServerSettingsJson);
const PersistedOptionalProviderSettings = Schema.Struct({
providers: Schema.optionalKey(
Schema.Struct({
cursor: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
grok: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
opencode: Schema.optionalKey(Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) })),
}),
),
});
const decodePersistedOptionalProviderSettingsJsonExit = Schema.decodeUnknownExit(
fromLenientJson(PersistedOptionalProviderSettings),
);

function restoreUsedProviders(
settings: ServerSettings,
persisted: typeof PersistedOptionalProviderSettings.Type,
providerHistory: ReadonlyArray<{
readonly providerName: string;
readonly providerInstanceId: string | null;
}>,
): ServerSettings {
const usedProviders = new Set(
providerHistory
.filter(
({ providerName, providerInstanceId }) =>
providerInstanceId === null || providerInstanceId === providerName,
)
.map(({ providerName }) => providerName),
);
const usedProviderInstances = new Set(
providerHistory.map(
({ providerName, providerInstanceId }) => providerInstanceId ?? providerName,
),
);
const providerInstances = Object.fromEntries(
Object.entries(settings.providerInstances).map(([instanceId, instance]) => [
instanceId,
instance.enabled === undefined &&
(instance.driver === "cursor" ||
instance.driver === "grok" ||
instance.driver === "opencode") &&
usedProviderInstances.has(instanceId)
? { ...instance, enabled: true }
: instance,
]),
);

return {
...settings,
providers: {
...settings.providers,
cursor: {
...settings.providers.cursor,
enabled:
persisted.providers?.cursor?.enabled ??
(usedProviders.has("cursor") ? true : settings.providers.cursor.enabled),
Comment on lines +239 to +240

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't enable the default provider from custom-instance history

When the history contains only a custom Cursor instance such as cursor_work, usedProviders.has("cursor") still enables the legacy providers.cursor entry. deriveProviderInstanceConfigMap then synthesizes a separate default cursor instance alongside cursor_work, so an upgrade unexpectedly exposes two enabled providers and can even resurrect the default after the custom instance was removed but its thread history remains. Only history whose instance ID is null or equals the provider name should restore the legacy default; custom-instance history should restore that instance alone.

Useful? React with 👍 / 👎.

},
grok: {
...settings.providers.grok,
enabled:
persisted.providers?.grok?.enabled ??
(usedProviders.has("grok") ? true : settings.providers.grok.enabled),
},
opencode: {
...settings.providers.opencode,
enabled:
persisted.providers?.opencode?.enabled ??
(usedProviders.has("opencode") ? true : settings.providers.opencode.enabled),
},
},
providerInstances,
};
}

function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings {
return isModelSelectionProviderEnabled(settings, settings.textGenerationModelSelection)
Expand Down Expand Up @@ -216,6 +290,16 @@ const ATOMIC_SETTINGS_KEYS: ReadonlySet<string> = new Set([
"textGenerationModelSelection",
]);

// Optional providers are enabled by history on load, so their persisted
// defaults must remain distinguishable from an explicit user choice.
const PERSISTED_SERVER_SETTINGS_DEFAULTS = {
...DEFAULT_SERVER_SETTINGS,
providers: {
...DEFAULT_SERVER_SETTINGS.providers,
cursor: { ...DEFAULT_SERVER_SETTINGS.providers.cursor, enabled: undefined },
},
};

function stripDefaultServerSettings(current: unknown, defaults: unknown): unknown | undefined {
if (Array.isArray(current) || Array.isArray(defaults)) {
return Equal.equals(current, defaults) ? undefined : current;
Expand Down Expand Up @@ -255,6 +339,7 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const secretStore = yield* ServerSecretStore.ServerSecretStore;
const sql = yield* SqlClient.SqlClient;
const writeSemaphore = yield* Semaphore.make(1);
const cacheKey = "settings" as const;
const changesPubSub = yield* PubSub.unbounded<ServerSettings>();
Expand Down Expand Up @@ -289,21 +374,57 @@ const make = Effect.gen(function* () {
);

const loadSettingsFromDisk = Effect.gen(function* () {
if (!(yield* readConfigExists)) {
return DEFAULT_SERVER_SETTINGS;
let settings = DEFAULT_SERVER_SETTINGS;
let persisted: typeof PersistedOptionalProviderSettings.Type = {};

if (yield* readConfigExists) {
const raw = yield* readRawConfig;
const decoded = decodeServerSettingsJsonExit(raw);
const persistedSettings = decodePersistedOptionalProviderSettingsJsonExit(raw);
if (persistedSettings._tag === "Success") {
persisted = persistedSettings.value;
}
if (decoded._tag === "Failure" || persistedSettings._tag === "Failure") {
const failure = decoded._tag === "Failure" ? decoded : persistedSettings;
if (failure._tag === "Failure") {
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
issues: Cause.pretty(failure.cause),
cause: failure.cause,
});
}
} else {
settings = decoded.value;
}
}

const raw = yield* readRawConfig;
const decoded = decodeServerSettingsJsonExit(raw);
if (decoded._tag === "Failure") {
yield* Effect.logWarning("failed to parse settings.json, using defaults", {
path: settingsPath,
issues: Cause.pretty(decoded.cause),
cause: decoded.cause,
});
return DEFAULT_SERVER_SETTINGS;
}
return decoded.value;
const providerHistory = yield* sql<{
readonly providerName: string;
readonly providerInstanceId: string | null;
}>`
SELECT DISTINCT
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM projection_thread_sessions
WHERE provider_name IN ('cursor', 'grok', 'opencode')
UNION
SELECT DISTINCT
provider_name AS "providerName",
provider_instance_id AS "providerInstanceId"
FROM provider_session_runtime
WHERE provider_name IN ('cursor', 'grok', 'opencode')
`.pipe(
Effect.mapError(
(cause) =>
new ServerSettingsError({
settingsPath,
operation: "read-provider-history",
cause,
}),
),
);

return restoreUsedProviders(settings, persisted, providerHistory);
});

const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
Expand Down Expand Up @@ -479,7 +600,7 @@ const make = Effect.gen(function* () {
const writeSettingsAtomically = Effect.fnUntraced(
function* (settings: ServerSettings) {
const sparseSettingsJson = yield* encodeServerSettingsJson(
stripDefaultServerSettings(settings, DEFAULT_SERVER_SETTINGS) ?? {},
stripDefaultServerSettings(settings, PERSISTED_SERVER_SETTINGS_DEFAULTS) ?? {},
);

return yield* writeFileStringAtomically({
Expand Down
8 changes: 2 additions & 6 deletions packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,7 @@ const makeBinaryPathSetting = (fallback: string) =>
Schema.withDecodingDefault(Effect.succeed(fallback)),
);

export type ProviderSettingsFormControl =
| "text"
| "password"
| "textarea"
| "switch"
| "select";
export type ProviderSettingsFormControl = "text" | "password" | "textarea" | "switch" | "select";

export interface ProviderSettingsFormSelectOption {
readonly value: string;
Expand Down Expand Up @@ -753,6 +748,7 @@ export const ServerSettingsOperation = Schema.Literals([
"normalize",
"check-exists",
"read-file",
"read-provider-history",
"read-secret",
"remove-secret",
"remove-stale-secret",
Expand Down
Loading