diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..b5cabeeeb1dc 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -19,6 +19,7 @@ import { ClaudeSettings, CodexSettings, DEFAULT_SERVER_SETTINGS, + EnvironmentId, ProviderDriverKind, ProviderInstanceId, ServerSettings, @@ -48,6 +49,7 @@ import { ProviderRegistryLive, } from "./ProviderRegistry.ts"; import * as ServerConfig from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, @@ -63,6 +65,16 @@ const decodeServerSettings = Schema.decodeSync(ServerSettings); const encodeServerSettings = Schema.encodeSync(ServerSettings); const encodedDefaultServerSettings = encodeServerSettings(DEFAULT_SERVER_SETTINGS); +const withProviderHost = ( + provider: Provider, +) => ({ + ...provider, + providerHostInstance: { + environmentId: EnvironmentId.make("provider-registry-test"), + providerInstanceId: ProviderInstanceId.make(provider.instanceId), + }, +}); + const defaultClaudeSettings: ClaudeSettings = Schema.decodeSync(ClaudeSettings)({}); const defaultCodexSettings: CodexSettings = Schema.decodeSync(CodexSettings)({}); const decodeCodexSettings = Schema.decodeSync(CodexSettings); @@ -77,11 +89,16 @@ process.env.T3CODE_CURSOR_ENABLED = "1"; const encoder = new TextEncoder(); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); -const TestHttpClientLive = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), +const TestHttpClientLive = Layer.mergeAll( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ version: "0.0.0" }))), + ), ), + Layer.succeed(ServerEnvironment.ServerEnvironmentIdentity, { + getEnvironmentId: Effect.succeed(EnvironmentId.make("provider-registry-test")), + }), ); const BackgroundPolicyAlwaysRunLayer = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ @@ -1422,7 +1439,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ).pipe(Scope.provide(scope)); yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withProviderHost(initialProvider), + ]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); }).pipe(Effect.provide(runtimeServices)); }), @@ -1753,7 +1772,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( recoveredProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withProviderHost(codexProvider), ); yield* Ref.set(catalogSnapshot, changedCatalogProvider); @@ -1765,7 +1784,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); assert.deepStrictEqual( changedProviders.find((provider) => provider.instanceId === codexInstanceId), - codexProvider, + withProviderHost(codexProvider), ); }).pipe(Effect.provide(runtimeServices)); @@ -1880,7 +1899,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const cachedProvider = yield* readProviderStatusCache(filePath); assert.deepStrictEqual(cachedProvider, { - ...refreshedProvider, + ...withProviderHost(refreshedProvider), models: [...initialProvider.models], }); }).pipe(Effect.provide(runtimeServices)); @@ -2094,10 +2113,14 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [cachedProvider]); - assert.deepStrictEqual(yield* registry.refresh(codexDriver), [cachedProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [ + withProviderHost(cachedProvider), + ]); + assert.deepStrictEqual(yield* registry.refresh(codexDriver), [ + withProviderHost(cachedProvider), + ]); assert.deepStrictEqual(yield* registry.refreshInstance(codexInstanceId), [ - cachedProvider, + withProviderHost(cachedProvider), ]); }).pipe(Effect.provide(runtimeServices)); }), @@ -2205,7 +2228,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.gen(function* () { const registry = yield* ProviderRegistry.ProviderRegistry; - assert.deepStrictEqual(yield* registry.getProviders, [codexProvider]); + assert.deepStrictEqual(yield* registry.getProviders, [withProviderHost(codexProvider)]); yield* Ref.set(failNextList, true); yield* PubSub.publish(changes, undefined); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..42e296afe618 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -24,6 +24,8 @@ */ import { defaultInstanceIdForDriver, + type EnvironmentId, + makeProviderHostInstanceIdentity, ProviderDriverKind, type ProviderInstanceId, type ServerProvider, @@ -41,6 +43,7 @@ import * as Stream from "effect/Stream"; import * as Semaphore from "effect/Semaphore"; import { ServerConfig } from "../../config.ts"; +import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; import { @@ -57,12 +60,15 @@ import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; const loadProviders = ( providerSources: ReadonlyArray, + environmentId: EnvironmentId, ): Effect.Effect> => Effect.forEach( providerSources, (providerSource) => providerSource.getSnapshot.pipe( - Effect.flatMap((snapshot) => correlateSnapshotWithSource(providerSource, snapshot)), + Effect.flatMap((snapshot) => + correlateSnapshotWithSource(providerSource, snapshot, environmentId), + ), ), { concurrency: "unbounded", @@ -231,9 +237,22 @@ const haveProvidersChanged = ( nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); +/** Stamp the persisted environment and configured instance at the registry boundary. */ +export const stampProviderHostInstance = ( + provider: ServerProvider, + environmentId: EnvironmentId, +): ServerProvider => ({ + ...provider, + providerHostInstance: makeProviderHostInstanceIdentity({ + environmentId, + providerInstanceId: provider.instanceId, + }), +}); + const correlateSnapshotWithSource = ( source: ProviderSnapshotSource, snapshot: ServerProvider, + environmentId: EnvironmentId, ): Effect.Effect => { if (snapshot.instanceId !== source.instanceId) { return Effect.die( @@ -249,7 +268,10 @@ const correlateSnapshotWithSource = ( ), ); } - return Effect.succeed(snapshot); + // Provider drivers only know their configured instance. The registry owns + // the persisted environment identity and stamps the pair centrally so + // unavailable, cached, and live snapshots use the same host contract. + return Effect.succeed(stampProviderHostInstance(snapshot, environmentId)); }; /** @@ -278,6 +300,9 @@ export const ProviderRegistryLive = Layer.effect( ProviderRegistry, Effect.gen(function* () { const instanceRegistry = yield* ProviderInstanceRegistry; + const environmentId = yield* ServerEnvironment.ServerEnvironmentIdentity.pipe( + Effect.flatMap((identity) => identity.getEnvironmentId), + ); const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -296,7 +321,7 @@ export const ProviderRegistryLive = Layer.effect( // below. const bootInstances = yield* instanceRegistry.listInstances; const bootSources = bootInstances.map(buildSnapshotSource); - const fallbackProviders = yield* loadProviders(bootSources); + const fallbackProviders = yield* loadProviders(bootSources, environmentId); const fallbackByInstance = new Map(); for (let index = 0; index < fallbackProviders.length; index++) { const provider = fallbackProviders[index]; @@ -526,7 +551,7 @@ export const ProviderRegistryLive = Layer.effect( ) { return yield* providerSource.refresh.pipe( Effect.flatMap((nextProvider) => - correlateSnapshotWithSource(providerSource, nextProvider).pipe( + correlateSnapshotWithSource(providerSource, nextProvider, environmentId).pipe( Effect.flatMap(syncProvider), ), ), @@ -669,7 +694,9 @@ export const ProviderRegistryLive = Layer.effect( for (const [, instance] of newlyAdded) { const source = buildSnapshotSource(instance); yield* Stream.runForEach(source.streamChanges, (provider) => - correlateSnapshotWithSource(source, provider).pipe(Effect.flatMap(syncProvider)), + correlateSnapshotWithSource(source, provider, environmentId).pipe( + Effect.flatMap(syncProvider), + ), ).pipe(Effect.forkScoped); } yield* Effect.yieldNow; @@ -684,16 +711,21 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const source = buildSnapshotSource(instance); const provider = yield* source.getSnapshot; - yield* correlateSnapshotWithSource(source, provider).pipe( + yield* correlateSnapshotWithSource(source, provider, environmentId).pipe( Effect.flatMap(syncProvider), ); }).pipe(Effect.ignoreCause({ log: true })), { concurrency: "unbounded", discard: true }, ); - yield* upsertProviders(unavailableProviders, { - persist: false, - replace: true, - }); + yield* upsertProviders( + unavailableProviders.map((provider) => + stampProviderHostInstance(provider, environmentId), + ), + { + persist: false, + replace: true, + }, + ); const nextSubs = new Map(carriedOver); for (const [instanceId, instance] of newlyAdded) { diff --git a/apps/server/src/provider/Layers/ProviderRegistryIdentity.test.ts b/apps/server/src/provider/Layers/ProviderRegistryIdentity.test.ts new file mode 100644 index 000000000000..259825d7eece --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderRegistryIdentity.test.ts @@ -0,0 +1,20 @@ +import { assert, describe, it } from "@effect/vitest"; +import { EnvironmentId, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; + +import { stampProviderHostInstance } from "./ProviderRegistry.ts"; + +describe("provider host identity stamping", () => { + it("uses the persisted environment plus instance and never continuation metadata", () => { + const provider = { + instanceId: ProviderInstanceId.make("codex_work"), + continuation: { groupKey: "codex:home:/shared" }, + } as ServerProvider; + assert.deepStrictEqual(stampProviderHostInstance(provider, EnvironmentId.make("env-a")), { + ...provider, + providerHostInstance: { + environmentId: EnvironmentId.make("env-a"), + providerInstanceId: ProviderInstanceId.make("codex_work"), + }, + }); + }); +}); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e69b..89a01fe02f2a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -14,6 +14,7 @@ export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; export * from "./providerInstance.ts"; +export * from "./providerIdentity.ts"; export * from "./providerSetup.ts"; export * from "./providerRuntime.ts"; export * from "./providerUsageLimits.ts"; diff --git a/packages/contracts/src/providerIdentity.test.ts b/packages/contracts/src/providerIdentity.test.ts new file mode 100644 index 000000000000..9ddafcea5183 --- /dev/null +++ b/packages/contracts/src/providerIdentity.test.ts @@ -0,0 +1,105 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; +import { + compareManagedProviderLaunchIdentity, + ManagedProviderLaunchIdentity, + qualifyManagedProviderLaunch, +} from "./providerIdentity.ts"; +import { ServerProvider } from "./server.ts"; +import { EnvironmentId } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); +const decodeManagedProviderLaunchIdentity = Schema.decodeUnknownSync(ManagedProviderLaunchIdentity); +const host = { + environmentId: EnvironmentId.make("env-a"), + providerInstanceId: ProviderInstanceId.make("codex"), +}; +const keyIds = [{ namespace: "provider.example", id: "key-a" }] as const; +const identity = { + subject: "subject-a", + keyIds: [...keyIds], + providerHostInstance: host, +} satisfies typeof ManagedProviderLaunchIdentity.Type; + +describe("managed provider launch identity", () => { + it("keeps old provider snapshots compatible when optional identity fields are absent", () => { + const provider = decodeServerProvider({ + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + }); + expect(provider.auth.keyIds).toBeUndefined(); + expect(provider.providerHostInstance).toBeUndefined(); + }); + + it("fails closed when any managed-launch component is unavailable", () => { + expect(qualifyManagedProviderLaunch({ keyIds, providerHostInstance: host })).toEqual({ + qualified: false, + reason: "missing-subject", + }); + expect( + qualifyManagedProviderLaunch({ subject: "subject-a", providerHostInstance: host }), + ).toEqual({ + qualified: false, + reason: "missing-key-id", + }); + expect(qualifyManagedProviderLaunch({ subject: "subject-a", keyIds })).toEqual({ + qualified: false, + reason: "missing-host-identity", + }); + expect(() => + decodeManagedProviderLaunchIdentity({ + subject: "subject-a", + keyIds: [], + providerHostInstance: host, + }), + ).toThrow(); + }); + + it("compares namespace-qualified key sets and reports exact drift", () => { + expect(compareManagedProviderLaunchIdentity(identity, identity)).toBe("match"); + expect( + compareManagedProviderLaunchIdentity(identity, { ...identity, subject: "subject-b" }), + ).toBe("subject-drift"); + expect( + compareManagedProviderLaunchIdentity(identity, { + ...identity, + keyIds: [{ namespace: "provider.example", id: "key-b" }], + }), + ).toBe("key-id-drift"); + expect( + compareManagedProviderLaunchIdentity(identity, { + ...identity, + providerHostInstance: { ...host, environmentId: EnvironmentId.make("env-b") }, + }), + ).toBe("host-drift"); + expect(compareManagedProviderLaunchIdentity(identity, undefined)).toBe("unknown-identity"); + }); + + it("does not collide on embedded NULs and normalizes duplicate IDs", () => { + const first = { + ...identity, + keyIds: [ + { namespace: "a\u0000b", id: "c" }, + { namespace: "a\u0000b", id: "c" }, + ], + }; + const second = { + ...identity, + keyIds: [{ namespace: "a", id: "b\u0000c" }], + }; + expect(compareManagedProviderLaunchIdentity(first, second)).toBe("key-id-drift"); + const qualification = qualifyManagedProviderLaunch(first); + expect(qualification.qualified).toBe(true); + if (qualification.qualified) { + expect(qualification.identity.keyIds).toHaveLength(1); + } + }); +}); diff --git a/packages/contracts/src/providerIdentity.ts b/packages/contracts/src/providerIdentity.ts new file mode 100644 index 000000000000..38d1befc7e6b --- /dev/null +++ b/packages/contracts/src/providerIdentity.ts @@ -0,0 +1,161 @@ +/** + * Managed-launch identity is deliberately separate from ordinary provider + * auth metadata. Host identity is derived from the persisted environment + * and configured instance; key identifiers are accepted only when a provider + * supplies a real public identifier. No local value is a substitute. + */ +import * as Schema from "effect/Schema"; +import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const ProviderKeyIdentifier = Schema.Struct({ + /** Provider or issuer namespace; IDs from different namespaces never match. */ + namespace: TrimmedNonEmptyString, + /** Opaque provider-issued public identifier. Never a token, digest, or UUID fallback. */ + id: TrimmedNonEmptyString, +}); +export type ProviderKeyIdentifier = typeof ProviderKeyIdentifier.Type; + +export const ProviderHostInstanceIdentity = Schema.Struct({ + /** Persisted T3 environment identity. A new state directory is a new host. */ + environmentId: EnvironmentId, + /** Configured provider routing identity within that environment. */ + providerInstanceId: ProviderInstanceId, +}); +export type ProviderHostInstanceIdentity = typeof ProviderHostInstanceIdentity.Type; + +/** Derive the structured host identity from the two existing persisted values. */ +export const makeProviderHostInstanceIdentity = (input: { + readonly environmentId: EnvironmentId; + readonly providerInstanceId: ProviderInstanceId; +}): ProviderHostInstanceIdentity => ({ + environmentId: input.environmentId, + providerInstanceId: input.providerInstanceId, +}); + +export const ManagedProviderLaunchIdentity = Schema.Struct({ + /** Non-secret authenticated principal from the verified session. */ + subject: TrimmedNonEmptyString, + /** One or more provider-issued public IDs; omission is not qualification. */ + keyIds: Schema.Array(ProviderKeyIdentifier).check(Schema.isNonEmpty()), + providerHostInstance: ProviderHostInstanceIdentity, +}); +export type ManagedProviderLaunchIdentity = typeof ManagedProviderLaunchIdentity.Type; + +export const ManagedProviderLaunchNotQualifiedReason = Schema.Literals([ + "missing-subject", + "missing-key-id", + "missing-host-identity", +]); +export type ManagedProviderLaunchNotQualifiedReason = + typeof ManagedProviderLaunchNotQualifiedReason.Type; + +export type ManagedProviderLaunchQualification = + | { readonly qualified: true; readonly identity: ManagedProviderLaunchIdentity } + | { + readonly qualified: false; + readonly reason: ManagedProviderLaunchNotQualifiedReason; + }; + +/** + * Project the three independent launch inputs without manufacturing any + * identity. This is intentionally fail-closed when the provider has no real + * key ID or when the authenticated session is anonymous. Session/access-token + * and DPoP-key rotation are not drift when the subject/key ID is unchanged; + * provider key replacement, subject replacement, or host changes are drift. + */ +export const qualifyManagedProviderLaunch = (input: { + readonly subject?: string | undefined; + readonly keyIds?: ReadonlyArray | undefined; + readonly providerHostInstance?: ProviderHostInstanceIdentity | undefined; +}): ManagedProviderLaunchQualification => { + const subject = input.subject?.trim(); + if (!subject) return { qualified: false, reason: "missing-subject" }; + if (!input.keyIds || input.keyIds.length === 0) { + return { qualified: false, reason: "missing-key-id" }; + } + if (!input.providerHostInstance) { + return { qualified: false, reason: "missing-host-identity" }; + } + return { + qualified: true, + identity: { + subject, + // Key IDs are a set by contract. Sorting and deduplicating makes the + // projection deterministic without treating duplicates as new keys. + keyIds: normalizeKeyIds(input.keyIds), + providerHostInstance: input.providerHostInstance, + }, + }; +}; + +export type ManagedProviderLaunchDrift = + | "subject-drift" + | "key-id-drift" + | "host-drift" + | "unknown-identity"; + +type KeyIdSet = ReadonlyMap>; + +const normalizeKeyIds = ( + keyIds: ReadonlyArray, +): ReadonlyArray => { + const sorted = [...keyIds].sort((left, right) => + left.namespace === right.namespace + ? left.id < right.id + ? -1 + : left.id > right.id + ? 1 + : 0 + : left.namespace < right.namespace + ? -1 + : 1, + ); + return sorted.filter( + (keyId, index) => + index === 0 || + keyId.namespace !== sorted[index - 1]?.namespace || + keyId.id !== sorted[index - 1]?.id, + ); +}; + +// Keep namespace and ID as separate map keys. A delimiter-joined string would +// make tuples containing that delimiter collide and could hide key rotation. +const keyIdSet = (keyIds: ReadonlyArray): KeyIdSet => { + const byNamespace = new Map>(); + for (const keyId of keyIds) { + const ids = byNamespace.get(keyId.namespace) ?? new Set(); + ids.add(keyId.id); + byNamespace.set(keyId.namespace, ids); + } + return byNamespace; +}; + +const equalKeyIdSets = (left: KeyIdSet, right: KeyIdSet): boolean => { + if (left.size !== right.size) return false; + for (const [namespace, ids] of left) { + const otherIds = right.get(namespace); + if (!otherIds || ids.size !== otherIds.size || [...ids].some((id) => !otherIds.has(id))) { + return false; + } + } + return true; +}; + +/** Exact comparison used by re-authorization/rotation gates. */ +export const compareManagedProviderLaunchIdentity = ( + expected: ManagedProviderLaunchIdentity | undefined, + actual: ManagedProviderLaunchIdentity | undefined, +): "match" | ManagedProviderLaunchDrift => { + if (!expected || !actual) return "unknown-identity"; + if (expected.subject !== actual.subject) return "subject-drift"; + if (!equalKeyIdSets(keyIdSet(expected.keyIds), keyIdSet(actual.keyIds))) return "key-id-drift"; + if ( + expected.providerHostInstance.environmentId !== actual.providerHostInstance.environmentId || + expected.providerHostInstance.providerInstanceId !== + actual.providerHostInstance.providerInstanceId + ) { + return "host-drift"; + } + return "match"; +}; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 49a377208799..954441c58982 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -24,6 +24,7 @@ import { import { EditorId, FileManagerRevealKind, RemoteOpenTarget } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { ProviderHostInstanceIdentity, ProviderKeyIdentifier } from "./providerIdentity.ts"; import { ServerProviderUsageLimits, UsageLimitSourceSnapshots } from "./providerUsageLimits.ts"; import { ServerSettings } from "./settings.ts"; @@ -63,6 +64,8 @@ export const ServerProviderAuth = Schema.Struct({ type: Schema.optional(TrimmedNonEmptyString), label: Schema.optional(TrimmedNonEmptyString), email: Schema.optional(TrimmedNonEmptyString), + /** Present only when the provider supplies a real stable public key ID. */ + keyIds: Schema.optional(Schema.Array(ProviderKeyIdentifier)), }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; @@ -214,6 +217,8 @@ export const ServerProvider = Schema.Struct({ version: Schema.NullOr(TrimmedNonEmptyString), status: ServerProviderState, auth: ServerProviderAuth, + /** Optional additive envelope; older clients omit and continue to decode. */ + providerHostInstance: Schema.optional(ProviderHostInstanceIdentity), checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), // Optional for back-compat: every legacy producer omits this field and