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
28 changes: 26 additions & 2 deletions apps/server/src/provider/ProviderInstanceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ResolvedProviderInstanceEnvironment,
ProviderInstanceId,
SecretProviderId,
SettingsSecretValue,
type SecretReference,
} from "@t3tools/contracts";
import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
Expand Down Expand Up @@ -668,11 +669,13 @@ describe("ProviderInstanceRegistry secret environment", () => {
function setup(
initial: Record<string, string>,
configSchema: AnyProviderDriver["configSchema"] = Schema.Unknown,
config: unknown = {},
) {
const vault = new Map(Object.entries(initial));
const reads: Array<ReadonlyArray<string>> = [];
const invalidated: Array<ReadonlyArray<string>> = [];
const created: Array<ResolvedProviderInstanceEnvironment> = [];
const configs: Array<unknown> = [];
const driver: AnyProviderDriver = {
driverKind,
metadata: { displayName: "Fake" },
Expand All @@ -681,6 +684,7 @@ describe("ProviderInstanceRegistry secret environment", () => {
create: (input) =>
Effect.sync(() => {
created.push(input.environment);
configs.push(input.config);
return { instanceId: input.instanceId, enabled: input.enabled } as ProviderInstance;
}),
};
Expand All @@ -703,7 +707,7 @@ describe("ProviderInstanceRegistry secret environment", () => {
const configMap: ProviderInstanceConfigMap = {
[instanceId]: {
driver: driverKind,
config: {},
config,
environment: [
{ name: "OPENAI_API_KEY", value: secret, sensitive: true },
{ name: "BASE_URL", value: "https://example.test", sensitive: false },
Expand All @@ -721,7 +725,7 @@ describe("ProviderInstanceRegistry secret environment", () => {
yield* PubSub.take(changes);
return built;
});
return { vault, reads, invalidated, created, make, makeGated, gate };
return { vault, reads, invalidated, created, configs, make, makeGated, gate };
}

it.effect("starts an instance with the secret its environment points at", () => {
Expand Down Expand Up @@ -787,6 +791,26 @@ describe("ProviderInstanceRegistry secret environment", () => {
}).pipe(Effect.scoped);
});

it.effect("reads a config field typed SettingsSecretValue and rebuilds when it rotates", () => {
const apiKey: SecretReference = { ...secret, reference: "op://Private/Gateway/key" };
const fixture = setup(
{ [secret.reference]: "sk-live", [apiKey.reference]: "gw-live" },
Schema.Struct({ apiKey: SettingsSecretValue, model: Schema.String }),
{ apiKey, model: "m" },
);
return Effect.gen(function* () {
const { mutator } = yield* fixture.make;
expect(fixture.reads.at(-1)).toEqual([secret.reference, apiKey.reference]);

fixture.vault.set(apiKey.reference, "gw-rotated");
yield* mutator.reloadSecrets();
expect(fixture.configs).toEqual([
{ apiKey: "gw-live", model: "m" },
{ apiKey: "gw-rotated", model: "m" },
]);
}).pipe(Effect.scoped);
});

it.effect("keeps a working instance live when a reload cannot read its secret", () => {
const fixture = setup({ [secret.reference]: "sk-live" });
return Effect.gen(function* () {
Expand Down
112 changes: 76 additions & 36 deletions apps/server/src/provider/ProviderInstanceRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ProviderDriverKind,
type ResolvedProviderInstanceEnvironment,
type SecretReference,
secretReferencesInSettings,
type ServerProvider,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -42,11 +43,16 @@ export class ProviderInstanceRegistry extends Context.Service<
}
>()("t3/provider/ProviderInstanceRegistry") {}

interface InstanceSecrets {
readonly environment: ResolvedProviderInstanceEnvironment;
readonly config: Readonly<Record<string, string>>;
}

interface LiveEntry {
readonly instance: ProviderInstance;
readonly scope: Scope.Closeable;
readonly entry: ProviderInstanceConfig;
readonly environment: ResolvedProviderInstanceEnvironment;
readonly secrets: InstanceSecrets;
}

interface RegistryState {
Expand All @@ -61,8 +67,18 @@ const entryEqual = (a: ProviderInstanceConfig, b: ProviderInstanceConfig): boole
const secretReferencesIn = (environment: ProviderInstanceEnvironment): Array<SecretReference> =>
environment.flatMap((variable) => (typeof variable.value === "string" ? [] : [variable.value]));

const secretReferencesOf = (entry: ProviderInstanceConfig): ReadonlyArray<SecretReference> =>
secretReferencesIn(entry.environment ?? []);
const configSecretReferences = <R>(
driver: AnyProviderDriver<R> | undefined,
entry: ProviderInstanceConfig,
) => (driver === undefined ? [] : secretReferencesInSettings(driver.configSchema, entry.config));

const secretReferencesOf = <R>(
driversById: ReadonlyMap<ProviderDriverKind, AnyProviderDriver<R>>,
entry: ProviderInstanceConfig,
): ReadonlyArray<SecretReference> => [
...secretReferencesIn(entry.environment ?? []),
...configSecretReferences(driversById.get(entry.driver), entry).map(({ reference }) => reference),
];

const withoutSecretReferences = (
environment: ProviderInstanceEnvironment,
Expand All @@ -71,25 +87,38 @@ const withoutSecretReferences = (
typeof variable.value === "string" ? [{ ...variable, value: variable.value }] : [],
);

const readSecretEnvironment = Effect.fn("ProviderInstanceRegistry.readSecretEnvironment")(
function* (environment: ProviderInstanceEnvironment) {
const references = secretReferencesIn(environment);
const values =
references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references);
const unreadable: Array<string> = [];
let index = 0;
const resolved: ResolvedProviderInstanceEnvironment = environment.flatMap((variable) => {
if (typeof variable.value === "string") return [{ ...variable, value: variable.value }];
const secret = values[index++];
if (secret === undefined) {
unreadable.push(variable.name);
return [];
}
return [{ ...variable, value: secret, sensitive: true }];
});
return { environment: resolved, unreadable };
},
);
const readInstanceSecrets = Effect.fn("ProviderInstanceRegistry.readInstanceSecrets")(function* <R>(
driver: AnyProviderDriver<R> | undefined,
entry: ProviderInstanceConfig,
) {
const variables = entry.environment ?? [];
const configFields = configSecretReferences(driver, entry);
const references = [
...secretReferencesIn(variables),
...configFields.map(({ reference }) => reference),
];
const values =
references.length === 0 ? [] : yield* (yield* SecretResolver.SecretResolver).read(references);
const unreadable: Array<string> = [];
let index = 0;
const environment: ResolvedProviderInstanceEnvironment = variables.flatMap((variable) => {
if (typeof variable.value === "string") return [{ ...variable, value: variable.value }];
const secret = values[index++];
if (secret === undefined) {
unreadable.push(variable.name);
return [];
}
return [{ ...variable, value: secret, sensitive: true }];
});
const config: Record<string, string> = {};
for (const { field } of configFields) {
const secret = values[index++];
if (secret === undefined) unreadable.push(field);
else config[field] = secret;
}
const secrets: InstanceSecrets = { environment, config };
return { secrets, unreadable };
});

/** Conflicting saved flags must preserve any explicit disable. */
const resolveEntryEnabled = (entry: ProviderInstanceConfig, typedConfig: unknown): boolean => {
Expand Down Expand Up @@ -164,7 +193,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R

const typedConfig = decodeResult.success;
const enabled = resolveEntryEnabled(entry, typedConfig);
if (deferSecrets && enabled && secretReferencesOf(entry).length > 0) {
if (deferSecrets && enabled && secretReferencesOf(driversById, entry).length > 0) {
return {
kind: "unavailable" as const,
snapshot: yield* buildUnavailableProviderSnapshot({
Expand All @@ -176,9 +205,12 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R
}),
};
}
const { environment, unreadable } = enabled
? yield* readSecretEnvironment(entry.environment ?? [])
: { environment: withoutSecretReferences(entry.environment ?? []), unreadable: [] };
const { secrets, unreadable } = enabled
? yield* readInstanceSecrets(driver, entry)
: {
secrets: { environment: withoutSecretReferences(entry.environment ?? []), config: {} },
unreadable: [],
};
// Starting with a blank credential would fail later with a less useful error, or fall back to
// whatever the server inherited.
if (unreadable.length > 0) {
Expand All @@ -202,9 +234,12 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R
instanceId,
displayName: entry.displayName,
accentColor: entry.accentColor,
environment,
environment: secrets.environment,
enabled,
config: typedConfig,
config:
Object.keys(secrets.config).length === 0
? typedConfig
: { ...typedConfig, ...secrets.config },
})
.pipe(Effect.provideService(Scope.Scope, childScope), Effect.result);
if (createResult._tag === "Failure") {
Expand Down Expand Up @@ -232,7 +267,7 @@ const buildEntry = Effect.fn("ProviderInstanceRegistry.buildEntry")(function* <R
instance: createResult.success,
scope: childScope,
entry,
environment,
secrets,
},
};
});
Expand Down Expand Up @@ -286,7 +321,7 @@ const makeReconcile = <R>(input: {
const kept = previousEntries.has(instanceId) && !replacedIds.has(instanceId);
return kept || !(yield* isEntryEnabled(driversById, entry))
? []
: secretReferencesOf(entry);
: secretReferencesOf(driversById, entry);
}),
).pipe(Effect.map((perEntry) => perEntry.flat()));
if (references.length > 0 && !deferSecrets) {
Expand Down Expand Up @@ -408,23 +443,26 @@ export const makeProviderInstanceRegistry = Effect.fn(
const configMap = yield* Ref.get(configMapRef);
const targets = yield* Effect.filter(Object.entries(configMap), ([rawInstanceId, entry]) =>
(instanceId === undefined || rawInstanceId === instanceId) &&
secretReferencesOf(entry).length > 0
secretReferencesOf(driversById, entry).length > 0
? isEntryEnabled(driversById, entry)
: Effect.succeed(false),
);
if (targets.length === 0) return;
const resolver = yield* SecretResolver.SecretResolver;
const references = targets.flatMap(([, entry]) => secretReferencesOf(entry));
const references = targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry));
yield* resolver.invalidate(references);
yield* resolver.read(references);
const live = yield* Ref.get(entries);
const rebuild = new Set<ProviderInstanceId>();
for (const [rawInstanceId, entry] of targets) {
const current = live.get(ProviderInstanceId.make(rawInstanceId));
if (current === undefined) continue;
const { environment, unreadable } = yield* readSecretEnvironment(entry.environment ?? []);
const { secrets, unreadable } = yield* readInstanceSecrets(
driversById.get(entry.driver),
entry,
);
// A failed re-read keeps the working instance and the value it already holds.
if (unreadable.length === 0 && !Equal.equals(environment, current.environment)) {
if (unreadable.length === 0 && !Equal.equals(secrets, current.secrets)) {
rebuild.add(current.instance.instanceId);
}
}
Expand All @@ -442,10 +480,12 @@ export const makeProviderInstanceRegistry = Effect.fn(
),
);
const startupReferences = yield* Effect.filter(Object.entries(input.configMap), ([, entry]) =>
secretReferencesOf(entry).length > 0
secretReferencesOf(driversById, entry).length > 0
? isEntryEnabled(driversById, entry)
: Effect.succeed(false),
).pipe(Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(entry))));
).pipe(
Effect.map((targets) => targets.flatMap(([, entry]) => secretReferencesOf(driversById, entry))),
);
if (startupReferences.length > 0) {
// The read waits outside the lock so other mutations are not held for a vault prompt; a
// configuration saved meanwhile has already reconciled itself.
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/secrets/settingsSecretValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { secretReferencesInSettings } from "@t3tools/contracts";
import type * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver";
import * as Effect from "effect/Effect";
import type * as Schema from "effect/Schema";

/**
* Replaces each reference in the fields `schema` declares as `SettingsSecretValue` with the secret
* it names, read in one batch. An unreadable secret comes back as `""`, so it behaves as unset.
*/
export const resolveSettingsSecrets = Effect.fn("SettingsSecrets.resolve")(function* <
Settings extends Readonly<Record<string, unknown>>,
>(resolver: SecretResolver.SecretResolver["Service"], schema: Schema.Top, settings: Settings) {
const pending = secretReferencesInSettings(schema, settings);
if (pending.length === 0) return settings;
const values = yield* resolver.read(pending.map(({ reference }) => reference));
const resolved = Object.fromEntries(
pending.map(({ field }, index) => [field, values[index] ?? ""] as const),
);
return { ...settings, ...resolved };
});
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts"
import * as SourceControlSignIn from "./sourceControl/SourceControlSignIn.ts";
import { BUILT_IN_SOURCE_CONTROL_DRIVER_LIST } from "./sourceControl/sourceControlDefinitions.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
import * as SecretResolver from "./secrets/SecretResolver.ts";
import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
import * as ProjectSettingsOverrideSweep from "./provider/ProjectSettingsOverrideSweep.ts";
import * as TerminalManager from "./terminal/Manager.ts";
Expand Down Expand Up @@ -628,6 +629,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
Layer.provideMerge(layerOrchestrationApplication),
Layer.provideMerge(RuntimeLayer.layerEventInfrastructure),
Layer.provideMerge(Layer.merge(ProjectStore.layer, ThreadSearch.layer)),
Layer.provideMerge(SecretResolver.layer),
Layer.provideMerge(layerServerSettings),
Layer.provideMerge(layerSourceControlProviderRegistry),
Layer.provideMerge(layerGit),
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1533,6 +1533,25 @@ it.layer(NodeServices.layer)("server settings", (it) => {
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 reference = {
provider: SecretProviderId.make("personal"),
reference: "op://Private/Bitbucket/token",
};
yield* serverSettings.updateSettings({
sourceControlHosts: { bitbucket: { accessToken: "bb-access" } },
});
assert.isTrue(Option.isSome(yield* stored("bitbucket", "accessToken", null)));
const referenced = yield* serverSettings.updateSettings({
sourceControlHosts: { bitbucket: { accessToken: reference } },
});
assert.deepEqual(referenced.sourceControlHosts.bitbucket?.accessToken, reference);
assert.isTrue(Option.isNone(yield* stored("bitbucket", "accessToken", null)));
assert.deepEqual(
ServerSettingsModule.redactServerSettingsForClient(referenced).sourceControlHosts
.bitbucket?.accessToken,
reference,
);
}).pipe(Effect.provide(layerServerSettingsWithSecrets())),
);

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/settingsSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ServerSettings,
SimlockDeviceHostConfig,
UsageLimitSourceConfig,
isSecretReference,
secretSettingsFields,
} from "@t3tools/contracts";
import { acpRegistryClient } from "@t3tools/provider-acp-registry/client";
Expand Down Expand Up @@ -60,7 +61,7 @@ export function mapSettingsSecrets(
) => {
const next = { ...fields };
for (const field of secretFields) {
if (!(field in next)) continue;
if (!(field in next) || isSecretReference(next[field])) continue;
next[field] = writeSecretFieldValues(
next[field],
readSecretFieldValues(next[field]).map(([serverHost, value]) => {
Expand Down
Loading
Loading