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
105 changes: 94 additions & 11 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,15 @@ import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as SchemaAST from "effect/SchemaAST";
import * as SqlClient from "effect/sql/SqlClient";
import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import { sourceControlHostSecretName } from "./sourceControl/sourceControlHostSecrets.ts";
import * as ServerConfig from "./config.ts";
import * as SqlitePersistence from "./persistence/Sqlite.ts";
import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import * as ServerSettingsModule from "./serverSettings.ts";
import { SECRET_REDACTED } from "./settingsSecrets.ts";
import * as ManagedPolicy from "./policy/ManagedPolicy.ts";
import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts";

Expand Down Expand Up @@ -102,6 +104,36 @@ const recordProviderUsage = (provider: string, instanceId: string | null = provi
`;
});

/** Paths of fields typed `SettingsSecretValue` that clients decode with the settings schema itself. */
function strictSecretReferencePaths(
ast: SchemaAST.AST,
path = "",
seen = new Set<SchemaAST.AST>(),
): ReadonlyArray<string> {
if (seen.has(ast)) return [];
seen.add(ast);
const walk = (type: SchemaAST.AST, at: string): ReadonlyArray<string> =>
type.context?.annotations?.settingsSecretReference === true
? [at]
: strictSecretReferencePaths(type, `${at}.`, seen);
if (SchemaAST.isObjects(ast)) {
return [
...ast.propertySignatures.flatMap((property) =>
walk(property.type, `${path}${String(property.name)}`),
),
...ast.indexSignatures.flatMap((signature) => walk(signature.type, `${path}*`)),
];
}
if (SchemaAST.isArrays(ast)) {
return [...ast.elements, ...ast.rest].flatMap((element) => walk(element, `${path}[]`));
}
if (SchemaAST.isUnion(ast)) {
return ast.types.flatMap((member) => strictSecretReferencePaths(member, path, seen));
}
if (SchemaAST.isSuspend(ast)) return strictSecretReferencePaths(ast.thunk(), path, seen);
return [];
}

it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings", (it) => {
it.effect("serializes concurrent model preference edits without cancelling duplicate adds", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -278,7 +310,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
},
});
assert.deepStrictEqual<unknown>(
ServerSettingsModule.redactServerSettingsForClient(settings).secretProviders,
ServerSettingsModule.redactServerSettingsForClient(settings, { secretReferences: true })
.secretProviders,
{
personal: { kind: "one_password", label: "Personal", config: { account: "my" } },
vault: { kind: "openbao", label: "Vault" },
Expand Down Expand Up @@ -1331,7 +1364,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"quota-secret",
"expo-secret",
];
const client = ServerSettingsModule.redactServerSettingsForClient(saved);
const client = ServerSettingsModule.redactServerSettingsForClient(saved, {
secretReferences: true,
});
const disk = yield* fs.readFileString(config.settingsPath);
for (const value of values) {
assert.include(JSON.stringify(saved), value);
Expand Down Expand Up @@ -1365,7 +1400,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
instance: { ...client.providerInstances[instanceId]!, config: { apiKey } },
});
assert.propertyVal(changed.providerInstances[instanceId]?.config, "apiKey", apiKey);
const redacted = ServerSettingsModule.redactServerSettingsForClient(changed);
const redacted = ServerSettingsModule.redactServerSettingsForClient(changed, {
secretReferences: true,
});
assert.propertyVal(
redacted.providerInstances[instanceId]?.config,
"apiKey",
Expand Down Expand Up @@ -1414,9 +1451,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
});
assert.isTrue(Option.isNone(yield* store.get(secretNames[1]!)));
assert.deepEqual(
ServerSettingsModule.redactServerSettingsForClient(referenced).providerInstances[
instanceId
]?.environment,
ServerSettingsModule.redactServerSettingsForClient(referenced, { secretReferences: true })
.providerInstances[instanceId]?.environment,
[{ name: "API_TOKEN", value: secret, sensitive: true }],
);
yield* service.updateSettings({
Expand Down Expand Up @@ -1463,7 +1499,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
"serverPassword",
"legacy-password",
);
const client = ServerSettingsModule.redactServerSettingsForClient(saved);
const client = ServerSettingsModule.redactServerSettingsForClient(saved, {
secretReferences: true,
});
for (const value of ["legacy-api-key", "legacy-password"]) {
assert.notInclude(yield* fs.readFileString(config.settingsPath), value);
assert.notInclude(JSON.stringify(client), value);
Expand Down Expand Up @@ -1507,8 +1545,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
for (const secret of ["bb-access", "ghp_dotcom", "ghp_ghe"]) assert.notInclude(raw, secret);
assert.include(raw, "me@example.com");

const forClient =
ServerSettingsModule.redactServerSettingsForClient(saved).sourceControlHosts;
const forClient = ServerSettingsModule.redactServerSettingsForClient(saved, {
secretReferences: true,
}).sourceControlHosts;
assert.equal(forClient.bitbucket?.email, "me@example.com");
assert.notInclude(String(forClient.bitbucket?.accessToken), "bb-access");
assert.isAbove(String(forClient.bitbucket?.accessToken).length, 0);
Expand Down Expand Up @@ -1570,15 +1609,59 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null)));
assert.isTrue(Option.isNone(yield* stored("github", "tokens", "github.com")));
assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test")));
const redacted =
ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts;
const redacted = ServerSettingsModule.redactServerSettingsForClient(referenced, {
secretReferences: true,
}).sourceControlHosts;
assert.deepEqual(redacted.bitbucket?.accessToken, reference);
const tokens = redacted.github?.tokens as Record<string, unknown>;
assert.deepEqual(tokens["github.com"], gitHubReference);
assert.notEqual(tokens["ghe.acme.test"], "ghp_ghe");

// A client that cannot decode references sees saved secrets and can send them back.
const usageReference = {
provider: SecretProviderId.make("personal"),
reference: "op://Private/Hub/key",
};
const hub = UsageLimitSourceId.make("hub");
const withUsage = yield* serverSettings.updateSettings({
usageLimitSources: {
[hub]: {
kind: "cliproxy",
url: "https://hub.test",
managementKey: usageReference,
enabled: true,
},
},
});
const legacy = ServerSettingsModule.redactServerSettingsForClient(withUsage, {
secretReferences: false,
});
assert.notInclude(JSON.stringify(legacy), "op://");
assert.equal(legacy.sourceControlHosts.bitbucket?.accessToken, SECRET_REDACTED);
assert.equal(legacy.usageLimitSources[hub]?.managementKey, SECRET_REDACTED);
const echoed = yield* serverSettings.updateSettings({
sourceControlHosts: legacy.sourceControlHosts,
usageLimitSources: legacy.usageLimitSources,
});
assert.deepEqual(echoed.sourceControlHosts.bitbucket?.accessToken, reference);
assert.deepEqual(echoed.sourceControlHosts.github?.tokens, {
"github.com": gitHubReference,
"ghe.acme.test": "ghp_ghe",
});
assert.deepEqual(echoed.usageLimitSources[hub]?.managementKey, usageReference);
assert.isTrue(Option.isSome(yield* stored("github", "tokens", "ghe.acme.test")));
}).pipe(Effect.provide(layerServerSettingsWithSecrets())),
);

it.effect("knows every strictly decoded field that can hold a secret reference", () =>
Effect.sync(() => {
// Shipped clients decode these as strings. Each must be covered by the older-client case above.
assert.deepEqual(strictSecretReferencePaths(ServerSettings.ast), [
"usageLimitSources.*.managementKey",
]);
}),
);

it.effect("keeps the retired keys for another load when a token cannot be copied", () => {
const cause = new ServerSecretStore.SecretStoreReadError({
resource: "bitbucket-access-token",
Expand Down
37 changes: 33 additions & 4 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type ServerSettingsPatch,
type ManagedPolicySummary,
globalIdleCompaction,
isSecretReference,
type SecretReference,
withIdleCompaction,
} from "@t3tools/contracts";
import * as Cache from "effect/Cache";
Expand Down Expand Up @@ -60,7 +62,12 @@ import {
sourceControlHostSecretName,
} from "./sourceControl/sourceControlHostSecrets.ts";

import { mapSettingsSecrets, SECRET_REDACTED, type SettingsSecret } from "./settingsSecrets.ts";
import {
mapSettingsSecrets,
mapSettingsSecretValues,
SECRET_REDACTED,
type SettingsSecret,
} from "./settingsSecrets.ts";

export {
resolveReviewGuideModelSelection,
Expand Down Expand Up @@ -131,13 +138,35 @@ const normalizeServerSettings = (
),
);

export function redactServerSettingsForClient(settings: ServerSettings): ServerSettings {
export function redactServerSettingsForClient(
settings: ServerSettings,
client: { readonly secretReferences: boolean },
): ServerSettings {
const redacted = mapSettingsSecrets(settings, (secret) =>
secret.sensitive
? { value: "", redacted: secret.redacted || secret.value.length > 0 }
: { value: secret.value, redacted: false },
);
return { ...redacted, secretProviders: withoutUnknownSecretProviderConfigs(redacted) };
// Older clients decode secret fields as strings and fail on a reference, so they see a saved secret.
const shaped = client.secretReferences
? redacted
: mapSettingsSecretValues(redacted, (value) =>
isSecretReference(value) ? SECRET_REDACTED : value,
);
return { ...shaped, secretProviders: withoutUnknownSecretProviderConfigs(shaped) };
}

/** A client shown a reference as a saved secret sends that marker back; it keeps the reference. */
function withEchoedSecretReferences(current: ServerSettings, next: ServerSettings): ServerSettings {
const references = new Map<string, SecretReference>();
mapSettingsSecretValues(current, (value, secretName) => {
if (isSecretReference(value)) references.set(secretName, value);
return value;
});
if (references.size === 0) return next;
return mapSettingsSecretValues(next, (value, secretName) =>
value === SECRET_REDACTED ? (references.get(secretName) ?? value) : value,
);
}

// A newer kind's config may hold credentials this build cannot recognize, so it never leaves the server.
Expand Down Expand Up @@ -1004,7 +1033,7 @@ const make = Effect.gen(function* () {
});
const changes: SecretChange[] = [];
const nextSecretNames = new Set<string>();
const settings = mapSettingsSecrets(next, (secret) => {
const settings = mapSettingsSecrets(withEchoedSecretReferences(current, next), (secret) => {
const metadata = {
secretName: secret.secretName,
...(secret.providerInstanceId !== undefined
Expand Down
102 changes: 75 additions & 27 deletions apps/server/src/settingsSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
ClaudeSettings,
CodexSettings,
EasDeviceHostConfig,
type ProviderInstanceConfig,
type ServerSettings,
type SettingsSecretValue,
SimlockDeviceHostConfig,
UsageLimitSourceConfig,
isSecretReference,
Expand Down Expand Up @@ -58,16 +60,19 @@ export interface SettingsSecret {
}

type SecretValue = Pick<SettingsSecret, "value" | "redacted">;
type SecretSlotName = (field: string, serverHost: string | null) => string;
type SecretFieldsMapper = (
fields: Readonly<Record<string, unknown>>,
secretFields: ReadonlyArray<string>,
name: SecretSlotName,
) => Record<string, unknown>;
type ProviderEnvironment = NonNullable<ProviderInstanceConfig["environment"]>;

export function mapSettingsSecrets(
settings: ServerSettings,
map: (secret: SettingsSecret) => SecretValue,
): ServerSettings {
const mapFields = (
fields: Readonly<Record<string, unknown>>,
secretFields: ReadonlyArray<string>,
name: (field: string, serverHost: string | null) => string,
) => {
const mapFields: SecretFieldsMapper = (fields, secretFields, name) => {
const next = { ...fields };
for (const field of secretFields) {
if (!(field in next) || isSecretReference(next[field])) continue;
Expand All @@ -88,6 +93,70 @@ export function mapSettingsSecrets(
return next;
};

return mapSecretFieldGroups(settings, mapFields, (instanceId, environment) =>
environment.map((variable) => {
const { valueRedacted: _omit, ...rest } = variable;
// A secret reference is not itself secret; storage sees the value cleared.
const reference = typeof variable.value === "string" ? undefined : variable.value;
const result = map({
secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`,
kind: "environment",
value: typeof variable.value === "string" ? variable.value : "",
redacted: reference === undefined && variable.valueRedacted === true,
sensitive: variable.sensitive,
providerInstanceId: instanceId,
environmentVariable: variable.name,
});
if (reference !== undefined) return { ...rest, value: reference };
return {
...rest,
value: result.value,
...(result.redacted ? { valueRedacted: true } : {}),
};
}),
);
}

/**
* Maps each value a secret field holds, literal or reference, with the secret name of its slot.
* Provider environment variables are left as they are.
*/
export function mapSettingsSecretValues(
settings: ServerSettings,
map: (value: SettingsSecretValue, secretName: string) => SettingsSecretValue,
): ServerSettings {
const mapValue = (value: unknown, secretName: string) =>
typeof value === "string" || isSecretReference(value) ? map(value, secretName) : value;
return mapSecretFieldGroups(settings, (fields, secretFields, name) => {
const next = { ...fields };
for (const field of secretFields) {
if (!(field in next)) continue;
const value = next[field];
next[field] =
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
!isSecretReference(value)
? Object.fromEntries(
Object.entries(value).map(([serverHost, entry]) => [
serverHost,
mapValue(entry, name(field, serverHost.trim().toLowerCase())),
]),
)
: mapValue(value, name(field, null));
}
return next;
});
}

function mapSecretFieldGroups(
settings: ServerSettings,
mapFields: SecretFieldsMapper,
mapEnvironment: (instanceId: string, environment: ProviderEnvironment) => ProviderEnvironment = (
_instanceId,
environment,
) => environment,
): ServerSettings {
const providerInstances = Object.fromEntries(
Object.entries(settings.providerInstances).map(([instanceId, instance]) => {
const config = instance.config;
Expand All @@ -107,28 +176,7 @@ export function mapSettingsSecrets(
}
: {}),
...(instance.environment
? {
environment: instance.environment.map((variable) => {
const { valueRedacted: _omit, ...rest } = variable;
// A secret reference is not itself secret; storage sees the value cleared.
const reference = typeof variable.value === "string" ? undefined : variable.value;
const result = map({
secretName: `provider-env-${encode(instanceId)}-${encode(variable.name)}`,
kind: "environment",
value: typeof variable.value === "string" ? variable.value : "",
redacted: reference === undefined && variable.valueRedacted === true,
sensitive: variable.sensitive,
providerInstanceId: instanceId,
environmentVariable: variable.name,
});
if (reference !== undefined) return { ...rest, value: reference };
return {
...rest,
value: result.value,
...(result.redacted ? { valueRedacted: true } : {}),
};
}),
}
? { environment: mapEnvironment(instanceId, instance.environment) }
: {}),
},
];
Expand Down
Loading
Loading