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
23 23
24export class SecretResolutionError extends Schema.TaggedError<SecretResolutionError>()(24export 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
29export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()(32export class PolicyDeniedError extends Schema.TaggedError<PolicyDeniedError>()(
packages/core/sdk/src/executor.ts+25−9
@@ -1,7 +1,7 @@
1import { Context, Effect } from "effect";1import { Context, Effect } from "effect";
2 2
3import type { ToolId, SecretId, PolicyId } from "./ids";3import type { ToolId, SecretId, PolicyId } from "./ids";
4import type { Secret, SecretStore } from "./secrets";4import type { SecretRef, SecretStore } from "./secrets";
5import type {5import 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";
25import type { ElicitationDeclinedError } from "./elicitation";26import 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: () =>
packages/core/sdk/src/in-memory/secret-store.ts+115−25
@@ -1,50 +1,140 @@
1import { Effect } from "effect";1import { Effect, Option } from "effect";
2 2
3import { ScopeId, SecretId } from "../ids";3import { ScopeId, SecretId } from "../ids";
4import { SecretNotFoundError } from "../errors";4import { SecretNotFoundError, SecretResolutionError } from "../errors";
5import type { Secret } from "../secrets";5import type { SecretRef, SecretProvider } from "../secrets";
6
7// ---------------------------------------------------------------------------
8// In-memory secret provider
9// ---------------------------------------------------------------------------
10
11export 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
7export const makeInMemorySecretStore = () => {27export 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};
packages/core/sdk/src/index.test.ts+24−13
@@ -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 secrets430 // 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 value440 // 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 secret457 // 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",
packages/core/sdk/src/index.ts+2−2
@@ -34,7 +34,7 @@ export { · 33 unmodified lines
34} from "./elicitation";34} from "./elicitation";
35 35
36// Secrets36// Secrets
37export { Secret, SecretStore } from "./secrets";37export { SecretRef, SecretStore, type SecretProvider } from "./secrets";
38 38
39// Policies39// Policies
40export { Policy, PolicyAction, PolicyEngine } from "./policies";40export { Policy, PolicyAction, PolicyEngine } from "./policies";
@@ -73,7 +73,7 @@ export { hoistDefinitions, collectRefs, reattachDefs } from "./schema-refs"; · 32 unmodified lines
73 73
74// In-memory implementations74// In-memory implementations
75export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";75export { makeInMemoryToolRegistry } from "./in-memory/tool-registry";
76export { makeInMemorySecretStore } from "./in-memory/secret-store";76export { makeInMemorySecretStore, makeInMemorySecretProvider } from "./in-memory/secret-store";
77export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine";77export { makeInMemoryPolicyEngine } from "./in-memory/policy-engine";
78 78
79// Testing79// 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
3import type { SecretId } from "../ids";3import type { SecretId } from "../ids";
4import { ToolId } from "../ids";4import { ToolId } from "../ids";
5import { ToolInvocationError } from "../errors";5import { ToolInvocationError } from "../errors";
6import type { Secret } from "../secrets";6import type { SecretRef } from "../secrets";
7import {7import {
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 */
54export interface MemoryToolSdkAccess {54export 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 }),
packages/core/sdk/src/secrets.ts+71−5
@@ -3,32 +3,98 @@ import { Context, Effect, Schema } from "effect"; · 2 unmodified lines
3import { SecretId, ScopeId } from "./ids";3import { SecretId, ScopeId } from "./ids";
4import { SecretNotFoundError, SecretResolutionError } from "./errors";4import { SecretNotFoundError, SecretResolutionError } from "./errors";
5 5
6export class Secret extends Schema.Class<Secret>("Secret")({6// ---------------------------------------------------------------------------
7// SecretProvider — adapter interface for secret backends
8// ---------------------------------------------------------------------------
9
10export 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
29export 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
14export class SecretStore extends Context.Tag("@executor/sdk/SecretStore")<45export 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>() {}
packages/plugins/openapi/src/invoke.ts+68−19
@@ -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
14import { OpenApiInvocationError } from "./errors";16import { OpenApiInvocationError } from "./errors";
15import type { OpenApiOperationStore } from "./operation-store";17import type { OpenApiOperationStore } from "./operation-store";
16import {18import {
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 application122// Header resolution — resolves secret refs at invocation time
120// ---------------------------------------------------------------------------123// ---------------------------------------------------------------------------
121 124
125const 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
122const applyHeaders = (151const 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 // Execute241 // 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 invoker278// ToolInvoker — bridges operation store + HTTP client into SDK invoker
248// ---------------------------------------------------------------------------279// ---------------------------------------------------------------------------
249 280
250export const makeOpenApiInvoker = (281export 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 = baseUrl308 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 ? err335 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});
packages/plugins/openapi/src/plugin.test.ts+96−1
@@ -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";
11import { NodeHttpServer } from "@effect/platform-node";12import { NodeHttpServer } from "@effect/platform-node";
12 13
13import { createExecutor, makeTestConfig } from "@executor/sdk";14import { createExecutor, makeTestConfig, SecretId } from "@executor/sdk";
14import { openApiPlugin } from "./plugin";15import { 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
26class EchoHeaders extends Schema.Class<EchoHeaders>("EchoHeaders")({
27 authorization: Schema.optional(Schema.String),
28 "x-static": Schema.optional(Schema.String),
29}) {}
30
25const ItemsGroup = HttpApiGroup.make("items")31const 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
35const TestApi = HttpApi.make("testApi").add(ItemsGroup);44const 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;
packages/plugins/openapi/src/plugin.ts+11−3
@@ -29,12 +29,15 @@ import { · 28 unmodified lines
29// Plugin config29// Plugin config
30// ---------------------------------------------------------------------------30// ---------------------------------------------------------------------------
31 31
32/** A header value — either a static string or a reference to a secret */
33export type HeaderValue = string | { readonly secretId: string; readonly prefix?: string };
34
32export interface OpenApiSpecConfig {35export 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 {
packages/plugins/openapi/src/types.ts+15−2
@@ -94,13 +94,26 @@ export class OperationBinding extends Schema.Class<OperationBinding>( · 93 unmodified lines
94// Invocation94// 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 */
101export const HeaderValue = Schema.Union(
102 Schema.String,
103 Schema.Struct({
104 secretId: Schema.String,
105 prefix: Schema.optional(Schema.String),
106 }),
107);
108export type HeaderValue = typeof HeaderValue.Type;
109
97export class InvocationConfig extends Schema.Class<InvocationConfig>(110export 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}) {}