Unified · Split
| @@ -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, |
| @@ -49,7 +49,7 @@ const ToolMetadataResponse = Schema.Struct({ · 48 unmodified lines | |||
| 49 | }); | 49 | }); |
| 50 | 50 | ||
| 51 | const DetectRequest = Schema.Struct({ | 51 | const DetectRequest = Schema.Struct({ |
| 52 | url: Schema.String, | 52 | url: Schema.String.check(Schema.isMaxLength(2_048)), |
| 53 | }); | 53 | }); |
| 54 | 54 | ||
| 55 | const DetectResultResponse = Schema.Struct({ | 55 | const DetectResultResponse = Schema.Struct({ |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Data, Effect, Exit, Predicate, Result } from "effect"; | 2 | import { Data, Effect, Exit, Predicate, Result } from "effect"; |
| 3 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 3 | 4 | ||
| 4 | import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; | 5 | import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; |
| 5 | import type { DBAdapter, Where } from "@executor-js/storage-core"; | 6 | import 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 | ||
| 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 | |||
| 233 | // --------------------------------------------------------------------------- | 303 | // --------------------------------------------------------------------------- |
| 234 | // Tests | 304 | // 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 → ElicitationDeclinedError | 417 | // requiresApproval: true → declined → ElicitationDeclinedError |
| 333 | const declined = yield* executor.tools | 418 | 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 → succeeds | 434 | // 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 | // --------------------------------------------------------------------------- |
| @@ -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"; | ||
| 2 | import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; | 12 | import { FetchHttpClient, type HttpClient } from "effect/unstable/http"; |
| 3 | import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; | 13 | import type { OAuthEndpointUrlPolicy } from "./oauth-helpers"; |
| 4 | import { generateKeyBetween } from "fractional-indexing"; | 14 | import { 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"; |
| 127 | import { validateHostedOutboundUrl } from "./hosted-http-client"; | ||
| 128 | |||
| 129 | const MAX_ANNOTATION_GROUPS = 64; | ||
| 130 | const 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 | ||
| 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 | |||
| 602 | // A `DBAdapter` whose methods dispatch to the active adapter (tx handle or | 636 | // 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 is | 638 | // `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. First | 883 | // Fallback: ask enumerating providers in registration order. First |
| 850 | // non-null in registration order wins. Providers that throw | 884 | // non-null wins. Providers that throw |
| 851 | // are treated as "don't have it" so one flaky provider can't | 885 | // are treated as "don't have it" so one flaky provider can't |
| 852 | // block resolution via others. Scope-partitioning providers | 886 | // block resolution via others. Scope-partitioning providers |
| 853 | // get asked at the innermost scope as a display default — the | 887 | // get asked at the innermost scope as a display default — the |
| 854 | // enumeration fallback doesn't know which scope the value | 888 | // 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't | 1288 | // 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 the | 2760 | // ~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 a | 3287 | const sourceDetectionMaxUrlLength = config.sourceDetection?.maxUrlLength ?? 2_048; |
| 3316 | // `detect` hook. Collect all non-null results. Plugin-level detect | 3288 | const sourceDetectionMaxDetectors = config.sourceDetection?.maxDetectors ?? 6; |
| 3317 | // implementations should swallow fetch errors and return null, so | 3289 | 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.plugin | 3334 | 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 | ||
| @@ -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 | StorageFailure | 49 | 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 | // --------------------------------------------------------------------------- |
| @@ -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 | ||
| @@ -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 | ||
| 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 | |||
| 51 | // --------------------------------------------------------------------------- | 59 | // --------------------------------------------------------------------------- |
| 52 | // Response helpers | 60 | // 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 | }, |
| @@ -1,5 +1,6 @@ | |||
| 1 | import { describe, it, expect } from "@effect/vitest"; | 1 | import { describe, it, expect } from "@effect/vitest"; |
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | import { |
| 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"; |
| 17 | import { memorySecretsPlugin } from "@executor-js/sdk/testing"; | 18 | import { memorySecretsPlugin, serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 18 | 19 | ||
| 19 | import { graphqlPlugin } from "./plugin"; | 20 | import { graphqlPlugin } from "./plugin"; |
| 21 | import { endpointForTelemetry } from "./invoke"; | ||
| 22 | import { introspect } from "./introspect"; | ||
| 20 | import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; | 23 | import { GraphqlSourceBindingInput, graphqlHeaderSlot, graphqlQueryParamSlot } from "./types"; |
| 21 | import type { IntrospectionResult } from "./introspect"; | 24 | import type { IntrospectionResult } from "./introspect"; |
| 22 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; | 25 | import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; |
| @@ -121,6 +124,33 @@ const sampleDataPlugin = definePlugin(() => ({ · 98 unmodified lines | |||
| 121 | })); | 124 | })); |
| 122 | 125 | ||
| 123 | describe("graphqlPlugin real protocol server", () => { | 126 | 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 | |||
| 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; |