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
76 capture(76 capture(
77 Effect.gen(function* () {77 Effect.gen(function* () {
78 const executor = yield* ExecutorService;78 const executor = yield* ExecutorService;
79 const results = yield* executor.sources.detect(payload.url);79 const results = yield* executor.sources.detect(payload.url.trim());
80 return results.map((r) => ({80 return results.map((r) => ({
81 kind: r.kind,81 kind: r.kind,
82 confidence: r.confidence,82 confidence: r.confidence,
packages/core/api/src/sources/api.ts+1−1
@@ -49,7 +49,7 @@ const ToolMetadataResponse = Schema.Struct({ · 48 unmodified lines
49});49});
50 50
51const DetectRequest = Schema.Struct({51const DetectRequest = Schema.Struct({
52 url: Schema.String,52 url: Schema.String.check(Schema.isMaxLength(2_048)),
53});53});
54 54
55const DetectResultResponse = Schema.Struct({55const DetectResultResponse = Schema.Struct({
packages/core/sdk/src/executor.test.ts+216−1
@@ -1,5 +1,6 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Data, Effect, Exit, Predicate, Result } from "effect";2import { Data, Effect, Exit, Predicate, Result } from "effect";
3import { FetchHttpClient } from "effect/unstable/http";
3 4
4import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";5import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";
5import type { DBAdapter, Where } from "@executor-js/storage-core";6import type { DBAdapter, Where } from "@executor-js/storage-core";
@@ -230,6 +231,75 @@ const opaqueRouteSecretsPlugin = definePlugin(() => ({ · 224 unmodified lines
230 secretProviders: [opaqueRouteProvider],231 secretProviders: [opaqueRouteProvider],
231}));232}));
232 233
234const 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
247const 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
271const 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
285const 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
233// ---------------------------------------------------------------------------303// ---------------------------------------------------------------------------
234// Tests304// Tests
235// ---------------------------------------------------------------------------305// ---------------------------------------------------------------------------
@@ -310,6 +380,20 @@ describe("createExecutor", () => { · 74 unmodified lines
310 }),380 }),
311 );381 );
312 382
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
313 it.effect("invokes a dynamic tool through plugin.invokeTool", () =>397 it.effect("invokes a dynamic tool through plugin.invokeTool", () =>
314 Effect.gen(function* () {398 Effect.gen(function* () {
315 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));399 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
@@ -328,6 +412,7 @@ describe("createExecutor", () => { · 12 unmodified lines
328 Effect.gen(function* () {412 Effect.gen(function* () {
329 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));413 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
330 yield* executor.test.addThing("thing1", "hello");414 yield* executor.test.addThing("thing1", "hello");
415 let approvalMessage = "";
331 416
332 // requiresApproval: true → declined → ElicitationDeclinedError417 // requiresApproval: true → declined → ElicitationDeclinedError
333 const declined = yield* executor.tools418 const declined = yield* executor.tools
@@ -335,11 +420,16 @@ describe("createExecutor", () => { · 1 unmodified line
335 "thing1.write",420 "thing1.write",
336 { value: "updated" },421 { value: "updated" },
337 {422 {
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 }),
339 },428 },
340 )429 )
341 .pipe(Effect.flip);430 .pipe(Effect.flip);
342 expect(Predicate.isTagged(declined, "ElicitationDeclinedError")).toBe(true);431 expect(Predicate.isTagged(declined, "ElicitationDeclinedError")).toBe(true);
432 expect(approvalMessage).toContain('"value": "updated"');
343 433
344 // auto-accept → succeeds434 // auto-accept → succeeds
345 const accepted = yield* executor.tools.invoke(435 const accepted = yield* executor.tools.invoke(
@@ -412,6 +502,75 @@ describe("createExecutor", () => { · 66 unmodified lines
412 }),502 }),
413 );503 );
414 504
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
415 it.effect("rejects remove of a static source", () =>574 it.effect("rejects remove of a static source", () =>
416 Effect.gen(function* () {575 Effect.gen(function* () {
417 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));576 const executor = yield* createExecutor(makeTestConfig({ plugins: [testPlugin()] as const }));
@@ -1267,6 +1426,62 @@ describe("tenant isolation (SDK)", () => { · 849 unmodified lines
1267 expect(status).toBe("resolved");1426 expect(status).toBe("resolved");
1268 }),1427 }),
1269 );1428 );
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 );
1270});1485});
1271 1486
1272// ---------------------------------------------------------------------------1487// ---------------------------------------------------------------------------
packages/core/sdk/src/executor.ts+93−97
@@ -1,4 +1,14 @@
1import { Context, Deferred, Effect, Layer, Option, Result, Schema, Semaphore } from "effect";1import {
2 Context,
3 Deferred,
4 Duration,
5 Effect,
6 Layer,
7 Option,
8 Result,
9 Schema,
10 Semaphore,
11} from "effect";
2import { FetchHttpClient, type HttpClient } from "effect/unstable/http";12import { FetchHttpClient, type HttpClient } from "effect/unstable/http";
3import type { OAuthEndpointUrlPolicy } from "./oauth-helpers";13import type { OAuthEndpointUrlPolicy } from "./oauth-helpers";
4import { generateKeyBetween } from "fractional-indexing";14import { generateKeyBetween } from "fractional-indexing";
@@ -114,6 +124,10 @@ import { · 109 unmodified lines
114 type ScopeContext,124 type ScopeContext,
115 type ScopedDBAdapter,125 type ScopedDBAdapter,
116} from "./scoped-adapter";126} from "./scoped-adapter";
127import { validateHostedOutboundUrl } from "./hosted-http-client";
128
129const MAX_ANNOTATION_GROUPS = 64;
130const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
117 131
118// ---------------------------------------------------------------------------132// ---------------------------------------------------------------------------
119// Elicitation handler — set once at `createExecutor({ onElicitation })`133// Elicitation handler — set once at `createExecutor({ onElicitation })`
@@ -345,6 +359,13 @@ export interface ExecutorConfig<TPlugins extends readonly AnyPlugin[] = []> { · 225 unmodified lines
345 readonly onElicitation: OnElicitation;359 readonly onElicitation: OnElicitation;
346 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;360 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
347 readonly oauthEndpointUrlPolicy?: OAuthEndpointUrlPolicy;361 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 };
348}369}
349 370
350// ---------------------------------------------------------------------------371// ---------------------------------------------------------------------------
@@ -476,11 +497,17 @@ const writeSourceInput = ( · 125 unmodified lines
476 forceAllowId: true,497 forceAllowId: true,
477 });498 });
478 499
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) {
480 yield* core.createMany({507 yield* core.createMany({
481 model: "tool",508 model: "tool",
482 data: input.tools.map((tool) => ({509 data: tools.map(([id, tool]) => ({
483 id: `${input.id}.${tool.name}`,510 id,
484 scope_id: input.scope,511 scope_id: input.scope,
485 source_id: input.id,512 source_id: input.id,
486 plugin_id: pluginId,513 plugin_id: pluginId,
@@ -599,6 +626,13 @@ const activeRawAdapterRef = Context.Reference<DBTransactionAdapter | null>( · 112 unmodified lines
599 },626 },
600);627);
601 628
629const 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
602// A `DBAdapter` whose methods dispatch to the active adapter (tx handle or636// A `DBAdapter` whose methods dispatch to the active adapter (tx handle or
603// root) on every call. Stable identity for consumers (plugin storage,637// root) on every call. Stable identity for consumers (plugin storage,
604// `typedAdapter`, etc.) — they see one adapter object, but the routing is638// `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
846 if (value !== null) return value;880 if (value !== null) return value;
847 }881 }
848 882
849 // Fallback: ask every enumerating provider in parallel. First883 // Fallback: ask enumerating providers in registration order. First
850 // non-null in registration order wins. Providers that throw884 // non-null wins. Providers that throw
851 // are treated as "don't have it" so one flaky provider can't885 // are treated as "don't have it" so one flaky provider can't
852 // block resolution via others. Scope-partitioning providers886 // block resolution via others. Scope-partitioning providers
853 // get asked at the innermost scope as a display default — the887 // get asked at the innermost scope as a display default — the
854 // enumeration fallback doesn't know which scope the value888 // enumeration fallback doesn't know which scope the value
855 // lives in; flat providers ignore the arg.889 // lives in; flat providers ignore the arg.
856 const fallbackScope = scopeIds[0]!;890 const fallbackScope = scopeIds[0]!;
857 const candidates = [...secretProviders.values()].filter((p) => p.list);891 const candidates = [...secretProviders.values()].filter(
858 const values = yield* Effect.all(892 (p) => p.list && p.allowFallback !== false,
859 candidates.map((p) =>
860 p.get(id, fallbackScope).pipe(Effect.catch(() => Effect.succeed(null))),
861 ),
862 { concurrency: "unbounded" },
863 );893 );
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 }
865 return null;900 return null;
866 });901 });
867 902
@@ -1253,9 +1288,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 385 unmodified lines
1253 // deny-set so provider `list()` results for the same id can't1288 // deny-set so provider `list()` results for the same id can't
1254 // leak them back in below.1289 // leak them back in below.
1255 const allRows = yield* core.findMany({ model: "secret" });1290 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 );
1259 const rows = allRows.filter((r) => !r.owned_by_connection_id);1291 const rows = allRows.filter((r) => !r.owned_by_connection_id);
1260 const pick = (row: (typeof rows)[number]) => {1292 const pick = (row: (typeof rows)[number]) => {
1261 const existing = byId.get(row.id);1293 const existing = byId.get(row.id);
@@ -1281,48 +1313,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 19 unmodified lines
1281 if (hasBackingValue) pick(row);1313 if (hasBackingValue) pick(row);
1282 }1314 }
1283 1315
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
1317 return Array.from(byId.values());1316 return Array.from(byId.values());
1318 });1317 });
1319 1318
1320 const secretsListAll = (): Effect.Effect<readonly SecretRef[], StorageFailure> =>1319 const secretsListAll = (): Effect.Effect<readonly SecretRef[], StorageFailure> =>
1321 Effect.gen(function* () {1320 Effect.gen(function* () {
1322 const allRows = yield* core.findMany({ model: "secret" });1321 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 );
1326 const coreIds = new Set<string>();1322 const coreIds = new Set<string>();
1327 const refs: SecretRef[] = [];1323 const refs: SecretRef[] = [];
1328 1324
@@ -1342,33 +1338,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 13 unmodified lines
1342 );1338 );
1343 }1339 }
1344 1340
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
1372 return refs.sort((a, b) => {1341 return refs.sort((a, b) => {
1373 const rank =1342 const rank =
1374 (scopePrecedence.get(a.scopeId) ?? Infinity) -1343 (scopePrecedence.get(a.scopeId) ?? Infinity) -
@@ -2791,7 +2760,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 1416 unmodified lines
2791 // ~200-300ms storage round-trips end-to-end and dominates the2760 // ~200-300ms storage round-trips end-to-end and dominates the
2792 // `executor.tools.list.annotations` span.2761 // `executor.tools.list.annotations` span.
2793 const maps = yield* Effect.forEach(2762 const maps = yield* Effect.forEach(
2794 [...groups],2763 [...groups].slice(0, MAX_ANNOTATION_GROUPS),
2795 ([key, groupRows]) =>2764 ([key, groupRows]) =>
2796 Effect.gen(function* () {2765 Effect.gen(function* () {
2797 const [pluginId, sourceId] = key.split("\u0000") as [string, string];2766 const [pluginId, sourceId] = key.split("\u0000") as [string, string];
@@ -3110,8 +3079,11 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 312 unmodified lines
3110 ? `Approve ${toolId}? (matched policy: ${policy.pattern})`3079 ? `Approve ${toolId}? (matched policy: ${policy.pattern})`
3111 : `Approve ${toolId}?`;3080 : `Approve ${toolId}?`;
3112 const request = new FormElicitation({3081 const request = new FormElicitation({
3113 message,3082 message: `${message}\n\nArguments:\n${approvalArgumentPreview(args)}`,
3114 requestedSchema: {},3083 requestedSchema: {
3084 type: "object",
3085 properties: {},
3086 },
3115 });3087 });
3116 const response = yield* handler({ toolId: tid, args, request });3088 const response = yield* handler({ toolId: tid, args, request });
3117 if (response.action !== "accept") {3089 if (response.action !== "accept") {
@@ -3312,10 +3284,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 194 unmodified lines
3312 }3284 }
3313 });3285 });
3314 3286
3315 // URL autodetection — fan out across every plugin that declared a3287 const sourceDetectionMaxUrlLength = config.sourceDetection?.maxUrlLength ?? 2_048;
3316 // `detect` hook. Collect all non-null results. Plugin-level detect3288 const sourceDetectionMaxDetectors = config.sourceDetection?.maxDetectors ?? 6;
3317 // implementations should swallow fetch errors and return null, so3289 const sourceDetectionMaxResults = config.sourceDetection?.maxResults ?? 4;
3318 // one flaky plugin doesn't block the whole dispatch.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.
3319 const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => {3299 const detectionConfidenceScore = (confidence: SourceDetectionResult["confidence"]) => {
3320 switch (confidence) {3300 switch (confidence) {
3321 case "high":3301 case "high":
@@ -3329,17 +3309,40 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 7 unmodified lines
3329 3309
3330 const detectSource = (url: string) =>3310 const detectSource = (url: string) =>
3331 Effect.gen(function* () {3311 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
3332 const results: SourceDetectionResult[] = [];3328 const results: SourceDetectionResult[] = [];
3329 let detectorCount = 0;
3333 for (const runtime of runtimes.values()) {3330 for (const runtime of runtimes.values()) {
3334 if (!runtime.plugin.detect) continue;3331 if (!runtime.plugin.detect) continue;
3332 if (detectorCount >= sourceDetectionMaxDetectors) break;
3333 detectorCount++;
3335 const result = yield* runtime.plugin3334 const result = yield* runtime.plugin
3336 .detect({ ctx: runtime.ctx, url })3335 .detect({ ctx: runtime.ctx, url: trimmed })
3336 .pipe(Effect.timeout(sourceDetectionTimeout))
3337 .pipe(Effect.catch(() => Effect.succeed(null)));3337 .pipe(Effect.catch(() => Effect.succeed(null)));
3338 if (result) results.push(result);3338 if (result) results.push(result);
3339 }3339 }
3340 return results.sort(3340 return results
3341 (a, b) => detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence),3341 .sort(
3342 );3342 (a, b) =>
3343 detectionConfidenceScore(b.confidence) - detectionConfidenceScore(a.confidence),
3344 )
3345 .slice(0, sourceDetectionMaxResults);
3343 });3346 });
3344 3347
3345 // Per-source definitions accessor — one query, one mapping pass.3348 // Per-source definitions accessor — one query, one mapping pass.
@@ -3358,13 +3361,6 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = []> · 12 unmodified lines
3358 if (yield* secretRouteHasBackingValue(row)) return "resolved";3361 if (yield* secretRouteHasBackingValue(row)) return "resolved";
3359 }3362 }
3360 3363
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 }
3368 return "missing";3364 return "missing";
3369 });3365 });
3370 3366
packages/core/sdk/src/secrets.ts+3−0
@@ -48,6 +48,9 @@ export interface SecretProvider { · 47 unmodified lines
48 readonly { readonly id: string; readonly name: string }[],48 readonly { readonly id: string; readonly name: string }[],
49 StorageFailure49 StorageFailure
50 >;50 >;
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;
51}54}
52 55
53// ---------------------------------------------------------------------------56// ---------------------------------------------------------------------------
packages/plugins/graphql/src/sdk/introspect.ts+1−2
@@ -220,9 +220,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 219 unmodified lines
220 );220 );
221 221
222 if (response.status !== 200) {222 if (response.status !== 200) {
223 const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("<unreadable>")));
224 return yield* new GraphqlIntrospectionError({223 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}`,
226 });225 });
227 }226 }
228 227
packages/plugins/graphql/src/sdk/invoke.ts+12−3
@@ -48,6 +48,14 @@ const endpointWithQueryParams = (endpoint: string, queryParams: Record<string, s · 47 unmodified lines
48 return url.toString();48 return url.toString();
49};49};
50 50
51export 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
51// ---------------------------------------------------------------------------59// ---------------------------------------------------------------------------
52// Response helpers60// Response helpers
53// ---------------------------------------------------------------------------61// ---------------------------------------------------------------------------
@@ -73,11 +81,12 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( · 19 unmodified lines
73) {81) {
74 const client = yield* HttpClient.HttpClient;82 const client = yield* HttpClient.HttpClient;
75 const requestEndpoint = endpointWithQueryParams(endpoint, resolvedQueryParams);83 const requestEndpoint = endpointWithQueryParams(endpoint, resolvedQueryParams);
84 const telemetryEndpoint = endpointForTelemetry(endpoint);
76 85
77 yield* Effect.annotateCurrentSpan({86 yield* Effect.annotateCurrentSpan({
78 "http.method": "POST",87 "http.method": "POST",
79 "http.url": requestEndpoint,88 "http.url": telemetryEndpoint,
80 "plugin.graphql.endpoint": endpoint,89 "plugin.graphql.endpoint": telemetryEndpoint,
81 "plugin.graphql.operation_kind": operation.kind,90 "plugin.graphql.operation_kind": operation.kind,
82 "plugin.graphql.field_name": operation.fieldName,91 "plugin.graphql.field_name": operation.fieldName,
83 "plugin.graphql.headers.resolved_count": Object.keys(resolvedHeaders).length,92 "plugin.graphql.headers.resolved_count": Object.keys(resolvedHeaders).length,
@@ -160,7 +169,7 @@ export const invokeWithLayer = ( · 76 unmodified lines
160 Effect.provide(httpClientLayer),169 Effect.provide(httpClientLayer),
161 Effect.withSpan("plugin.graphql.invoke", {170 Effect.withSpan("plugin.graphql.invoke", {
162 attributes: {171 attributes: {
163 "plugin.graphql.endpoint": endpoint,172 "plugin.graphql.endpoint": endpointForTelemetry(endpoint),
164 "plugin.graphql.operation_kind": operation.kind,173 "plugin.graphql.operation_kind": operation.kind,
165 "plugin.graphql.field_name": operation.fieldName,174 "plugin.graphql.field_name": operation.fieldName,
166 },175 },
packages/plugins/graphql/src/sdk/plugin.test.ts+31−1
@@ -1,5 +1,6 @@
1import { describe, it, expect } from "@effect/vitest";1import { describe, it, expect } from "@effect/vitest";
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";
3 4
4import {5import {
5 ConnectionId,6 ConnectionId,
@@ -14,9 +15,11 @@ import { · 8 unmodified lines
14 SecretId,15 SecretId,
15 TokenMaterial,16 TokenMaterial,
16} from "@executor-js/sdk";17} from "@executor-js/sdk";
17import { memorySecretsPlugin } from "@executor-js/sdk/testing";18import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing";
18 19
19import { graphqlPlugin } from "./plugin";20import { graphqlPlugin } from "./plugin";
21import { endpointForTelemetry } from "./invoke";
22import { introspect } from "./introspect";
20import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types";23import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types";
21import type { IntrospectionResult } from "./introspect";24import type { IntrospectionResult } from "./introspect";
22import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";25import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
@@ -121,6 +124,33 @@ const sampleDataPlugin = definePlugin(() => ({ · 98 unmodified lines
121}));124}));
122 125
123describe("graphqlPlugin real protocol server", () => {126describe("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
124 it.effect("adds a source by introspecting the live GraphQL endpoint", () =>154 it.effect("adds a source by introspecting the live GraphQL endpoint", () =>
125 Effect.gen(function* () {155 Effect.gen(function* () {
126 const server = yield* serveGreetingServer;156 const server = yield* serveGreetingServer;