- 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.
Showing the changes to packages/plugins/openapi/src (4 of 11 files). Show all
Unified · Split
| @@ -9,11 +9,14 @@ import { · 8 unmodified lines | ||
| 9 | 9 | type ToolInvoker, |
| 10 | 10 | ToolInvocationResult, |
| 11 | 11 | ToolInvocationError, |
| 12 | + type ScopeId, | |
| 13 | + type SecretId, | |
| 12 | 14 | } from "@executor/sdk"; |
| 13 | 15 | |
| 14 | 16 | import { OpenApiInvocationError } from "./errors"; |
| 15 | 17 | import type { OpenApiOperationStore } from "./operation-store"; |
| 16 | 18 | import { |
| 19 | + type HeaderValue, | |
| 17 | 20 | type OperationBinding, |
| 18 | 21 | InvocationConfig, |
| 19 | 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 | 151 | const applyHeaders = ( |
| 123 | 152 | request: HttpClientRequest.HttpClientRequest, |
| 124 | 153 | headers: Record<string, string>, |
| @@ -153,6 +182,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 28 unmodified lines | ||
| 153 | 182 | operation: OperationBinding, |
| 154 | 183 | args: Record<string, unknown>, |
| 155 | 184 | config: InvocationConfig, |
| 185 | + /** Pre-resolved headers (secrets already resolved) */ | |
| 186 | + resolvedHeaders?: Record<string, string>, | |
| 156 | 187 | ) { |
| 157 | 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.) | |
| 208 | − request = applyHeaders(request, config.headers); | |
| 238 | + // Static headers (auth, custom headers, etc.) — use pre-resolved if available | |
| 239 | + request = applyHeaders(request, resolvedHeaders ?? {}); | |
| 209 | 240 | |
| 210 | 241 | // Execute |
| 211 | 242 | const response = yield* client.execute(request).pipe( |
| @@ -247,13 +278,15 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 35 unmodified lines | ||
| 247 | 278 | // ToolInvoker — bridges operation store + HTTP client into SDK invoker |
| 248 | 279 | // --------------------------------------------------------------------------- |
| 249 | 280 | |
| 250 | −export const makeOpenApiInvoker = ( | |
| 251 | − operationStore: OpenApiOperationStore, | |
| 252 | − httpClientLayer: Layer.Layer<HttpClient.HttpClient>, | |
| 253 | −): ToolInvoker => ({ | |
| 281 | +export const makeOpenApiInvoker = (opts: { | |
| 282 | + readonly operationStore: OpenApiOperationStore; | |
| 283 | + readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient>; | |
| 284 | + readonly secrets: { readonly resolve: (secretId: SecretId, scopeId: ScopeId) => Effect.Effect<string, unknown> }; | |
| 285 | + readonly scopeId: ScopeId; | |
| 286 | +}): ToolInvoker => ({ | |
| 254 | 287 | invoke: (toolId: ToolId, args: unknown, _options) => |
| 255 | 288 | Effect.gen(function* () { |
| 256 | − const entry = yield* operationStore.get(toolId); | |
| 289 | + const entry = yield* opts.operationStore.get(toolId); | |
| 257 | 290 | if (!entry) { |
| 258 | 291 | return yield* new ToolInvocationError({ |
| 259 | 292 | toolId, |
| @@ -265,6 +298,13 @@ export const makeOpenApiInvoker = ( · 5 unmodified lines | ||
| 265 | 298 | const { binding, config } = entry; |
| 266 | 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 | 308 | const clientWithBaseUrl = baseUrl |
| 269 | 309 | ? Layer.effect( |
| 270 | 310 | HttpClient.HttpClient, |
| @@ -274,13 +314,14 @@ export const makeOpenApiInvoker = ( · 3 unmodified lines | ||
| 274 | 314 | HttpClientRequest.prependUrl(baseUrl), |
| 275 | 315 | ), |
| 276 | 316 | ), |
| 277 | − ).pipe(Layer.provide(httpClientLayer)) | |
| 278 | − : httpClientLayer; | |
| 317 | + ).pipe(Layer.provide(opts.httpClientLayer)) | |
| 318 | + : opts.httpClientLayer; | |
| 279 | 319 | |
| 280 | 320 | const result = yield* invoke( |
| 281 | 321 | binding, |
| 282 | 322 | (args ?? {}) as Record<string, unknown>, |
| 283 | 323 | config, |
| 324 | + resolvedHeaders, | |
| 284 | 325 | ).pipe(Effect.provide(clientWithBaseUrl)); |
| 285 | 326 | |
| 286 | 327 | return new ToolInvocationResult({ |
| @@ -289,14 +330,22 @@ export const makeOpenApiInvoker = ( · 2 unmodified lines | ||
| 289 | 330 | status: result.status, |
| 290 | 331 | }); |
| 291 | 332 | }).pipe( |
| 292 | − Effect.mapError((err) => | |
| 293 | − err._tag === "ToolInvocationError" | |
| 294 | − ? err | |
| 295 | − : new ToolInvocationError({ | |
| 296 | − toolId, | |
| 297 | − message: `OpenAPI invocation failed: ${err.message}`, | |
| 298 | − cause: err, | |
| 299 | − }), | |
| 300 | − ), | |
| 333 | + Effect.catchAll((err) => { | |
| 334 | + if ( | |
| 335 | + typeof err === "object" && | |
| 336 | + err !== null && | |
| 337 | + "_tag" in err && | |
| 338 | + (err as { _tag: string })._tag === "ToolInvocationError" | |
| 339 | + ) { | |
| 340 | + return Effect.fail(err as ToolInvocationError); | |
| 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 | 6 | HttpApiEndpoint, |
| 7 | 7 | HttpApiGroup, |
| 8 | 8 | HttpClient, |
| 9 | + HttpServerRequest, | |
| 9 | 10 | OpenApi, |
| 10 | 11 | } from "@effect/platform"; |
| 11 | 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 | 15 | import { openApiPlugin } from "./plugin"; |
| 15 | 16 | |
| 16 | 17 | // --------------------------------------------------------------------------- |
| @@ -22,6 +23,11 @@ class Item extends Schema.Class<Item>("Item")({ · 5 unmodified lines | ||
| 22 | 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 | 31 | const ItemsGroup = HttpApiGroup.make("items") |
| 26 | 32 | .add( |
| 27 | 33 | HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)), |
| @@ -30,6 +36,9 @@ const ItemsGroup = HttpApiGroup.make("items") · 2 unmodified lines | ||
| 30 | 36 | HttpApiEndpoint.get("getItem", "/items/:itemId") |
| 31 | 37 | .setPath(Schema.Struct({ itemId: Schema.NumberFromString })) |
| 32 | 38 | .addSuccess(Item), |
| 39 | + ) | |
| 40 | + .add( | |
| 41 | + HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders), | |
| 33 | 42 | ); |
| 34 | 43 | |
| 35 | 44 | const TestApi = HttpApi.make("testApi").add(ItemsGroup); |
| @@ -57,6 +66,15 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 21 unmodified lines | ||
| 57 | 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 | 196 | it.effect("registers tools from an OpenAPI spec", () => |
| 102 | 197 | Effect.gen(function* () { |
| 103 | 198 | const httpClient = yield* HttpClient.HttpClient; |
| @@ -29,12 +29,15 @@ import { · 28 unmodified lines | ||
| 29 | 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 | 35 | export interface OpenApiSpecConfig { |
| 33 | 36 | readonly spec: string; |
| 34 | 37 | readonly baseUrl?: string; |
| 35 | 38 | readonly namespace?: string; |
| 36 | − /** Static headers applied to every request for this spec */ | |
| 37 | − readonly headers?: Record<string, string>; | |
| 39 | + /** Headers applied to every request. Values can reference secrets. */ | |
| 40 | + readonly headers?: Record<string, HeaderValue>; | |
| 38 | 41 | } |
| 39 | 42 | |
| 40 | 43 | // --------------------------------------------------------------------------- |
| @@ -105,7 +108,12 @@ export const openApiPlugin = (options?: { · 64 unmodified lines | ||
| 105 | 108 | Effect.gen(function* () { |
| 106 | 109 | yield* ctx.tools.registerInvoker( |
| 107 | 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 | 119 | return { |
| @@ -94,13 +94,26 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 93 unmodified lines | ||
| 94 | 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 | 110 | export class InvocationConfig extends Schema.Class<InvocationConfig>( |
| 98 | 111 | "InvocationConfig", |
| 99 | 112 | )({ |
| 100 | 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 | 115 | headers: Schema.optionalWith( |
| 103 | − Schema.Record({ key: Schema.String, value: Schema.String }), | |
| 116 | + Schema.Record({ key: Schema.String, value: HeaderValue }), | |
| 104 | 117 | { default: () => ({}) }, |
| 105 | 118 | ), |
| 106 | 119 | }) {} |