History

Bound source detection behavior

Rhys Sullivan committed 6 months ago

Commit 88d361b1 parent c0dbc588 files · +358 −106Browse files

Unified · Split

8 files
  1. packages/core/api/src/handlers/sources.ts +1 −1
  2. packages/core/api/src/sources/api.ts +1 −1
  3. packages/core/sdk/src/executor.test.ts +216 −1
  4. packages/core/sdk/src/executor.ts +93 −97
  5. packages/core/sdk/src/secrets.ts +3 −0
  6. packages/plugins/graphql/src/sdk/introspect.ts +1 −2
  7. packages/plugins/graphql/src/sdk/invoke.ts +12 −3
  8. packages/plugins/graphql/src/sdk/plugin.test.ts +31 −1
packages/core/api/src/handlers/sources.ts+1−1
@@ -76,7 +76,7 @@ export const SourcesHandlers = HttpApiBuilder.group(ExecutorApi, "sources", (han · 75 unmodified lines
7676 capture(
7777 Effect.gen(function* () {
7878 const executor = yield* ExecutorService;
79− const results = yield* executor.sources.detect(payload.url);
79+ const results = yield* executor.sources.detect(payload.url.trim());
8080 return results.map((r) => ({
8181 kind: r.kind,
8282 confidence: r.confidence,
packages/core/api/src/sources/api.ts+1−1
@@ -49,7 +49,7 @@ const ToolMetadataResponse = Schema.Struct({ · 48 unmodified lines
4949 });
5050
5151 const DetectRequest = Schema.Struct({
52− url: Schema.String,
52+ url: Schema.String.check(Schema.isMaxLength(2_048)),
5353 });
5454
5555 const DetectResultResponse = Schema.Struct({
packages/core/sdk/src/executor.test.ts+216−1
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Data, Effect, Exit, Predicate, Result } from "effect";
3+import { FetchHttpClient } from "effect/unstable/http";
34
45 import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";
56 import type { DBAdapter, Where } from "@executor-js/storage-core";
@@ -230,6 +231,75 @@ const opaqueRouteSecretsPlugin = definePlugin(() => ({ · 224 unmodified lines
230231 secretProviders: [opaqueRouteProvider],
231232 }));
232233
234+const providerOnlySecretPlugin = definePlugin(() => ({
235+ id: "provider-only-secret" as const,
236+ storage: () => ({}),
237+ secretProviders: [
238+ {
239+ key: "provider-only",
240+ writable: false,
241+ get: (id: string) => Effect.succeed(id === "provider-token" ? "provider-value" : null),
242+ list: () => Effect.succeed([{ id: "provider-token", name: "Provider token" }]),
243+ } satisfies SecretProvider,
244+ ],
245+}));
246+
247+const fallbackOrderingPlugin = (laterCalls: { count: number }) =>
248+ definePlugin(() => ({
249+ id: "fallback-ordering" as const,
250+ storage: () => ({}),
251+ secretProviders: [
252+ {
253+ key: "first-fallback",
254+ writable: false,
255+ get: (id: string) => Effect.succeed(id === "token" ? "first-value" : null),
256+ list: () => Effect.succeed([{ id: "token", name: "token" }]),
257+ },
258+ {
259+ key: "later-fallback",
260+ writable: false,
261+ get: () =>
262+ Effect.sync(() => {
263+ laterCalls.count += 1;
264+ return "later-value";
265+ }),
266+ list: () => Effect.succeed([{ id: "token", name: "token" }]),
267+ },
268+ ] satisfies readonly SecretProvider[],
269+ }));
270+
271+const fallbackDisabledPlugin = definePlugin(() => ({
272+ id: "fallback-disabled" as const,
273+ storage: () => ({}),
274+ secretProviders: [
275+ {
276+ key: "fallback-disabled-provider",
277+ writable: false,
278+ allowFallback: false,
279+ get: (id: string) => Effect.succeed(id === "token" ? "disabled-value" : null),
280+ list: () => Effect.succeed([{ id: "token", name: "token" }]),
281+ },
282+ ] satisfies readonly SecretProvider[],
283+}));
284+
285+const duplicateToolsPlugin = definePlugin(() => ({
286+ id: "duplicateTools" as const,
287+ storage: () => ({}),
288+ extension: (ctx) => ({
289+ register: () =>
290+ ctx.core.sources.register({
291+ id: "dup-source",
292+ scope: ctx.scopes[0]!.id,
293+ kind: "test",
294+ name: "Duplicate source",
295+ tools: [
296+ { name: "same", description: "first" },
297+ { name: "same", description: "last" },
298+ ],
299+ }),
300+ }),
301+}));
302+
233303 // ---------------------------------------------------------------------------
234304 // Tests
235305 // ---------------------------------------------------------------------------
@@ -310,6 +380,20 @@ describe("createExecutor", () => { · 74 unmodified lines
310380 }),
311381 );
312382
383+ it.effect("bounds annotation resolution groups", () =>
384+ Effect.gen(function* () {
385+ const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
386+ for (let index = 0; index < 70; index++) {
387+ yield* executor.test.addThing(`thing${index}`, "hello");
388+ }
389+ testAnnotationResolveCount = 0;
390+
391+ yield* executor.tools.list();
392+
393+ expect(testAnnotationResolveCount).toBe(64);
394+ }),
395+ );
396+
313397 it.effect("invokes a dynamic tool through plugin.invokeTool", () =>
314398 Effect.gen(function* () {
315399 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
@@ -328,6 +412,7 @@ describe("createExecutor", () => { · 12 unmodified lines
328412 Effect.gen(function* () {
329413 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
330414 yield* executor.test.addThing("thing1", "hello");
415+ let approvalMessage = "";
331416
332417 // requiresApproval: true → declined → ElicitationDeclinedError
333418 const declined = yield* executor.tools
@@ -335,11 +420,16 @@ describe("createExecutor", () => { · 1 unmodified line
335420 "thing1.write",
336421 { value: "updated" },
337422 {
338− onElicitation: () => Effect.succeed(new ElicitationResponse({ action: "decline" })),
423+ onElicitation: (ctx) =>
424+ Effect.sync(() => {
425+ approvalMessage = ctx.request.message;
426+ return new ElicitationResponse({ action: "decline" });
427+ }),
339428 },
340429 )
341430 .pipe(Effect.flip);
342431 expect(Predicate.isTagged(declined, "ElicitationDeclinedError")).toBe(true);
432+ expect(approvalMessage).toContain('"value": "updated"');
343433
344434 // auto-accept → succeeds
345435 const accepted = yield* executor.tools.invoke(
@@ -412,6 +502,75 @@ describe("createExecutor", () => { · 66 unmodified lines
412502 }),
413503 );
414504
505+ it.effect("bounds source detection fan-out and results", () =>
506+ Effect.gen(function* () {
507+ const calls: string[] = [];
508+ const detector = (id: string, confidence: "high" | "medium" | "low") =>
509+ definePlugin(() => ({
510+ id,
511+ storage: () => ({}),
512+ detect: () =>
513+ Effect.sync(() => {
514+ calls.push(id);
515+ return new SourceDetectionResult({
516+ kind: id,
517+ confidence,
518+ endpoint: `https://example.com/${id}`,
519+ name: id,
520+ namespace: id,
521+ });
522+ }),
523+ }));
524+
525+ const executor = yield* createExecutor({
526+ ...makeTestConfig({
527+ plugins: [
528+ detector("first", "low")(),
529+ detector("second", "high")(),
530+ detector("third", "medium")(),
531+ ] as const,
532+ }),
533+ sourceDetection: { maxDetectors: 2, maxResults: 1 },
534+ });
535+
536+ const results = yield* executor.sources.detect("https://example.com/source");
537+
538+ expect(calls).toEqual(["first", "second"]);
539+ expect(results.map((result) => result.kind)).toEqual(["second"]);
540+ }),
541+ );
542+
543+ it.effect("applies hosted outbound policy before source detection plugins run", () =>
544+ Effect.gen(function* () {
545+ let called = false;
546+ const detector = definePlugin(() => ({
547+ id: "detector" as const,
548+ storage: () => ({}),
549+ detect: () =>
550+ Effect.sync(() => {
551+ called = true;
552+ return new SourceDetectionResult({
553+ kind: "detector",
554+ confidence: "high",
555+ endpoint: "http://127.0.0.1/source",
556+ name: "detector",
557+ namespace: "detector",
558+ });
559+ }),
560+ }));
561+
562+ const executor = yield* createExecutor({
563+ ...makeTestConfig({ plugins: [detector()] as const }),
564+ httpClientLayer: FetchHttpClient.layer,
565+ });
566+
567+ const results = yield* executor.sources.detect("http://127.0.0.1/source");
568+
569+ expect(results).toEqual([]);
570+ expect(called).toBe(false);
571+ }),
572+ );
573+
415574 it.effect("rejects remove of a static source", () =>
416575 Effect.gen(function* () {
417576 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
@@ -1267,6 +1426,62 @@ describe("tenant isolation (SDK)", () => { · 849 unmodified lines
12671426 expect(status).toBe("resolved");
12681427 }),
12691428 );
1429+
1430+ it.effect("secrets.list and status only expose routed secret rows", () =>
1431+ Effect.gen(function* () {
1432+ const executor = yield* createExecutor(
1433+ makeTestConfig({ plugins: [providerOnlySecretPlugin()] as const }),
1434+ );
1435+
1436+ const refs = yield* executor.secrets.list();
1437+ const status = yield* executor.secrets.status("provider-token");
1438+ const value = yield* executor.secrets.get("provider-token");
1439+
1440+ expect(refs.map((ref) => ref.id)).not.toContain("provider-token");
1441+ expect(status).toBe("missing");
1442+ expect(value).toBe("provider-value");
1443+ }),
1444+ );
1445+
1446+ it.effect("secrets.get short-circuits provider fallback in registration order", () =>
1447+ Effect.gen(function* () {
1448+ const laterCalls = { count: 0 };
1449+ const executor = yield* createExecutor(
1450+ makeTestConfig({ plugins: [fallbackOrderingPlugin(laterCalls)()] as const }),
1451+ );
1452+
1453+ const value = yield* executor.secrets.get("token");
1454+
1455+ expect(value).toBe("first-value");
1456+ expect(laterCalls.count).toBe(0);
1457+ }),
1458+ );
1459+
1460+ it.effect("secrets.get skips providers that disable fallback lookup", () =>
1461+ Effect.gen(function* () {
1462+ const executor = yield* createExecutor(
1463+ makeTestConfig({ plugins: [fallbackDisabledPlugin()] as const }),
1464+ );
1465+
1466+ const value = yield* executor.secrets.get("token");
1467+
1468+ expect(value).toBeNull();
1469+ }),
1470+ );
1471+
1472+ it.effect("source registration deduplicates duplicate tool ids before bulk insert", () =>
1473+ Effect.gen(function* () {
1474+ const executor = yield* createExecutor(
1475+ makeTestConfig({ plugins: [duplicateToolsPlugin()] as const }),
1476+ );
1477+
1478+ yield* executor.duplicateTools.register();
1479+
1480+ const tools = yield* executor.tools.list();
1481+ expect(tools.filter((tool) => tool.id === "dup-source.same")).toHaveLength(1);
1482+ expect(tools.find((tool) => tool.id === "dup-source.same")?.description).toBe("last");
1483+ }),
1484+ );
12701485 });
12711486
12721487 // ---------------------------------------------------------------------------
packages/core/sdk/src/executor.ts+93−97
@@ -1,4 +1,14 @@
1−import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";
1+import {
2+ Context,
3+ Deferred,
4+ Duration,
5+ Effect,
6+ Layer,
7+ Option,
8+ Result,
9+ Schema,
10+ Semaphore,
11+} from "effect";
212 import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
313 import type { OAuthEndpointUrlPolicy } from "./oauth-helpers";
414 import { generateKeyBetween } from "fractional-indexing";
@@ -114,6 +124,10 @@ import { · 109 unmodified lines
114124 type ScopeContext,
115125 type ScopedDBAdapter,
116126 } from "./scoped-adapter";
127+import { validateHostedOutboundUrl } from "./hosted-http-client";
128+
129+const MAX_ANNOTATION_GROUPS = 64;
130+const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
117131
118132 // ---------------------------------------------------------------------------
119133 // Elicitation handler — set once at `createExecutor({ onElicitation })`
@@ -345,6 +359,13 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 225 unmodified lines
345359 readonly onElicitation: OnElicitation;
346360 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
347361 readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;
362+ readonly sourceDetection?: {
363+ readonly maxUrlLength?: number;
364+ readonly maxDetectors?: number;
365+ readonly maxResults?: number;
366+ readonly timeout?: Duration.Input;
367+ readonly hostedOutboundPolicy?: boolean;
368+ };
348369 }
349370
350371 // ---------------------------------------------------------------------------
@@ -476,11 +497,17 @@ const writeSourceInput = ( · 125 unmodified lines
476497 forceAllowId: true,
477498 });
478499
479− if (input.tools.length > 0) {
500+ const toolsById = new Map<string, (typeof input.tools)[number]>();
501+ for (const tool of input.tools) {
502+ toolsById.set(`${input.id}.${tool.name}`, tool);
503+ }
504+ const tools = [...toolsById.entries()];
505+
506+ if (tools.length > 0) {
480507 yield* core.createMany({
481508 model: "tool",
482− data: input.tools.map((tool) => ({
483− id: `${input.id}.${tool.name}`,
509+ data: tools.map(([id, tool]) => ({
510+ id,
484511 scope_id: input.scope,
485512 source_id: input.id,
486513 plugin_id: pluginId,
@@ -599,6 +626,13 @@ const activeRawAdapterRef = Context.Reference<DBTransactionAdapter | null>( · 112 unmodified lines
599626 },
600627 );
601628
629+const approvalArgumentPreview = (args: unknown): string => {
630+ const text = JSON.stringify(args ?? {}, null, 2) ?? "null";
631+ return text.length > MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS
632+ ? `${text.slice(0, MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS)}...`
633+ : text;
634+};
635+
602636 // A `DBAdapter` whose methods dispatch to the active adapter (tx handle or
603637 // root) on every call. Stable identity for consumers (plugin storage,
604638 // `typedAdapter`, etc.) — they see one adapter object, but the routing is
@@ -846,22 +880,23 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 241 unmodified lines
846880 if (value !== null) return value;
847881 }
848882
849− // Fallback: ask every enumerating provider in parallel. First
850− // non-null in registration order wins. Providers that throw
883+ // Fallback: ask enumerating providers in registration order. First
884+ // non-null wins. Providers that throw
851885 // are treated as "don't have it" so one flaky provider can't
852886 // block resolution via others. Scope-partitioning providers
853887 // get asked at the innermost scope as a display default — the
854888 // enumeration fallback doesn't know which scope the value
855889 // lives in; flat providers ignore the arg.
856890 const fallbackScope = scopeIds[0]!;
857− const candidates = [...secretProviders.values()].filter((p) => p.list);
858− const values = yield* Effect.all(
859− candidates.map((p) =>
860− p.get(id, fallbackScope).pipe(Effect.catch(() => Effect.succeed(null))),
861− ),
862− { concurrency: "unbounded" },
891+ const candidates = [...secretProviders.values()].filter(
892+ (p) => p.list && p.allowFallback !== false,
863893 );
864− for (const value of values) if (value !== null) return value;
894+ for (const provider of candidates) {
895+ const value = yield* provider
896+ .get(id, fallbackScope)
897+ .pipe(Effect.catch(() => Effect.succeed(null)));
898+ if (value !== null) return value;
899+ }
865900 return null;
866901 });
867902
@@ -1253,9 +1288,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 385 unmodified lines
12531288 // deny-set so provider `list()` results for the same id can't
12541289 // leak them back in below.
12551290 const allRows = yield* core.findMany({ model: "secret" });
1256− const connectionOwnedIds = new Set(
1257− allRows.filter((r) => r.owned_by_connection_id).map((r) => r.id),
1258− );
12591291 const rows = allRows.filter((r) => !r.owned_by_connection_id);
12601292 const pick = (row: (typeof rows)[number]) => {
12611293 const existing = byId.get(row.id);
@@ -1281,48 +1313,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 19 unmodified lines
12811313 if (hasBackingValue) pick(row);
12821314 }
12831315
1284− // Then every provider that can enumerate itself, in parallel.
1285− // If a provider fails to list (unlocked vault, network error),
1286− // swallow the failure so one flaky provider can't block the
1287− // whole list. Merge in registration order afterwards so the
1288− // "first provider wins" precedence stays deterministic.
1289− const attribution = scopes[0]!.id;
1290− const listers = [...secretProviders.entries()].filter(([, p]) => p.list);
1291− const lists = yield* Effect.all(
1292− listers.map(([key, p]) =>
1293− p.list!().pipe(
1294− Effect.catch(() => Effect.succeed([] as const)),
1295− Effect.map((entries) => ({ key, entries })),
1296− ),
1297− ),
1298− { concurrency: "unbounded" },
1299− );
1300− for (const { key, entries } of lists) {
1301− for (const entry of entries) {
1302− if (byId.has(entry.id)) continue; // core row wins
1303− if (connectionOwnedIds.has(entry.id)) continue; // hidden by connection
1304− byId.set(
1305− entry.id,
1306− new SecretRef({
1307− id: SecretId.make(entry.id),
1308− scopeId: attribution,
1309− name: entry.name,
1310− provider: key,
1311− createdAt: new Date(),
1312− }),
1313− );
1314− }
1315− }
1316−
13171316 return Array.from(byId.values());
13181317 });
13191318
13201319 const secretsListAll = (): Effect.Effect<readonly SecretRef[], StorageFailure> =>
13211320 Effect.gen(function* () {
13221321 const allRows = yield* core.findMany({ model: "secret" });
1323− const connectionOwnedIds = new Set(
1324− allRows.filter((r) => r.owned_by_connection_id).map((r) => r.id),
1325− );
13261322 const coreIds = new Set<string>();
13271323 const refs: SecretRef[] = [];
13281324
@@ -1342,33 +1338,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 13 unmodified lines
13421338 );
13431339 }
13441340
1345− const attribution = scopes[0]!.id;
1346− const listers = [...secretProviders.entries()].filter(([, p]) => p.list);
1347− const lists = yield* Effect.all(
1348− listers.map(([key, p]) =>
1349− p.list!().pipe(
1350− Effect.catch(() => Effect.succeed([] as const)),
1351− Effect.map((entries) => ({ key, entries })),
1352− ),
1353− ),
1354− { concurrency: "unbounded" },
1355− );
1356− for (const { key, entries } of lists) {
1357− for (const entry of entries) {
1358− if (coreIds.has(entry.id)) continue;
1359− if (connectionOwnedIds.has(entry.id)) continue;
1360− refs.push(
1361− new SecretRef({
1362− id: SecretId.make(entry.id),
1363− scopeId: attribution,
1364− name: entry.name,
1365− provider: key,
1366− createdAt: new Date(),
1367− }),
1368− );
1369− }
1370− }
1371−
13721341 return refs.sort((a, b) => {
13731342 const rank =
13741343 (scopePrecedence.get(a.scopeId) ?? Infinity) -
@@ -2791,7 +2760,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 1416 unmodified lines
27912760 // ~200-300ms storage round-trips end-to-end and dominates the
27922761 // `executor.tools.list.annotations` span.
27932762 const maps = yield* Effect.forEach(
2794− [...groups],
2763+ [...groups].slice(0, MAX_ANNOTATION_GROUPS),
27952764 ([key, groupRows]) =>
27962765 Effect.gen(function* () {
27972766 const [pluginId, sourceId] = key.split("\u0000") as [string, string];
@@ -3110,8 +3079,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 312 unmodified lines
31103079 ? `Approve ${toolId}? (matched policy: ${policy.pattern})`
31113080 : `Approve ${toolId}?`;
31123081 const request = new FormElicitation({
3113− message,
3114− requestedSchema: {},
3082+ message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`,
3083+ requestedSchema: {
3084+ type: "object",
3085+ properties: {},
3086+ },
31153087 });
31163088 const response = yield* handler({ toolId: tid, args, request });
31173089 if (response.action !== "accept") {
@@ -3312,10 +3284,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 194 unmodified lines
33123284 }
33133285 });
33143286
3315− // URL autodetection — fan out across every plugin that declared a
3316− // `detect` hook. Collect all non-null results. Plugin-level detect
3317− // implementations should swallow fetch errors and return null, so
3318− // one flaky plugin doesn't block the whole dispatch.
3287+ const sourceDetectionMaxUrlLength = config.sourceDetection?.maxUrlLength ?? 2_048;
3288+ const sourceDetectionMaxDetectors = config.sourceDetection?.maxDetectors ?? 6;
3289+ const sourceDetectionMaxResults = config.sourceDetection?.maxResults ?? 4;
3290+ const sourceDetectionTimeout = config.sourceDetection?.timeout ?? "5 seconds";
3291+ const sourceDetectionHostedOutboundPolicy =
3292+ config.sourceDetection?.hostedOutboundPolicy ?? config.httpClientLayer !== undefined;
3293+
3294+ // URL autodetection — fan out across a bounded set of plugins that
3295+ // declared a `detect` hook. Collect non-null results up to the
3296+ // configured cap. Plugin-level detect implementations should
3297+ // swallow fetch errors and return null, so one flaky plugin doesn't
3298+ // block the whole dispatch.
33193299 const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => {
33203300 switch (confidence) {
33213301 case "high":
@@ -3329,17 +3309,40 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 7 unmodified lines
33293309
33303310 const detectSource = (url: string) =>
33313311 Effect.gen(function* () {
3312+ const trimmed = url.trim();
3313+ if (trimmed.length === 0 || trimmed.length > sourceDetectionMaxUrlLength) return [];
3314+ const parsed = yield* Effect.try({
3315+ try: () => new URL(trimmed),
3316+ catch: (error) => error,
3317+ }).pipe(Effect.option);
3318+ if (Option.isNone(parsed)) return [];
3319+ if (parsed.value.protocol !== "http:" && parsed.value.protocol !== "https:") return [];
3320+ if (sourceDetectionHostedOutboundPolicy) {
3321+ const allowed = yield* validateHostedOutboundUrl(trimmed).pipe(
3322+ Effect.as(true),
3323+ Effect.catch(() => Effect.succeed(false)),
3324+ );
3325+ if (!allowed) return [];
3326+ }
3327+
33323328 const results: SourceDetectionResult[] = [];
3329+ let detectorCount = 0;
33333330 for (const runtime of runtimes.values()) {
33343331 if (!runtime.plugin.detect) continue;
3332+ if (detectorCount >= sourceDetectionMaxDetectors) break;
3333+ detectorCount++;
33353334 const result = yield* runtime.plugin
3336− .detect({ ctx: runtime.ctx, url })
3335+ .detect({ ctx: runtime.ctx, url: trimmed })
3336+ .pipe(Effect.timeout(sourceDetectionTimeout))
33373337 .pipe(Effect.catch(() => Effect.succeed(null)));
33383338 if (result) results.push(result);
33393339 }
3340− return results.sort(
3341− (a, b) => detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence),
3342− );
3340+ return results
3341+ .sort(
3342+ (a, b) =>
3343+ detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence),
3344+ )
3345+ .slice(0, sourceDetectionMaxResults);
33433346 });
33443347
33453348 // Per-source definitions accessor — one query, one mapping pass.
@@ -3358,13 +3361,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 12 unmodified lines
33583361 if (yield* secretRouteHasBackingValue(row)) return "resolved";
33593362 }
33603363
3361− for (const provider of secretProviders.values()) {
3362− if (!provider.list) continue;
3363− const entries = yield* provider
3364− .list()
3365− .pipe(Effect.catch(() => Effect.succeed([] as const)));
3366− if (entries.some((e) => e.id === id)) return "resolved";
3367− }
33683364 return "missing";
33693365 });
33703366
packages/core/sdk/src/secrets.ts+3−0
@@ -48,6 +48,9 @@ export interface SecretProvider { · 47 unmodified lines
4848 readonly { readonly id: string; readonly name: string }[],
4949 StorageFailure
5050 >;
51+ /** If false, `get` only runs for routed secret rows owned by this
52+ * provider and is skipped by provider-enumeration fallback lookup. */
53+ readonly allowFallback?: boolean;
5154 }
5255
5356 // ---------------------------------------------------------------------------
packages/plugins/graphql/src/sdk/introspect.ts+1−2
@@ -220,9 +220,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 219 unmodified lines
220220 );
221221
222222 if (response.status !== 200) {
223− const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("<unreadable>")));
224223 return yield* new GraphqlIntrospectionError({
225− message: `Introspection failed with status ${response.status}: ${body.slice(0, 1_000)}`,
224+ message: `Introspection failed with status ${response.status}`,
226225 });
227226 }
228227
packages/plugins/graphql/src/sdk/invoke.ts+12−3
@@ -48,6 +48,14 @@ const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, s · 47 unmodified lines
4848 return url.toString();
4949 };
5050
51+export const endpointForTelemetry = (endpoint: string): string => {
52+ if (!URL.canParse(endpoint)) return endpoint;
53+ const url = new URL(endpoint);
54+ url.search = "";
55+ url.hash = "";
56+ return url.toString();
57+};
58+
5159 // ---------------------------------------------------------------------------
5260 // Response helpers
5361 // ---------------------------------------------------------------------------
@@ -73,11 +81,12 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( · 19 unmodified lines
7381 ) {
7482 const client = yield* HttpClient.HttpClient;
7583 const requestEndpoint = endpointWithQueryParams(endpoint, resolvedQueryParams);
84+ const telemetryEndpoint = endpointForTelemetry(endpoint);
7685
7786 yield* Effect.annotateCurrentSpan({
7887 "http.method": "POST",
79− "http.url": requestEndpoint,
80− "plugin.graphql.endpoint": endpoint,
88+ "http.url": telemetryEndpoint,
89+ "plugin.graphql.endpoint": telemetryEndpoint,
8190 "plugin.graphql.operation_kind": operation.kind,
8291 "plugin.graphql.field_name": operation.fieldName,
8392 "plugin.graphql.headers.resolved_count": Object.keys(resolvedHeaders).length,
@@ -160,7 +169,7 @@ export const invokeWithLayer = ( · 76 unmodified lines
160169 Effect.provide(httpClientLayer),
161170 Effect.withSpan("plugin.graphql.invoke", {
162171 attributes: {
163− "plugin.graphql.endpoint": endpoint,
172+ "plugin.graphql.endpoint": endpointForTelemetry(endpoint),
164173 "plugin.graphql.operation_kind": operation.kind,
165174 "plugin.graphql.field_name": operation.fieldName,
166175 },
packages/plugins/graphql/src/sdk/plugin.test.ts+31−1
@@ -1,5 +1,6 @@
11 import { describe, it, expect } from "@effect/vitest";
22 import { Effect, Predicate } from "effect";
3+import { HttpServerResponse } from "effect/unstable/http";
34
45 import {
56 ConnectionId,
@@ -14,9 +15,11 @@ import { · 8 unmodified lines
1415 SecretId,
1516 TokenMaterial,
1617 } from "@executor-js/sdk";
17−import { memorySecretsPlugin } from "@executor-js/sdk/testing";
18+import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing";
1819
1920 import { graphqlPlugin } from "./plugin";
21+import { endpointForTelemetry } from "./invoke";
22+import { introspect } from "./introspect";
2023 import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types";
2124 import type { IntrospectionResult } from "./introspect";
2225 import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
@@ -121,6 +124,33 @@ const sampleDataPlugin = definePlugin(() => ({ · 98 unmodified lines
121124 }));
122125
123126 describe("graphqlPlugin real protocol server", () => {
127+ it("uses query-free endpoints for invocation attributes", () => {
128+ expect(endpointForTelemetry("https://api.example.test/graphql?token=secret#section")).toBe(
129+ "https://api.example.test/graphql",
130+ );
131+ });
132+
133+ it.effect("does not include upstream response bodies in introspection status errors", () =>
134+ Effect.gen(function* () {
135+ const server = yield* serveTestHttpApp(() =>
136+ Effect.succeed(
137+ HttpServerResponse.text("internal token value", {
138+ status: 500,
139+ contentType: "text/plain",
140+ }),
141+ ),
142+ );
143+
144+ const error = yield* introspect(server.url("/graphql")).pipe(
145+ Effect.provide(server.httpClientLayer),
146+ Effect.flip,
147+ );
148+
149+ expect(error).toHaveProperty("message", "Introspection failed with status 500");
150+ expect(error).not.toHaveProperty("message", expect.stringContaining("internal token value"));
151+ }),
152+ );
153+
124154 it.effect("adds a source by introspecting the live GraphQL endpoint", () =>
125155 Effect.gen(function* () {
126156 const server = yield* serveGreetingServer;