- Introduced `SecretRef` class to represent stored secrets without their values. - Updated `SecretStore` to manage `SecretRef` instances, allowing for better handling of secret resolution and status checks. - Added `resolve` and `status` methods to `SecretStore` for retrieving secret values and checking their availability. - Refactored in-memory secret store to support the new structure and added a default in-memory secret provider. - Updated OpenAPI plugin to resolve secret-backed headers during invocation, improving security handling in API requests. - Enhanced tests to cover new secret management features and ensure proper resolution of secrets in headers. All changes are aimed at improving the flexibility and security of secret handling within the SDK.
Unified · Split
| @@ -23,7 +23,10 @@ export class SecretNotFoundError extends Schema.TaggedError<SecretNotFoundError> · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | export class SecretResolutionError extends Schema.TaggedError<SecretResolutionError>()( | 24 | export class SecretResolutionError extends Schema.TaggedError<SecretResolutionError>()( |
| 25 | "SecretResolutionError", | 25 | "SecretResolutionError", |
| 26 | { secretId: SecretId, message: Schema.String }, | 26 | { |
| 27 | secretId: SecretId, | ||
| 28 | message: Schema.String, | ||
| 29 | }, | ||
| 27 | ) {} | 30 | ) {} |
| 28 | 31 | ||
| 29 | export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()( | 32 | export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()( |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { Context, Effect } from "effect"; | 1 | import { Context, Effect } from "effect"; |
| 2 | 2 | ||
| 3 | import type { ToolId, SecretId, PolicyId } from "./ids"; | 3 | import type { ToolId, SecretId, PolicyId } from "./ids"; |
| 4 | import type { Secret, SecretStore } from "./secrets"; | 4 | import type { SecretRef, SecretStore } from "./secrets"; |
| 5 | import type { | 5 | import type { |
| 6 | ToolMetadata, | 6 | ToolMetadata, |
| 7 | ToolSchema, | 7 | ToolSchema, |
| @@ -20,6 +20,7 @@ import type { · 12 unmodified lines | |||
| 20 | ToolNotFoundError, | 20 | ToolNotFoundError, |
| 21 | ToolInvocationError, | 21 | ToolInvocationError, |
| 22 | SecretNotFoundError, | 22 | SecretNotFoundError, |
| 23 | SecretResolutionError, | ||
| 23 | PolicyDeniedError, | 24 | PolicyDeniedError, |
| 24 | } from "./errors"; | 25 | } from "./errors"; |
| 25 | import type { ElicitationDeclinedError } from "./elicitation"; | 26 | import type { ElicitationDeclinedError } from "./elicitation"; |
| @@ -65,14 +66,25 @@ export type Executor< · 39 unmodified lines | |||
| 65 | }; | 66 | }; |
| 66 | 67 | ||
| 67 | readonly secrets: { | 68 | readonly secrets: { |
| 68 | readonly list: () => Effect.Effect<readonly Secret[]>; | 69 | readonly list: () => Effect.Effect<readonly SecretRef[]>; |
| 69 | readonly store: (input: { | 70 | /** Resolve a secret value by id */ |
| 71 | readonly resolve: ( | ||
| 72 | secretId: SecretId, | ||
| 73 | ) => Effect.Effect<string, SecretNotFoundError | SecretResolutionError>; | ||
| 74 | /** Check if a secret can be resolved */ | ||
| 75 | readonly status: ( | ||
| 76 | secretId: SecretId, | ||
| 77 | ) => Effect.Effect<"resolved" | "missing">; | ||
| 78 | /** Store a secret value (creates ref + writes to provider) */ | ||
| 79 | readonly set: (input: { | ||
| 80 | readonly id: SecretId; | ||
| 70 | readonly name: string; | 81 | readonly name: string; |
| 71 | readonly value: string; | 82 | readonly value: string; |
| 72 | readonly purpose?: string; | 83 | readonly purpose?: string; |
| 73 | }) => Effect.Effect<Secret>; | 84 | readonly provider?: string; |
| 85 | }) => Effect.Effect<SecretRef, SecretResolutionError>; | ||
| 74 | readonly remove: ( | 86 | readonly remove: ( |
| 75 | secretId: string, | 87 | secretId: SecretId, |
| 76 | ) => Effect.Effect<boolean, SecretNotFoundError>; | 88 | ) => Effect.Effect<boolean, SecretNotFoundError>; |
| 77 | }; | 89 | }; |
| 78 | 90 | ||
| @@ -153,13 +165,17 @@ export const createExecutor = < · 74 unmodified lines | |||
| 153 | 165 | ||
| 154 | secrets: { | 166 | secrets: { |
| 155 | list: () => secrets.list(scope.id), | 167 | list: () => secrets.list(scope.id), |
| 156 | store: (input: { | 168 | resolve: (secretId: SecretId) => secrets.resolve(secretId, scope.id), |
| 169 | status: (secretId: SecretId) => secrets.status(secretId, scope.id), | ||
| 170 | set: (input: { | ||
| 171 | readonly id: SecretId; | ||
| 157 | readonly name: string; | 172 | readonly name: string; |
| 158 | readonly value: string; | 173 | readonly value: string; |
| 159 | readonly purpose?: string; | 174 | readonly purpose?: string; |
| 160 | }) => secrets.store({ ...input, scopeId: scope.id }), | 175 | readonly provider?: string; |
| 161 | remove: (secretId: string) => | 176 | }) => secrets.set({ ...input, scopeId: scope.id }), |
| 162 | secrets.remove(secretId as SecretId), | 177 | remove: (secretId: SecretId) => |
| 178 | secrets.remove(secretId), | ||
| 163 | }, | 179 | }, |
| 164 | 180 | ||
| 165 | close: () => | 181 | close: () => |
| @@ -1,50 +1,140 @@ | |||
| 1 | import { Effect } from "effect"; | 1 | import { Effect, Option } from "effect"; |
| 2 | 2 | ||
| 3 | import { ScopeId, SecretId } from "../ids"; | 3 | import { ScopeId, SecretId } from "../ids"; |
| 4 | import { SecretNotFoundError } from "../errors"; | 4 | import { SecretNotFoundError, SecretResolutionError } from "../errors"; |
| 5 | import type { Secret } from "../secrets"; | 5 | import type { SecretRef, SecretProvider } from "../secrets"; |
| 6 | |||
| 7 | // --------------------------------------------------------------------------- | ||
| 8 | // In-memory secret provider | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | export const makeInMemorySecretProvider = (): SecretProvider => { | ||
| 12 | const values = new Map<string, string>(); | ||
| 13 | return { | ||
| 14 | key: "memory", | ||
| 15 | writable: true, | ||
| 16 | get: (key) => Effect.sync(() => values.get(key) ?? null), | ||
| 17 | set: (key, value) => Effect.sync(() => { values.set(key, value); }), | ||
| 18 | delete: (key) => Effect.sync(() => values.delete(key)), | ||
| 19 | list: () => Effect.sync(() => [...values.keys()]), | ||
| 20 | }; | ||
| 21 | }; | ||
| 22 | |||
| 23 | // --------------------------------------------------------------------------- | ||
| 24 | // In-memory secret store | ||
| 25 | // --------------------------------------------------------------------------- | ||
| 6 | 26 | ||
| 7 | export const makeInMemorySecretStore = () => { | 27 | export const makeInMemorySecretStore = () => { |
| 8 | const secrets = new Map<string, Secret & { value: string }>(); | 28 | const refs = new Map<string, SecretRef>(); |
| 9 | let counter = 0; | 29 | const providers: SecretProvider[] = []; |
| 30 | |||
| 31 | // Add a default in-memory provider | ||
| 32 | const defaultProvider = makeInMemorySecretProvider(); | ||
| 33 | providers.push(defaultProvider); | ||
| 34 | |||
| 35 | const findWritableProvider = (key?: string): SecretProvider | undefined => | ||
| 36 | key ? providers.find((p) => p.key === key) : providers.find((p) => p.writable); | ||
| 37 | |||
| 38 | const resolveFromProviders = ( | ||
| 39 | secretId: SecretId, | ||
| 40 | providerKey: string | undefined, | ||
| 41 | ): Effect.Effect<string | null> => { | ||
| 42 | if (providerKey) { | ||
| 43 | const provider = providers.find((p) => p.key === providerKey); | ||
| 44 | return provider ? provider.get(secretId) : Effect.succeed(null); | ||
| 45 | } | ||
| 46 | // Try all providers in order | ||
| 47 | return Effect.gen(function* () { | ||
| 48 | for (const provider of providers) { | ||
| 49 | const value = yield* provider.get(secretId); | ||
| 50 | if (value !== null) return value; | ||
| 51 | } | ||
| 52 | return null; | ||
| 53 | }); | ||
| 54 | }; | ||
| 10 | 55 | ||
| 11 | return { | 56 | return { |
| 12 | list: (scopeId: ScopeId) => | 57 | list: (scopeId: ScopeId) => |
| 13 | Effect.succeed( | 58 | Effect.sync(() => |
| 14 | [...secrets.values()].filter((s) => s.scopeId === scopeId), | 59 | [...refs.values()].filter((r) => r.scopeId === scopeId), |
| 15 | ), | 60 | ), |
| 61 | |||
| 16 | get: (secretId: SecretId) => | 62 | get: (secretId: SecretId) => |
| 17 | Effect.fromNullable(secrets.get(secretId)).pipe( | 63 | Effect.fromNullable(refs.get(secretId)).pipe( |
| 18 | Effect.mapError(() => new SecretNotFoundError({ secretId })), | 64 | Effect.mapError(() => new SecretNotFoundError({ secretId })), |
| 19 | ), | 65 | ), |
| 20 | resolve: (secretId: SecretId) => | 66 | |
| 21 | Effect.fromNullable(secrets.get(secretId)).pipe( | 67 | resolve: (secretId: SecretId, _scopeId: ScopeId) => |
| 22 | Effect.mapError(() => new SecretNotFoundError({ secretId })), | 68 | Effect.gen(function* () { |
| 23 | Effect.map((s) => s.value), | 69 | const ref = refs.get(secretId); |
| 24 | ), | 70 | const providerKey = ref ? Option.getOrUndefined(ref.provider) : undefined; |
| 25 | store: (input: { | 71 | |
| 72 | const value = yield* resolveFromProviders(secretId, providerKey); | ||
| 73 | if (value === null) { | ||
| 74 | return yield* new SecretResolutionError({ | ||
| 75 | secretId, | ||
| 76 | message: `Secret "${secretId}" not found in any provider`, | ||
| 77 | }); | ||
| 78 | } | ||
| 79 | return value; | ||
| 80 | }), | ||
| 81 | |||
| 82 | status: (secretId: SecretId, _scopeId: ScopeId) => | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const value = yield* resolveFromProviders(secretId, undefined); | ||
| 85 | return value !== null ? ("resolved" as const) : ("missing" as const); | ||
| 86 | }), | ||
| 87 | |||
| 88 | set: (input: { | ||
| 26 | readonly scopeId: ScopeId; | 89 | readonly scopeId: ScopeId; |
| 90 | readonly id: SecretId; | ||
| 27 | readonly name: string; | 91 | readonly name: string; |
| 28 | readonly value: string; | 92 | readonly value: string; |
| 29 | readonly purpose?: string; | 93 | readonly purpose?: string; |
| 94 | readonly provider?: string; | ||
| 30 | }) => | 95 | }) => |
| 31 | Effect.sync(() => { | 96 | Effect.gen(function* () { |
| 32 | const id = SecretId.make(`secret-${++counter}`); | 97 | const provider = findWritableProvider(input.provider); |
| 33 | const secret = { | 98 | if (!provider?.set) { |
| 34 | id, | 99 | return yield* new SecretResolutionError({ |
| 100 | secretId: input.id, | ||
| 101 | message: `No writable provider found${input.provider ? ` (requested: ${input.provider})` : ""}`, | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | yield* provider.set(input.id, input.value); | ||
| 106 | |||
| 107 | const ref: SecretRef = { | ||
| 108 | id: input.id, | ||
| 35 | scopeId: input.scopeId, | 109 | scopeId: input.scopeId, |
| 36 | name: input.name, | 110 | name: input.name, |
| 111 | provider: Option.fromNullable(input.provider), | ||
| 37 | purpose: input.purpose, | 112 | purpose: input.purpose, |
| 38 | createdAt: new Date(), | 113 | createdAt: new Date(), |
| 39 | value: input.value, | ||
| 40 | }; | 114 | }; |
| 41 | secrets.set(id, secret); | 115 | refs.set(input.id, ref); |
| 42 | return secret; | 116 | return ref; |
| 43 | }), | 117 | }), |
| 118 | |||
| 44 | remove: (secretId: SecretId) => | 119 | remove: (secretId: SecretId) => |
| 45 | Effect.fromNullable(secrets.get(secretId)).pipe( | 120 | Effect.gen(function* () { |
| 46 | Effect.mapError(() => new SecretNotFoundError({ secretId })), | 121 | const ref = refs.get(secretId); |
| 47 | Effect.map(() => secrets.delete(secretId)), | 122 | if (!ref) return yield* new SecretNotFoundError({ secretId }); |
| 48 | ), | 123 | |
| 124 | const providerKey = Option.getOrUndefined(ref.provider); | ||
| 125 | const provider = findWritableProvider(providerKey); | ||
| 126 | if (provider?.delete) { | ||
| 127 | yield* provider.delete(secretId); | ||
| 128 | } | ||
| 129 | |||
| 130 | refs.delete(secretId); | ||
| 131 | return true; | ||
| 132 | }), | ||
| 133 | |||
| 134 | addProvider: (provider: SecretProvider) => | ||
| 135 | Effect.sync(() => { providers.push(provider); }), | ||
| 136 | |||
| 137 | providers: () => | ||
| 138 | Effect.sync(() => providers.map((p) => p.key)), | ||
| 49 | }; | 139 | }; |
| 50 | }; | 140 | }; |
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | ElicitationResponse, | 11 | ElicitationResponse, |
| 12 | type MemoryToolContext, | 12 | type MemoryToolContext, |
| 13 | type ToolId, | 13 | type ToolId, |
| 14 | SecretId, | ||
| 14 | } from "./index"; | 15 | } from "./index"; |
| 15 | 16 | ||
| 16 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| @@ -212,16 +213,28 @@ describe("SDK Executor", () => { · 195 unmodified lines | |||
| 212 | Effect.gen(function* () { | 213 | Effect.gen(function* () { |
| 213 | const executor = yield* createExecutor(makeTestConfig()); | 214 | const executor = yield* createExecutor(makeTestConfig()); |
| 214 | 215 | ||
| 215 | const secret = yield* executor.secrets.store({ | 216 | const secret = yield* executor.secrets.set({ |
| 217 | id: SecretId.make("api-key"), | ||
| 216 | name: "API Key", | 218 | name: "API Key", |
| 217 | value: "sk_test_123", | 219 | value: "sk_test_123", |
| 218 | purpose: "auth", | 220 | purpose: "auth", |
| 219 | }); | 221 | }); |
| 220 | expect(secret.name).toBe("API Key"); | 222 | expect(secret.name).toBe("API Key"); |
| 223 | expect(secret.id).toBe("api-key"); | ||
| 221 | 224 | ||
| 222 | const listed = yield* executor.secrets.list(); | 225 | const listed = yield* executor.secrets.list(); |
| 223 | expect(listed).toHaveLength(1); | 226 | expect(listed).toHaveLength(1); |
| 224 | expect(listed[0]!.name).toBe("API Key"); | 227 | expect(listed[0]!.name).toBe("API Key"); |
| 228 | |||
| 229 | // Can resolve by id | ||
| 230 | const resolved = yield* executor.secrets.resolve(SecretId.make("api-key")); | ||
| 231 | expect(resolved).toBe("sk_test_123"); | ||
| 232 | |||
| 233 | // Can check status | ||
| 234 | const status = yield* executor.secrets.status(SecretId.make("api-key")); | ||
| 235 | expect(status).toBe("resolved"); | ||
| 236 | const missing = yield* executor.secrets.status(SecretId.make("nonexistent")); | ||
| 237 | expect(missing).toBe("missing"); | ||
| 225 | }), | 238 | }), |
| 226 | ); | 239 | ); |
| 227 | 240 | ||
| @@ -414,22 +427,19 @@ describe("SDK Executor", () => { · 186 unmodified lines | |||
| 414 | ctx: MemoryToolContext, | 427 | ctx: MemoryToolContext, |
| 415 | ) => | 428 | ) => |
| 416 | Effect.gen(function* () { | 429 | Effect.gen(function* () { |
| 417 | // Read the current secrets | 430 | // Try to resolve the existing secret by key |
| 418 | const secrets = yield* ctx.sdk.secrets.list(); | 431 | const secretId = SecretId.make(secretName); |
| 419 | const existing = secrets.find( | 432 | const status = yield* ctx.sdk.secrets.status(secretId); |
| 420 | (s) => s.name === secretName, | ||
| 421 | ); | ||
| 422 | 433 | ||
| 423 | let oldValue = "<none>"; | 434 | let oldValue = "<none>"; |
| 424 | if (existing) { | 435 | if (status === "resolved") { |
| 425 | oldValue = yield* ctx.sdk.secrets.resolve( | 436 | oldValue = yield* ctx.sdk.secrets.resolve(secretId); |
| 426 | existing.id, | 437 | yield* ctx.sdk.secrets.remove(secretId); |
| 427 | ); | ||
| 428 | yield* ctx.sdk.secrets.remove(existing.id); | ||
| 429 | } | 438 | } |
| 430 | 439 | ||
| 431 | // Store the new value | 440 | // Store the new value |
| 432 | yield* ctx.sdk.secrets.store({ | 441 | yield* ctx.sdk.secrets.set({ |
| 442 | id: secretId, | ||
| 433 | name: secretName, | 443 | name: secretName, |
| 434 | value: newValue, | 444 | value: newValue, |
| 435 | purpose: "api_key", | 445 | purpose: "api_key", |
| @@ -445,7 +455,8 @@ describe("SDK Executor", () => { · 9 unmodified lines | |||
| 445 | ); | 455 | ); |
| 446 | 456 | ||
| 447 | // 1. Write initial secret | 457 | // 1. Write initial secret |
| 448 | yield* executor.secrets.store({ | 458 | yield* executor.secrets.set({ |
| 459 | id: SecretId.make("DB_PASSWORD"), | ||
| 449 | name: "DB_PASSWORD", | 460 | name: "DB_PASSWORD", |
| 450 | value: "hunter2", | 461 | value: "hunter2", |
| 451 | purpose: "database", | 462 | purpose: "database", |
| @@ -34,7 +34,7 @@ export { · 33 unmodified lines | |||
| 34 | } from "./elicitation"; | 34 | } from "./elicitation"; |
| 35 | 35 | ||
| 36 | // Secrets | 36 | // Secrets |
| 37 | export { Secret, SecretStore } from "./secrets"; | 37 | export { SecretRef, SecretStore, type SecretProvider } from "./secrets"; |
| 38 | 38 | ||
| 39 | // Policies | 39 | // Policies |
| 40 | export { Policy, PolicyAction, PolicyEngine } from "./policies"; | 40 | export { Policy, PolicyAction, PolicyEngine } from "./policies"; |
| @@ -73,7 +73,7 @@ export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; · 32 unmodified lines | |||
| 73 | 73 | ||
| 74 | // In-memory implementations | 74 | // In-memory implementations |
| 75 | export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; | 75 | export { makeInMemoryToolRegistry } from "./in-memory/tool-registry"; |
| 76 | export { makeInMemorySecretStore } from "./in-memory/secret-store"; | 76 | export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store"; |
| 77 | export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine"; | 77 | export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine"; |
| 78 | 78 | ||
| 79 | // Testing | 79 | // Testing |
| @@ -3,7 +3,7 @@ import { Effect, JSONSchema, Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import type { SecretId } from "../ids"; | 3 | import type { SecretId } from "../ids"; |
| 4 | import { ToolId } from "../ids"; | 4 | import { ToolId } from "../ids"; |
| 5 | import { ToolInvocationError } from "../errors"; | 5 | import { ToolInvocationError } from "../errors"; |
| 6 | import type { Secret } from "../secrets"; | 6 | import type { SecretRef } from "../secrets"; |
| 7 | import { | 7 | import { |
| 8 | ToolInvocationResult, | 8 | ToolInvocationResult, |
| 9 | type ToolRegistration, | 9 | type ToolRegistration, |
| @@ -53,13 +53,15 @@ export interface MemoryToolContext { · 43 unmodified lines | |||
| 53 | /** SDK services available to in-memory tool handlers */ | 53 | /** SDK services available to in-memory tool handlers */ |
| 54 | export interface MemoryToolSdkAccess { | 54 | export interface MemoryToolSdkAccess { |
| 55 | readonly secrets: { | 55 | readonly secrets: { |
| 56 | readonly list: () => Effect.Effect<readonly Secret[]>; | 56 | readonly list: () => Effect.Effect<readonly SecretRef[]>; |
| 57 | readonly resolve: (secretId: SecretId) => Effect.Effect<string, unknown>; | 57 | readonly resolve: (secretId: SecretId) => Effect.Effect<string, unknown>; |
| 58 | readonly store: (input: { | 58 | readonly status: (secretId: SecretId) => Effect.Effect<"resolved" | "missing">; |
| 59 | readonly set: (input: { | ||
| 60 | readonly id: SecretId; | ||
| 59 | readonly name: string; | 61 | readonly name: string; |
| 60 | readonly value: string; | 62 | readonly value: string; |
| 61 | readonly purpose?: string; | 63 | readonly purpose?: string; |
| 62 | }) => Effect.Effect<Secret>; | 64 | }) => Effect.Effect<SecretRef, unknown>; |
| 63 | readonly remove: (secretId: SecretId) => Effect.Effect<boolean, unknown>; | 65 | readonly remove: (secretId: SecretId) => Effect.Effect<boolean, unknown>; |
| 64 | }; | 66 | }; |
| 65 | } | 67 | } |
| @@ -179,9 +181,10 @@ const makeInvoker = ( · 113 unmodified lines | |||
| 179 | sdk: { | 181 | sdk: { |
| 180 | secrets: { | 182 | secrets: { |
| 181 | list: () => pluginCtx.secrets.list(pluginCtx.scope.id), | 183 | list: () => pluginCtx.secrets.list(pluginCtx.scope.id), |
| 182 | resolve: (secretId) => pluginCtx.secrets.resolve(secretId), | 184 | resolve: (secretId) => pluginCtx.secrets.resolve(secretId, pluginCtx.scope.id), |
| 183 | store: (input) => | 185 | status: (secretId) => pluginCtx.secrets.status(secretId, pluginCtx.scope.id), |
| 184 | pluginCtx.secrets.store({ | 186 | set: (input) => |
| 187 | pluginCtx.secrets.set({ | ||
| 185 | ...input, | 188 | ...input, |
| 186 | scopeId: pluginCtx.scope.id, | 189 | scopeId: pluginCtx.scope.id, |
| 187 | }), | 190 | }), |
| @@ -3,32 +3,98 @@ import { Context, Effect, Schema } from "effect"; · 2 unmodified lines | |||
| 3 | import { SecretId, ScopeId } from "./ids"; | 3 | import { SecretId, ScopeId } from "./ids"; |
| 4 | import { SecretNotFoundError, SecretResolutionError } from "./errors"; | 4 | import { SecretNotFoundError, SecretResolutionError } from "./errors"; |
| 5 | 5 | ||
| 6 | export class Secret extends Schema.Class<Secret>("Secret")({ | 6 | // --------------------------------------------------------------------------- |
| 7 | // SecretProvider — adapter interface for secret backends | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | export interface SecretProvider { | ||
| 11 | /** Unique key (e.g. "keychain", "env", "1password", "memory") */ | ||
| 12 | readonly key: string; | ||
| 13 | /** Whether this provider supports writing */ | ||
| 14 | readonly writable: boolean; | ||
| 15 | /** Get a secret value by key. Returns null if not found. */ | ||
| 16 | readonly get: (key: string) => Effect.Effect<string | null>; | ||
| 17 | /** Set a secret value. Only called on writable providers. */ | ||
| 18 | readonly set?: (key: string, value: string) => Effect.Effect<void>; | ||
| 19 | /** Delete a secret. Only called on writable providers. */ | ||
| 20 | readonly delete?: (key: string) => Effect.Effect<boolean>; | ||
| 21 | /** List known secret keys. Optional — not all providers can enumerate. */ | ||
| 22 | readonly list?: () => Effect.Effect<readonly string[]>; | ||
| 23 | } | ||
| 24 | |||
| 25 | // --------------------------------------------------------------------------- | ||
| 26 | // SecretRef — what gets stored (not the value itself) | ||
| 27 | // --------------------------------------------------------------------------- | ||
| 28 | |||
| 29 | export class SecretRef extends Schema.Class<SecretRef>("SecretRef")({ | ||
| 7 | id: SecretId, | 30 | id: SecretId, |
| 8 | scopeId: ScopeId, | 31 | scopeId: ScopeId, |
| 32 | /** Human-readable label (e.g. "Cloudflare API Token") */ | ||
| 9 | name: Schema.String, | 33 | name: Schema.String, |
| 34 | /** Optional: pin to a specific provider */ | ||
| 35 | provider: Schema.optionalWith(Schema.String, { as: "Option" }), | ||
| 36 | /** What this secret is for */ | ||
| 10 | purpose: Schema.optional(Schema.String), | 37 | purpose: Schema.optional(Schema.String), |
| 11 | createdAt: Schema.DateFromNumber, | 38 | createdAt: Schema.DateFromNumber, |
| 12 | }) {} | 39 | }) {} |
| 13 | 40 | ||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | // SecretStore — manages refs + delegates resolution to providers | ||
| 43 | // --------------------------------------------------------------------------- | ||
| 44 | |||
| 14 | export class SecretStore extends Context.Tag("@executor/sdk/SecretStore")< | 45 | export class SecretStore extends Context.Tag("@executor/sdk/SecretStore")< |
| 15 | SecretStore, | 46 | SecretStore, |
| 16 | { | 47 | { |
| 17 | readonly list: (scopeId: ScopeId) => Effect.Effect<readonly Secret[]>; | 48 | /** List all secret refs for a scope */ |
| 49 | readonly list: (scopeId: ScopeId) => Effect.Effect<readonly SecretRef[]>; | ||
| 50 | |||
| 51 | /** Get a specific secret ref by id */ | ||
| 18 | readonly get: ( | 52 | readonly get: ( |
| 19 | secretId: SecretId, | 53 | secretId: SecretId, |
| 20 | ) => Effect.Effect<Secret, SecretNotFoundError>; | 54 | ) => Effect.Effect<SecretRef, SecretNotFoundError>; |
| 55 | |||
| 56 | /** | ||
| 57 | * Resolve a secret value by id. | ||
| 58 | * Walks the provider chain (and optionally scope chain) to find the value. | ||
| 59 | */ | ||
| 21 | readonly resolve: ( | 60 | readonly resolve: ( |
| 22 | secretId: SecretId, | 61 | secretId: SecretId, |
| 62 | scopeId: ScopeId, | ||
| 23 | ) => Effect.Effect<string, SecretNotFoundError | SecretResolutionError>; | 63 | ) => Effect.Effect<string, SecretNotFoundError | SecretResolutionError>; |
| 24 | readonly store: (input: { | 64 | |
| 65 | /** | ||
| 66 | * Check if a secret can be resolved. | ||
| 67 | */ | ||
| 68 | readonly status: ( | ||
| 69 | secretId: SecretId, | ||
| 70 | scopeId: ScopeId, | ||
| 71 | ) => Effect.Effect<"resolved" | "missing">; | ||
| 72 | |||
| 73 | /** | ||
| 74 | * Store a secret value. Creates a ref and writes the value to the | ||
| 75 | * preferred writable provider. | ||
| 76 | */ | ||
| 77 | readonly set: (input: { | ||
| 25 | readonly scopeId: ScopeId; | 78 | readonly scopeId: ScopeId; |
| 79 | readonly id: SecretId; | ||
| 26 | readonly name: string; | 80 | readonly name: string; |
| 27 | readonly value: string; | 81 | readonly value: string; |
| 28 | readonly purpose?: string; | 82 | readonly purpose?: string; |
| 29 | }) => Effect.Effect<Secret>; | 83 | /** Pin to a specific provider. Otherwise uses the first writable one. */ |
| 84 | readonly provider?: string; | ||
| 85 | }) => Effect.Effect<SecretRef, SecretResolutionError>; | ||
| 86 | |||
| 87 | /** Remove a secret ref and its value from the provider */ | ||
| 30 | readonly remove: ( | 88 | readonly remove: ( |
| 31 | secretId: SecretId, | 89 | secretId: SecretId, |
| 32 | ) => Effect.Effect<boolean, SecretNotFoundError>; | 90 | ) => Effect.Effect<boolean, SecretNotFoundError>; |
| 91 | |||
| 92 | // ----- Provider management ----- | ||
| 93 | |||
| 94 | /** Register a secret provider */ | ||
| 95 | readonly addProvider: (provider: SecretProvider) => Effect.Effect<void>; | ||
| 96 | |||
| 97 | /** List registered provider keys */ | ||
| 98 | readonly providers: () => Effect.Effect<readonly string[]>; | ||
| 33 | } | 99 | } |
| 34 | >() {} | 100 | >() {} |
| @@ -9,11 +9,14 @@ import { · 8 unmodified lines | |||
| 9 | type ToolInvoker, | 9 | type ToolInvoker, |
| 10 | ToolInvocationResult, | 10 | ToolInvocationResult, |
| 11 | ToolInvocationError, | 11 | ToolInvocationError, |
| 12 | type ScopeId, | ||
| 13 | type SecretId, | ||
| 12 | } from "@executor/sdk"; | 14 | } from "@executor/sdk"; |
| 13 | 15 | ||
| 14 | import { OpenApiInvocationError } from "./errors"; | 16 | import { OpenApiInvocationError } from "./errors"; |
| 15 | import type { OpenApiOperationStore } from "./operation-store"; | 17 | import type { OpenApiOperationStore } from "./operation-store"; |
| 16 | import { | 18 | import { |
| 19 | type HeaderValue, | ||
| 17 | type OperationBinding, | 20 | type OperationBinding, |
| 18 | InvocationConfig, | 21 | InvocationConfig, |
| 19 | InvocationResult, | 22 | InvocationResult, |
| @@ -116,9 +119,35 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 96 unmodified lines | |||
| 116 | }); | 119 | }); |
| 117 | 120 | ||
| 118 | // --------------------------------------------------------------------------- | 121 | // --------------------------------------------------------------------------- |
| 119 | // Header application | 122 | // Header resolution — resolves secret refs at invocation time |
| 120 | // --------------------------------------------------------------------------- | 123 | // --------------------------------------------------------------------------- |
| 121 | 124 | ||
| 125 | const resolveHeaders = ( | ||
| 126 | headers: Record<string, HeaderValue>, | ||
| 127 | secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }, | ||
| 128 | scopeId: ScopeId, | ||
| 129 | ): Effect.Effect<Record<string, string>, ToolInvocationError> => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const resolved: Record<string, string> = {}; | ||
| 132 | for (const [name, value] of Object.entries(headers)) { | ||
| 133 | if (typeof value === "string") { | ||
| 134 | resolved[name] = value; | ||
| 135 | } else { | ||
| 136 | const secret = yield* secrets.resolve(value.secretId as SecretId, scopeId).pipe( | ||
| 137 | Effect.mapError(() => | ||
| 138 | new ToolInvocationError({ | ||
| 139 | toolId: "" as ToolId, | ||
| 140 | message: `Failed to resolve secret "${value.secretId}" for header "${name}"`, | ||
| 141 | cause: undefined, | ||
| 142 | }), | ||
| 143 | ), | ||
| 144 | ); | ||
| 145 | resolved[name] = value.prefix ? `${value.prefix}${secret}` : secret; | ||
| 146 | } | ||
| 147 | } | ||
| 148 | return resolved; | ||
| 149 | }); | ||
| 150 | |||
| 122 | const applyHeaders = ( | 151 | const applyHeaders = ( |
| 123 | request: HttpClientRequest.HttpClientRequest, | 152 | request: HttpClientRequest.HttpClientRequest, |
| 124 | headers: Record<string, string>, | 153 | headers: Record<string, string>, |
| @@ -153,6 +182,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 28 unmodified lines | |||
| 153 | operation: OperationBinding, | 182 | operation: OperationBinding, |
| 154 | args: Record<string, unknown>, | 183 | args: Record<string, unknown>, |
| 155 | config: InvocationConfig, | 184 | config: InvocationConfig, |
| 185 | /** Pre-resolved headers (secrets already resolved) */ | ||
| 186 | resolvedHeaders?: Record<string, string>, | ||
| 156 | ) { | 187 | ) { |
| 157 | const client = yield* HttpClient.HttpClient; | 188 | const client = yield* HttpClient.HttpClient; |
| 158 | 189 | ||
| @@ -204,8 +235,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 45 unmodified lines | |||
| 204 | } | 235 | } |
| 205 | } | 236 | } |
| 206 | 237 | ||
| 207 | // Static headers (auth, custom headers, etc.) | 238 | // Static headers (auth, custom headers, etc.) — use pre-resolved if available |
| 208 | request = applyHeaders(request, config.headers); | 239 | request = applyHeaders(request, resolvedHeaders ?? {}); |
| 209 | 240 | ||
| 210 | // Execute | 241 | // Execute |
| 211 | const response = yield* client.execute(request).pipe( | 242 | const response = yield* client.execute(request).pipe( |
| @@ -247,13 +278,15 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 35 unmodified lines | |||
| 247 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker | 278 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker |
| 248 | // --------------------------------------------------------------------------- | 279 | // --------------------------------------------------------------------------- |
| 249 | 280 | ||
| 250 | export const makeOpenApiInvoker = ( | 281 | export const makeOpenApiInvoker = (opts: { |
| 251 | operationStore: OpenApiOperationStore, | 282 | readonly operationStore: OpenApiOperationStore; |
| 252 | httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | 283 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; |
| 253 | ): ToolInvoker => ({ | 284 | readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; |
| 285 | readonly scopeId: ScopeId; | ||
| 286 | }): ToolInvoker => ({ | ||
| 254 | invoke: (toolId: ToolId, args: unknown, _options) => | 287 | invoke: (toolId: ToolId, args: unknown, _options) => |
| 255 | Effect.gen(function* () { | 288 | Effect.gen(function* () { |
| 256 | const entry = yield* operationStore.get(toolId); | 289 | const entry = yield* opts.operationStore.get(toolId); |
| 257 | if (!entry) { | 290 | if (!entry) { |
| 258 | return yield* new ToolInvocationError({ | 291 | return yield* new ToolInvocationError({ |
| 259 | toolId, | 292 | toolId, |
| @@ -265,6 +298,13 @@ export const makeOpenApiInvoker = ( · 5 unmodified lines | |||
| 265 | const { binding, config } = entry; | 298 | const { binding, config } = entry; |
| 266 | const baseUrl = config.baseUrl; | 299 | const baseUrl = config.baseUrl; |
| 267 | 300 | ||
| 301 | // Resolve secret-backed headers | ||
| 302 | const resolvedHeaders = yield* resolveHeaders( | ||
| 303 | config.headers, | ||
| 304 | opts.secrets, | ||
| 305 | opts.scopeId, | ||
| 306 | ); | ||
| 307 | |||
| 268 | const clientWithBaseUrl = baseUrl | 308 | const clientWithBaseUrl = baseUrl |
| 269 | ? Layer.effect( | 309 | ? Layer.effect( |
| 270 | HttpClient.HttpClient, | 310 | HttpClient.HttpClient, |
| @@ -274,13 +314,14 @@ export const makeOpenApiInvoker = ( · 3 unmodified lines | |||
| 274 | HttpClientRequest.prependUrl(baseUrl), | 314 | HttpClientRequest.prependUrl(baseUrl), |
| 275 | ), | 315 | ), |
| 276 | ), | 316 | ), |
| 277 | ).pipe(Layer.provide(httpClientLayer)) | 317 | ).pipe(Layer.provide(opts.httpClientLayer)) |
| 278 | : httpClientLayer; | 318 | : opts.httpClientLayer; |
| 279 | 319 | ||
| 280 | const result = yield* invoke( | 320 | const result = yield* invoke( |
| 281 | binding, | 321 | binding, |
| 282 | (args ?? {}) as Record<string, unknown>, | 322 | (args ?? {}) as Record<string, unknown>, |
| 283 | config, | 323 | config, |
| 324 | resolvedHeaders, | ||
| 284 | ).pipe(Effect.provide(clientWithBaseUrl)); | 325 | ).pipe(Effect.provide(clientWithBaseUrl)); |
| 285 | 326 | ||
| 286 | return new ToolInvocationResult({ | 327 | return new ToolInvocationResult({ |
| @@ -289,14 +330,22 @@ export const makeOpenApiInvoker = ( · 2 unmodified lines | |||
| 289 | status: result.status, | 330 | status: result.status, |
| 290 | }); | 331 | }); |
| 291 | }).pipe( | 332 | }).pipe( |
| 292 | Effect.mapError((err) => | 333 | Effect.catchAll((err) => { |
| 293 | err._tag === "ToolInvocationError" | 334 | if ( |
| 294 | ? err | 335 | typeof err === "object" && |
| 295 | : new ToolInvocationError({ | 336 | err !== null && |
| 296 | toolId, | 337 | "_tag" in err && |
| 297 | message: `OpenAPI invocation failed: ${err.message}`, | 338 | (err as { _tag: string })._tag === "ToolInvocationError" |
| 298 | cause: err, | 339 | ) { |
| 299 | }), | 340 | return Effect.fail(err as ToolInvocationError); |
| 300 | ), | 341 | } |
| 342 | return Effect.fail( | ||
| 343 | new ToolInvocationError({ | ||
| 344 | toolId, | ||
| 345 | message: `OpenAPI invocation failed: ${err instanceof Error ? err.message : String(err)}`, | ||
| 346 | cause: err, | ||
| 347 | }), | ||
| 348 | ); | ||
| 349 | }), | ||
| 301 | ), | 350 | ), |
| 302 | }); | 351 | }); |
| @@ -6,11 +6,12 @@ import { · 5 unmodified lines | |||
| 6 | HttpApiEndpoint, | 6 | HttpApiEndpoint, |
| 7 | HttpApiGroup, | 7 | HttpApiGroup, |
| 8 | HttpClient, | 8 | HttpClient, |
| 9 | HttpServerRequest, | ||
| 9 | OpenApi, | 10 | OpenApi, |
| 10 | } from "@effect/platform"; | 11 | } from "@effect/platform"; |
| 11 | import { NodeHttpServer } from "@effect/platform-node"; | 12 | import { NodeHttpServer } from "@effect/platform-node"; |
| 12 | 13 | ||
| 13 | import { createExecutor, makeTestConfig } from "@executor/sdk"; | 14 | import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk"; |
| 14 | import { openApiPlugin } from "./plugin"; | 15 | import { openApiPlugin } from "./plugin"; |
| 15 | 16 | ||
| 16 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| @@ -22,6 +23,11 @@ class Item extends Schema.Class<Item>("Item")({ · 5 unmodified lines | |||
| 22 | name: Schema.String, | 23 | name: Schema.String, |
| 23 | }) {} | 24 | }) {} |
| 24 | 25 | ||
| 26 | class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({ | ||
| 27 | authorization: Schema.optional(Schema.String), | ||
| 28 | "x-static": Schema.optional(Schema.String), | ||
| 29 | }) {} | ||
| 30 | |||
| 25 | const ItemsGroup = HttpApiGroup.make("items") | 31 | const ItemsGroup = HttpApiGroup.make("items") |
| 26 | .add( | 32 | .add( |
| 27 | HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)), | 33 | HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)), |
| @@ -30,6 +36,9 @@ const ItemsGroup = HttpApiGroup.make("items") · 2 unmodified lines | |||
| 30 | HttpApiEndpoint.get("getItem", "/items/:itemId") | 36 | HttpApiEndpoint.get("getItem", "/items/:itemId") |
| 31 | .setPath(Schema.Struct({ itemId: Schema.NumberFromString })) | 37 | .setPath(Schema.Struct({ itemId: Schema.NumberFromString })) |
| 32 | .addSuccess(Item), | 38 | .addSuccess(Item), |
| 39 | ) | ||
| 40 | .add( | ||
| 41 | HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders), | ||
| 33 | ); | 42 | ); |
| 34 | 43 | ||
| 35 | const TestApi = HttpApi.make("testApi").add(ItemsGroup); | 44 | const TestApi = HttpApi.make("testApi").add(ItemsGroup); |
| @@ -57,6 +66,15 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 21 unmodified lines | |||
| 57 | name: "Unknown", | 66 | name: "Unknown", |
| 58 | }, | 67 | }, |
| 59 | ), | 68 | ), |
| 69 | ) | ||
| 70 | .handle("echoHeaders", () => | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const req = yield* HttpServerRequest.HttpServerRequest; | ||
| 73 | return new EchoHeaders({ | ||
| 74 | authorization: req.headers["authorization"], | ||
| 75 | "x-static": req.headers["x-static"], | ||
| 76 | }); | ||
| 77 | }), | ||
| 60 | ), | 78 | ), |
| 61 | ); | 79 | ); |
| 62 | 80 | ||
| @@ -98,6 +116,83 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 35 unmodified lines | |||
| 98 | }), | 116 | }), |
| 99 | ); | 117 | ); |
| 100 | 118 | ||
| 119 | it.effect("resolves secret-backed headers at invocation time", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const httpClient = yield* HttpClient.HttpClient; | ||
| 122 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | ||
| 123 | |||
| 124 | const executor = yield* createExecutor( | ||
| 125 | makeTestConfig({ | ||
| 126 | plugins: [ | ||
| 127 | openApiPlugin({ httpClientLayer: clientLayer }), | ||
| 128 | ] as const, | ||
| 129 | }), | ||
| 130 | ); | ||
| 131 | |||
| 132 | // Store a secret | ||
| 133 | yield* executor.secrets.set({ | ||
| 134 | id: SecretId.make("test-api-token"), | ||
| 135 | name: "Test API Token", | ||
| 136 | value: "secret-value-123", | ||
| 137 | }); | ||
| 138 | |||
| 139 | // Add spec with secret-backed header | ||
| 140 | yield* executor.openapi.addSpec({ | ||
| 141 | spec: specJson, | ||
| 142 | namespace: "authed", | ||
| 143 | baseUrl: "", | ||
| 144 | headers: { | ||
| 145 | "Authorization": { secretId: "test-api-token", prefix: "Bearer " }, | ||
| 146 | "X-Static": "hello", | ||
| 147 | }, | ||
| 148 | }); | ||
| 149 | |||
| 150 | // Invoke the echo endpoint — verifies secret was resolved and sent | ||
| 151 | const result = yield* executor.tools.invoke( | ||
| 152 | "authed.items.echoHeaders", | ||
| 153 | {}, | ||
| 154 | ); | ||
| 155 | |||
| 156 | expect(result.error).toBeNull(); | ||
| 157 | const data = result.data as { authorization?: string; "x-static"?: string }; | ||
| 158 | expect(data.authorization).toBe("Bearer secret-value-123"); | ||
| 159 | expect(data["x-static"]).toBe("hello"); | ||
| 160 | }), | ||
| 161 | ); | ||
| 162 | |||
| 163 | it.effect("fails clearly when a secret is missing", () => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const httpClient = yield* HttpClient.HttpClient; | ||
| 166 | const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | ||
| 167 | |||
| 168 | const executor = yield* createExecutor( | ||
| 169 | makeTestConfig({ | ||
| 170 | plugins: [ | ||
| 171 | openApiPlugin({ httpClientLayer: clientLayer }), | ||
| 172 | ] as const, | ||
| 173 | }), | ||
| 174 | ); | ||
| 175 | |||
| 176 | // Add spec with secret-backed header but DON'T store the secret | ||
| 177 | yield* executor.openapi.addSpec({ | ||
| 178 | spec: specJson, | ||
| 179 | namespace: "noauth", | ||
| 180 | baseUrl: "", | ||
| 181 | headers: { | ||
| 182 | "Authorization": { secretId: "missing-token", prefix: "Bearer " }, | ||
| 183 | }, | ||
| 184 | }); | ||
| 185 | |||
| 186 | // Invoke — should fail with a clear error about the missing secret | ||
| 187 | const error = yield* Effect.flip( | ||
| 188 | executor.tools.invoke("noauth.items.listItems", {}), | ||
| 189 | ); | ||
| 190 | |||
| 191 | expect(error._tag).toBe("ToolInvocationError"); | ||
| 192 | expect((error as { message: string }).message).toContain("missing-token"); | ||
| 193 | }), | ||
| 194 | ); | ||
| 195 | |||
| 101 | it.effect("registers tools from an OpenAPI spec", () => | 196 | it.effect("registers tools from an OpenAPI spec", () => |
| 102 | Effect.gen(function* () { | 197 | Effect.gen(function* () { |
| 103 | const httpClient = yield* HttpClient.HttpClient; | 198 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -29,12 +29,15 @@ import { · 28 unmodified lines | |||
| 29 | // Plugin config | 29 | // Plugin config |
| 30 | // --------------------------------------------------------------------------- | 30 | // --------------------------------------------------------------------------- |
| 31 | 31 | ||
| 32 | /** A header value — either a static string or a reference to a secret */ | ||
| 33 | export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string }; | ||
| 34 | |||
| 32 | export interface OpenApiSpecConfig { | 35 | export interface OpenApiSpecConfig { |
| 33 | readonly spec: string; | 36 | readonly spec: string; |
| 34 | readonly baseUrl?: string; | 37 | readonly baseUrl?: string; |
| 35 | readonly namespace?: string; | 38 | readonly namespace?: string; |
| 36 | /** Static headers applied to every request for this spec */ | 39 | /** Headers applied to every request. Values can reference secrets. */ |
| 37 | readonly headers?: Record<string, string>; | 40 | readonly headers?: Record<string, HeaderValue>; |
| 38 | } | 41 | } |
| 39 | 42 | ||
| 40 | // --------------------------------------------------------------------------- | 43 | // --------------------------------------------------------------------------- |
| @@ -105,7 +108,12 @@ export const openApiPlugin = (options?: { · 64 unmodified lines | |||
| 105 | Effect.gen(function* () { | 108 | Effect.gen(function* () { |
| 106 | yield* ctx.tools.registerInvoker( | 109 | yield* ctx.tools.registerInvoker( |
| 107 | "openapi", | 110 | "openapi", |
| 108 | makeOpenApiInvoker(operationStore, httpClientLayer), | 111 | makeOpenApiInvoker({ |
| 112 | operationStore, | ||
| 113 | httpClientLayer, | ||
| 114 | secrets: ctx.secrets, | ||
| 115 | scopeId: ctx.scope.id, | ||
| 116 | }), | ||
| 109 | ); | 117 | ); |
| 110 | 118 | ||
| 111 | return { | 119 | return { |
| @@ -94,13 +94,26 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 93 unmodified lines | |||
| 94 | // Invocation | 94 | // Invocation |
| 95 | // --------------------------------------------------------------------------- | 95 | // --------------------------------------------------------------------------- |
| 96 | 96 | ||
| 97 | /** | ||
| 98 | * A header value — either a static string or a reference to a secret. | ||
| 99 | * Stored as JSON-serializable data. | ||
| 100 | */ | ||
| 101 | export const HeaderValue = Schema.Union( | ||
| 102 | Schema.String, | ||
| 103 | Schema.Struct({ | ||
| 104 | secretId: Schema.String, | ||
| 105 | prefix: Schema.optional(Schema.String), | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | export type HeaderValue = typeof HeaderValue.Type; | ||
| 109 | |||
| 97 | export class InvocationConfig extends Schema.Class<InvocationConfig>( | 110 | export class InvocationConfig extends Schema.Class<InvocationConfig>( |
| 98 | "InvocationConfig", | 111 | "InvocationConfig", |
| 99 | )({ | 112 | )({ |
| 100 | baseUrl: Schema.String, | 113 | baseUrl: Schema.String, |
| 101 | /** Static headers applied to every request (auth, custom headers, etc.) */ | 114 | /** Headers applied to every request. Values can reference secrets. */ |
| 102 | headers: Schema.optionalWith( | 115 | headers: Schema.optionalWith( |
| 103 | Schema.Record({ key: Schema.String, value: Schema.String }), | 116 | Schema.Record({ key: Schema.String, value: HeaderValue }), |
| 104 | { default: () => ({}) }, | 117 | { default: () => ({}) }, |
| 105 | ), | 118 | ), |
| 106 | }) {} | 119 | }) {} |