History

feat: enhance secret management in SDK

- 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.

Rhys Sullivan committed 7 months ago

Commit 8d3cddf1 parent 673821a11 files · +441 −87Browse files

Unified · Split

11 files
  1. packages/core/sdk/src/errors.ts +4 −1
  2. packages/core/sdk/src/executor.ts +25 −9
  3. packages/core/sdk/src/in-memory/secret-store.ts +115 −25
  4. packages/core/sdk/src/index.test.ts +24 −13
  5. packages/core/sdk/src/index.ts +2 −2
  6. packages/core/sdk/src/plugins/in-memory-tools.ts +10 −7
  7. packages/core/sdk/src/secrets.ts +71 −5
  8. packages/plugins/openapi/src/invoke.ts +68 −19
  9. packages/plugins/openapi/src/plugin.test.ts +96 −1
  10. packages/plugins/openapi/src/plugin.ts +11 −3
  11. packages/plugins/openapi/src/types.ts +15 −2
packages/core/sdk/src/errors.ts+4−1
@@ -23,7 +23,10 @@ export class SecretNotFoundError extends Schema.TaggedError<SecretNotFoundError> · 22 unmodified lines
2323
2424 export class SecretResolutionError extends Schema.TaggedError<SecretResolutionError>()(
2525 "SecretResolutionError",
26− { secretId: SecretId, message: Schema.String },
26+ {
27+ secretId: SecretId,
28+ message: Schema.String,
29+ },
2730 ) {}
2831
2932 export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()(
packages/core/sdk/src/executor.ts+25−9
@@ -1,7 +1,7 @@
11 import { Context, Effect } from "effect";
22
33 import type { ToolId, SecretId, PolicyId } from "./ids";
4−import type { Secret, SecretStore } from "./secrets";
4+import type { SecretRef, SecretStore } from "./secrets";
55 import type {
66 ToolMetadata,
77 ToolSchema,
@@ -20,6 +20,7 @@ import type { · 12 unmodified lines
2020 ToolNotFoundError,
2121 ToolInvocationError,
2222 SecretNotFoundError,
23+ SecretResolutionError,
2324 PolicyDeniedError,
2425 } from "./errors";
2526 import type { ElicitationDeclinedError } from "./elicitation";
@@ -65,14 +66,25 @@ export type Executor< · 39 unmodified lines
6566 };
6667
6768 readonly secrets: {
68− readonly list: () => Effect.Effect<readonly Secret[]>;
69− readonly store: (input: {
69+ readonly list: () => Effect.Effect<readonly SecretRef[]>;
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;
7081 readonly name: string;
7182 readonly value: string;
7283 readonly purpose?: string;
73− }) => Effect.Effect<Secret>;
84+ readonly provider?: string;
85+ }) => Effect.Effect<SecretRef, SecretResolutionError>;
7486 readonly remove: (
75− secretId: string,
87+ secretId: SecretId,
7688 ) => Effect.Effect<boolean, SecretNotFoundError>;
7789 };
7890
@@ -153,13 +165,17 @@ export const createExecutor = < · 74 unmodified lines
153165
154166 secrets: {
155167 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;
157172 readonly name: string;
158173 readonly value: string;
159174 readonly purpose?: string;
160− }) => secrets.store({ ...input, scopeId: scope.id }),
161− remove: (secretId: string) =>
162− secrets.remove(secretId as SecretId),
175+ readonly provider?: string;
176+ }) => secrets.set({ ...input, scopeId: scope.id }),
177+ remove: (secretId: SecretId) =>
178+ secrets.remove(secretId),
163179 },
164180
165181 close: () =>
packages/core/sdk/src/in-memory/secret-store.ts+115−25
@@ -1,50 +1,140 @@
1−import { Effect } from "effect";
1+import { Effect, Option } from "effect";
22
33 import { ScopeId, SecretId } from "../ids";
4−import { SecretNotFoundError } from "../errors";
5−import type { Secret } from "../secrets";
4+import { SecretNotFoundError, SecretResolutionError } from "../errors";
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+// ---------------------------------------------------------------------------
626
727 export const makeInMemorySecretStore = () => {
8− const secrets = new Map<string, Secret & { value: string }>();
9− let counter = 0;
28+ const refs = new Map<string, SecretRef>();
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+ };
1055
1156 return {
1257 list: (scopeId: ScopeId) =>
13− Effect.succeed(
14− [...secrets.values()].filter((s) => s.scopeId === scopeId),
58+ Effect.sync(() =>
59+ [...refs.values()].filter((r) => r.scopeId === scopeId),
1560 ),
61+
1662 get: (secretId: SecretId) =>
17− Effect.fromNullable(secrets.get(secretId)).pipe(
63+ Effect.fromNullable(refs.get(secretId)).pipe(
1864 Effect.mapError(() => new SecretNotFoundError({ secretId })),
1965 ),
20− resolve: (secretId: SecretId) =>
21− Effect.fromNullable(secrets.get(secretId)).pipe(
22− Effect.mapError(() => new SecretNotFoundError({ secretId })),
23− Effect.map((s) => s.value),
24− ),
25− store: (input: {
66+
67+ resolve: (secretId: SecretId, _scopeId: ScopeId) =>
68+ Effect.gen(function* () {
69+ const ref = refs.get(secretId);
70+ const providerKey = ref ? Option.getOrUndefined(ref.provider) : undefined;
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: {
2689 readonly scopeId: ScopeId;
90+ readonly id: SecretId;
2791 readonly name: string;
2892 readonly value: string;
2993 readonly purpose?: string;
94+ readonly provider?: string;
3095 }) =>
31− Effect.sync(() => {
32− const id = SecretId.make(`secret-${++counter}`);
33− const secret = {
34− id,
96+ Effect.gen(function* () {
97+ const provider = findWritableProvider(input.provider);
98+ if (!provider?.set) {
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,
35109 scopeId: input.scopeId,
36110 name: input.name,
111+ provider: Option.fromNullable(input.provider),
37112 purpose: input.purpose,
38113 createdAt: new Date(),
39− value: input.value,
40114 };
41− secrets.set(id, secret);
42− return secret;
115+ refs.set(input.id, ref);
116+ return ref;
43117 }),
118+
44119 remove: (secretId: SecretId) =>
45− Effect.fromNullable(secrets.get(secretId)).pipe(
46− Effect.mapError(() => new SecretNotFoundError({ secretId })),
47− Effect.map(() => secrets.delete(secretId)),
48− ),
120+ Effect.gen(function* () {
121+ const ref = refs.get(secretId);
122+ if (!ref) return yield* new SecretNotFoundError({ secretId });
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)),
49139 };
50140 };
packages/core/sdk/src/index.test.ts+24−13
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 ElicitationResponse,
1212 type MemoryToolContext,
1313 type ToolId,
14+ SecretId,
1415 } from "./index";
1516
1617 // ---------------------------------------------------------------------------
@@ -212,16 +213,28 @@ describe("SDK Executor", () => { · 195 unmodified lines
212213 Effect.gen(function* () {
213214 const executor = yield* createExecutor(makeTestConfig());
214215
215− const secret = yield* executor.secrets.store({
216+ const secret = yield* executor.secrets.set({
217+ id: SecretId.make("api-key"),
216218 name: "API Key",
217219 value: "sk_test_123",
218220 purpose: "auth",
219221 });
220222 expect(secret.name).toBe("API Key");
223+ expect(secret.id).toBe("api-key");
221224
222225 const listed = yield* executor.secrets.list();
223226 expect(listed).toHaveLength(1);
224227 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");
225238 }),
226239 );
227240
@@ -414,22 +427,19 @@ describe("SDK Executor", () => { · 186 unmodified lines
414427 ctx: MemoryToolContext,
415428 ) =>
416429 Effect.gen(function* () {
417− // Read the current secrets
418− const secrets = yield* ctx.sdk.secrets.list();
419− const existing = secrets.find(
420− (s) => s.name === secretName,
421− );
430+ // Try to resolve the existing secret by key
431+ const secretId = SecretId.make(secretName);
432+ const status = yield* ctx.sdk.secrets.status(secretId);
422433
423434 let oldValue = "<none>";
424− if (existing) {
425− oldValue = yield* ctx.sdk.secrets.resolve(
426− existing.id,
427− );
428− yield* ctx.sdk.secrets.remove(existing.id);
435+ if (status === "resolved") {
436+ oldValue = yield* ctx.sdk.secrets.resolve(secretId);
437+ yield* ctx.sdk.secrets.remove(secretId);
429438 }
430439
431440 // Store the new value
432− yield* ctx.sdk.secrets.store({
441+ yield* ctx.sdk.secrets.set({
442+ id: secretId,
433443 name: secretName,
434444 value: newValue,
435445 purpose: "api_key",
@@ -445,7 +455,8 @@ describe("SDK Executor", () => { · 9 unmodified lines
445455 );
446456
447457 // 1. Write initial secret
448− yield* executor.secrets.store({
458+ yield* executor.secrets.set({
459+ id: SecretId.make("DB_PASSWORD"),
449460 name: "DB_PASSWORD",
450461 value: "hunter2",
451462 purpose: "database",
packages/core/sdk/src/index.ts+2−2
@@ -34,7 +34,7 @@ export { · 33 unmodified lines
3434 } from "./elicitation";
3535
3636 // Secrets
37−export { Secret, SecretStore } from "./secrets";
37+export { SecretRef, SecretStore, type SecretProvider } from "./secrets";
3838
3939 // Policies
4040 export { Policy, PolicyAction, PolicyEngine } from "./policies";
@@ -73,7 +73,7 @@ export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; · 32 unmodified lines
7373
7474 // In-memory implementations
7575 export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";
76−export { makeInMemorySecretStore } from "./in-memory/secret-store";
76+export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store";
7777 export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine";
7878
7979 // Testing
packages/core/sdk/src/plugins/in-memory-tools.ts+10−7
@@ -3,7 +3,7 @@ import { Effect, JSONSchema, Schema } from "effect"; · 2 unmodified lines
33 import type { SecretId } from "../ids";
44 import { ToolId } from "../ids";
55 import { ToolInvocationError } from "../errors";
6−import type { Secret } from "../secrets";
6+import type { SecretRef } from "../secrets";
77 import {
88 ToolInvocationResult,
99 type ToolRegistration,
@@ -53,13 +53,15 @@ export interface MemoryToolContext { · 43 unmodified lines
5353 /** SDK services available to in-memory tool handlers */
5454 export interface MemoryToolSdkAccess {
5555 readonly secrets: {
56− readonly list: () => Effect.Effect<readonly Secret[]>;
56+ readonly list: () => Effect.Effect<readonly SecretRef[]>;
5757 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;
5961 readonly name: string;
6062 readonly value: string;
6163 readonly purpose?: string;
62− }) => Effect.Effect<Secret>;
64+ }) => Effect.Effect<SecretRef, unknown>;
6365 readonly remove: (secretId: SecretId) => Effect.Effect<boolean, unknown>;
6466 };
6567 }
@@ -179,9 +181,10 @@ const makeInvoker = ( · 113 unmodified lines
179181 sdk: {
180182 secrets: {
181183 list: () => pluginCtx.secrets.list(pluginCtx.scope.id),
182− resolve: (secretId) => pluginCtx.secrets.resolve(secretId),
183− store: (input) =>
184− pluginCtx.secrets.store({
184+ resolve: (secretId) => pluginCtx.secrets.resolve(secretId, pluginCtx.scope.id),
185+ status: (secretId) => pluginCtx.secrets.status(secretId, pluginCtx.scope.id),
186+ set: (input) =>
187+ pluginCtx.secrets.set({
185188 ...input,
186189 scopeId: pluginCtx.scope.id,
187190 }),
packages/core/sdk/src/secrets.ts+71−5
@@ -3,32 +3,98 @@ import { Context, Effect, Schema } from "effect"; · 2 unmodified lines
33 import { SecretId, ScopeId } from "./ids";
44 import { SecretNotFoundError, SecretResolutionError } from "./errors";
55
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")({
730 id: SecretId,
831 scopeId: ScopeId,
32+ /** Human-readable label (e.g. "Cloudflare API Token") */
933 name: Schema.String,
34+ /** Optional: pin to a specific provider */
35+ provider: Schema.optionalWith(Schema.String, { as: "Option" }),
36+ /** What this secret is for */
1037 purpose: Schema.optional(Schema.String),
1138 createdAt: Schema.DateFromNumber,
1239 }) {}
1340
41+// ---------------------------------------------------------------------------
42+// SecretStore — manages refs + delegates resolution to providers
43+// ---------------------------------------------------------------------------
44+
1445 export class SecretStore extends Context.Tag("@executor/sdk/SecretStore")<
1546 SecretStore,
1647 {
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 */
1852 readonly get: (
1953 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+ */
2160 readonly resolve: (
2261 secretId: SecretId,
62+ scopeId: ScopeId,
2363 ) => 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: {
2578 readonly scopeId: ScopeId;
79+ readonly id: SecretId;
2680 readonly name: string;
2781 readonly value: string;
2882 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 */
3088 readonly remove: (
3189 secretId: SecretId,
3290 ) => 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[]>;
3399 }
34100 >() {}
packages/plugins/openapi/src/invoke.ts+68−19
@@ -9,11 +9,14 @@ import { · 8 unmodified lines
99 type ToolInvoker,
1010 ToolInvocationResult,
1111 ToolInvocationError,
12+ type ScopeId,
13+ type SecretId,
1214 } from "@executor/sdk";
1315
1416 import { OpenApiInvocationError } from "./errors";
1517 import type { OpenApiOperationStore } from "./operation-store";
1618 import {
19+ type HeaderValue,
1720 type OperationBinding,
1821 InvocationConfig,
1922 InvocationResult,
@@ -116,9 +119,35 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* ( · 96 unmodified lines
116119 });
117120
118121 // ---------------------------------------------------------------------------
119−// Header application
122+// Header resolution — resolves secret refs at invocation time
120123 // ---------------------------------------------------------------------------
121124
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+
122151 const applyHeaders = (
123152 request: HttpClientRequest.HttpClientRequest,
124153 headers: Record<string, string>,
@@ -153,6 +182,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 28 unmodified lines
153182 operation: OperationBinding,
154183 args: Record<string, unknown>,
155184 config: InvocationConfig,
185+ /** Pre-resolved headers (secrets already resolved) */
186+ resolvedHeaders?: Record<string, string>,
156187 ) {
157188 const client = yield* HttpClient.HttpClient;
158189
@@ -204,8 +235,8 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 45 unmodified lines
204235 }
205236 }
206237
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 ?? {});
209240
210241 // Execute
211242 const response = yield* client.execute(request).pipe(
@@ -247,13 +278,15 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* ( · 35 unmodified lines
247278 // ToolInvoker — bridges operation store + HTTP client into SDK invoker
248279 // ---------------------------------------------------------------------------
249280
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 => ({
254287 invoke: (toolId: ToolId, args: unknown, _options) =>
255288 Effect.gen(function* () {
256− const entry = yield* operationStore.get(toolId);
289+ const entry = yield* opts.operationStore.get(toolId);
257290 if (!entry) {
258291 return yield* new ToolInvocationError({
259292 toolId,
@@ -265,6 +298,13 @@ export const makeOpenApiInvoker = ( · 5 unmodified lines
265298 const { binding, config } = entry;
266299 const baseUrl = config.baseUrl;
267300
301+ // Resolve secret-backed headers
302+ const resolvedHeaders = yield* resolveHeaders(
303+ config.headers,
304+ opts.secrets,
305+ opts.scopeId,
306+ );
307+
268308 const clientWithBaseUrl = baseUrl
269309 ? Layer.effect(
270310 HttpClient.HttpClient,
@@ -274,13 +314,14 @@ export const makeOpenApiInvoker = ( · 3 unmodified lines
274314 HttpClientRequest.prependUrl(baseUrl),
275315 ),
276316 ),
277− ).pipe(Layer.provide(httpClientLayer))
278− : httpClientLayer;
317+ ).pipe(Layer.provide(opts.httpClientLayer))
318+ : opts.httpClientLayer;
279319
280320 const result = yield* invoke(
281321 binding,
282322 (args ?? {}) as Record<string, unknown>,
283323 config,
324+ resolvedHeaders,
284325 ).pipe(Effect.provide(clientWithBaseUrl));
285326
286327 return new ToolInvocationResult({
@@ -289,14 +330,22 @@ export const makeOpenApiInvoker = ( · 2 unmodified lines
289330 status: result.status,
290331 });
291332 }).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+ }),
301350 ),
302351 });
packages/plugins/openapi/src/plugin.test.ts+96−1
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
66 HttpApiEndpoint,
77 HttpApiGroup,
88 HttpClient,
9+ HttpServerRequest,
910 OpenApi,
1011 } from "@effect/platform";
1112 import { NodeHttpServer } from "@effect/platform-node";
1213
13−import { createExecutor, makeTestConfig } from "@executor/sdk";
14+import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk";
1415 import { openApiPlugin } from "./plugin";
1516
1617 // ---------------------------------------------------------------------------
@@ -22,6 +23,11 @@ class Item extends Schema.Class<Item>("Item")({ · 5 unmodified lines
2223 name: Schema.String,
2324 }) {}
2425
26+class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({
27+ authorization: Schema.optional(Schema.String),
28+ "x-static": Schema.optional(Schema.String),
29+}) {}
30+
2531 const ItemsGroup = HttpApiGroup.make("items")
2632 .add(
2733 HttpApiEndpoint.get("listItems", "/items").addSuccess(Schema.Array(Item)),
@@ -30,6 +36,9 @@ const ItemsGroup = HttpApiGroup.make("items") · 2 unmodified lines
3036 HttpApiEndpoint.get("getItem", "/items/:itemId")
3137 .setPath(Schema.Struct({ itemId: Schema.NumberFromString }))
3238 .addSuccess(Item),
39+ )
40+ .add(
41+ HttpApiEndpoint.get("echoHeaders", "/echo-headers").addSuccess(EchoHeaders),
3342 );
3443
3544 const TestApi = HttpApi.make("testApi").add(ItemsGroup);
@@ -57,6 +66,15 @@ const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) => · 21 unmodified lines
5766 name: "Unknown",
5867 },
5968 ),
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+ }),
6078 ),
6179 );
6280
@@ -98,6 +116,83 @@ layer(TestLayer)("OpenAPI Plugin", (it) => { · 35 unmodified lines
98116 }),
99117 );
100118
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+
101196 it.effect("registers tools from an OpenAPI spec", () =>
102197 Effect.gen(function* () {
103198 const httpClient = yield* HttpClient.HttpClient;
packages/plugins/openapi/src/plugin.ts+11−3
@@ -29,12 +29,15 @@ import { · 28 unmodified lines
2929 // Plugin config
3030 // ---------------------------------------------------------------------------
3131
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+
3235 export interface OpenApiSpecConfig {
3336 readonly spec: string;
3437 readonly baseUrl?: string;
3538 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>;
3841 }
3942
4043 // ---------------------------------------------------------------------------
@@ -105,7 +108,12 @@ export const openApiPlugin = (options?: { · 64 unmodified lines
105108 Effect.gen(function* () {
106109 yield* ctx.tools.registerInvoker(
107110 "openapi",
108− makeOpenApiInvoker(operationStore, httpClientLayer),
111+ makeOpenApiInvoker({
112+ operationStore,
113+ httpClientLayer,
114+ secrets: ctx.secrets,
115+ scopeId: ctx.scope.id,
116+ }),
109117 );
110118
111119 return {
packages/plugins/openapi/src/types.ts+15−2
@@ -94,13 +94,26 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 93 unmodified lines
9494 // Invocation
9595 // ---------------------------------------------------------------------------
9696
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+
97110 export class InvocationConfig extends Schema.Class<InvocationConfig>(
98111 "InvocationConfig",
99112 )({
100113 baseUrl: Schema.String,
101− /** Static headers applied to every request (auth, custom headers, etc.) */
114+ /** Headers applied to every request. Values can reference secrets. */
102115 headers: Schema.optionalWith(
103− Schema.Record({ key: Schema.String, value: Schema.String }),
116+ Schema.Record({ key: Schema.String, value: HeaderValue }),
104117 { default: () => ({}) },
105118 ),
106119 }) {}