Unified · Split
| @@ -45,6 +45,27 @@ const toError = (cause: unknown): Error => · 44 unmodified lines | ||
| 45 | 45 | const sleep = (ms: number) => |
| 46 | 46 | Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms))); |
| 47 | 47 | |
| 48 | +const openUrlInBrowser = (url: string): Effect.Effect<void, never, never> => | |
| 49 | + Effect.sync(() => { | |
| 50 | + const cmd = | |
| 51 | + process.platform === "darwin" | |
| 52 | + ? ["open", url] | |
| 53 | + : process.platform === "win32" | |
| 54 | + ? ["cmd", "/c", "start", "", url] | |
| 55 | + : ["xdg-open", url]; | |
| 56 | + | |
| 57 | + try { | |
| 58 | + const child = spawn(cmd[0]!, cmd.slice(1), { | |
| 59 | + detached: true, | |
| 60 | + stdio: "ignore", | |
| 61 | + }); | |
| 62 | + child.on("error", () => undefined); | |
| 63 | + child.unref(); | |
| 64 | + } catch { | |
| 65 | + // Best-effort browser launch only; always leave the URL in stdout. | |
| 66 | + } | |
| 67 | + }).pipe(Effect.catchAll(() => Effect.void)); | |
| 68 | + | |
| 48 | 69 | const promptLine = (prompt: string): Effect.Effect<string, Error, never> => |
| 49 | 70 | Effect.tryPromise({ |
| 50 | 71 | try: async () => { |
| @@ -254,8 +275,8 @@ const printRootHelp = (workflow: string) => · 203 unmodified lines | ||
| 254 | 275 | "", |
| 255 | 276 | "USAGE", |
| 256 | 277 | "", |
| 257 | − " executor call [code] [--file text] [--stdin] [--base-url text]", | |
| 258 | − " executor resume --execution-id text [--base-url text]", | |
| 278 | + " executor call [code] [--file text] [--stdin] [--base-url text] [--no-open]", | |
| 279 | + " executor resume --execution-id text [--base-url text] [--no-open]", | |
| 259 | 280 | "", |
| 260 | 281 | "CALL WORKFLOW", |
| 261 | 282 | "", |
| @@ -281,7 +302,7 @@ const printCallHelp = (workflow: string) => · 19 unmodified lines | ||
| 281 | 302 | "", |
| 282 | 303 | "USAGE", |
| 283 | 304 | "", |
| 284 | − " executor call [code] [--file text] [--stdin] [--base-url text]", | |
| 305 | + " executor call [code] [--file text] [--stdin] [--base-url text] [--no-open]", | |
| 285 | 306 | "", |
| 286 | 307 | "DESCRIPTION", |
| 287 | 308 | "", |
| @@ -301,15 +322,18 @@ const printCallHelp = (workflow: string) => · 13 unmodified lines | ||
| 301 | 322 | " Read code from stdin.", |
| 302 | 323 | " --base-url text", |
| 303 | 324 | " Override the executor server base URL.", |
| 325 | + " --no-open", | |
| 326 | + " Print interaction URLs without opening a browser.", | |
| 304 | 327 | "", |
| 305 | 328 | "EXAMPLES", |
| 306 | 329 | "", |
| 307 | 330 | ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'', |
| 308 | 331 | ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'', |
| 309 | 332 | ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'', |
| 310 | − ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github", auth: { kind: "bearer" } });\'', | |
| 333 | + ' executor call \'return await tools.executor.sources.add({ kind: "openapi", endpoint: "https://api.github.com", specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", name: "GitHub", namespace: "github" });\'', | |
| 311 | 334 | " cat script.ts | executor call --stdin", |
| 312 | 335 | " executor call --file script.ts", |
| 336 | + " executor call --no-open --file script.ts", | |
| 313 | 337 | " executor resume --execution-id exec_123", |
| 314 | 338 | ].join("\n")); |
| 315 | 339 | }); |
| @@ -564,17 +588,37 @@ const promptStructuredInteraction = (parsed: { · 248 unmodified lines | ||
| 564 | 588 | return content; |
| 565 | 589 | }); |
| 566 | 590 | |
| 567 | −const promptInteraction = (interaction: ExecutionInteraction) => | |
| 591 | +const printUrlInteraction = (input: { | |
| 592 | + message: string; | |
| 593 | + url: string | null; | |
| 594 | + shouldOpen: boolean; | |
| 595 | +}) => | |
| 596 | + Effect.gen(function* () { | |
| 597 | + yield* Effect.sync(() => { | |
| 598 | + process.stdout.write(`${input.message}\n${input.url ?? ""}\n`); | |
| 599 | + }); | |
| 600 | + | |
| 601 | + if (input.shouldOpen && input.url) { | |
| 602 | + yield* openUrlInBrowser(input.url); | |
| 603 | + } | |
| 604 | + }); | |
| 605 | + | |
| 606 | +const promptInteraction = (input: { | |
| 607 | + interaction: ExecutionInteraction; | |
| 608 | + shouldOpenUrls: boolean; | |
| 609 | +}) => | |
| 568 | 610 | Effect.gen(function* () { |
| 569 | − const parsed = parseInteractionPayload(interaction); | |
| 611 | + const parsed = parseInteractionPayload(input.interaction); | |
| 570 | 612 | |
| 571 | 613 | if (!process.stdin.isTTY || !process.stdout.isTTY || parsed === null) { |
| 572 | 614 | return null; |
| 573 | 615 | } |
| 574 | 616 | |
| 575 | 617 | if (parsed.mode === "url") { |
| 576 | − yield* Effect.sync(() => { | |
| 577 | − process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`); | |
| 618 | + yield* printUrlInteraction({ | |
| 619 | + message: parsed.message, | |
| 620 | + url: parsed.url ?? null, | |
| 621 | + shouldOpen: input.shouldOpenUrls, | |
| 578 | 622 | }); |
| 579 | 623 | return null; |
| 580 | 624 | } |
| @@ -708,6 +752,7 @@ const driveExecution = (input: { · 127 unmodified lines | ||
| 708 | 752 | client: ControlPlaneClient; |
| 709 | 753 | workspaceId: ExecutionEnvelope["execution"]["workspaceId"]; |
| 710 | 754 | envelope: ExecutionEnvelope; |
| 755 | + shouldOpenUrls: boolean; | |
| 711 | 756 | }) => |
| 712 | 757 | Effect.gen(function* () { |
| 713 | 758 | let current = input.envelope; |
| @@ -721,8 +766,10 @@ const driveExecution = (input: { · 7 unmodified lines | ||
| 721 | 766 | |
| 722 | 767 | const parsed = parseInteractionPayload(pending); |
| 723 | 768 | if (parsed?.mode === "url") { |
| 724 | − yield* Effect.sync(() => { | |
| 725 | − process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`); | |
| 769 | + yield* printUrlInteraction({ | |
| 770 | + message: parsed.message, | |
| 771 | + url: parsed.url ?? null, | |
| 772 | + shouldOpen: input.shouldOpenUrls, | |
| 726 | 773 | }); |
| 727 | 774 | |
| 728 | 775 | if (!process.stdin.isTTY || !process.stdout.isTTY) { |
| @@ -738,7 +785,10 @@ const driveExecution = (input: { · 9 unmodified lines | ||
| 738 | 785 | continue; |
| 739 | 786 | } |
| 740 | 787 | |
| 741 | − const responseJson = yield* promptInteraction(pending); | |
| 788 | + const responseJson = yield* promptInteraction({ | |
| 789 | + interaction: pending, | |
| 790 | + shouldOpenUrls: input.shouldOpenUrls, | |
| 791 | + }); | |
| 742 | 792 | if (responseJson === null) { |
| 743 | 793 | yield* Effect.sync(() => { |
| 744 | 794 | console.log(JSON.stringify({ |
| @@ -790,8 +840,9 @@ const callCommand = Command.make( · 45 unmodified lines | ||
| 790 | 840 | file: Options.text("file").pipe(Options.optional), |
| 791 | 841 | stdin: Options.boolean("stdin").pipe(Options.withDefault(false)), |
| 792 | 842 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), |
| 843 | + noOpen: Options.boolean("no-open").pipe(Options.withDefault(false)), | |
| 793 | 844 | }, |
| 794 | − ({ code, file, stdin, baseUrl }) => | |
| 845 | + ({ code, file, stdin, baseUrl, noOpen }) => | |
| 795 | 846 | Effect.gen(function* () { |
| 796 | 847 | const resolvedCode = yield* readCode({ |
| 797 | 848 | code: Option.getOrUndefined(code), |
| @@ -814,6 +865,7 @@ const callCommand = Command.make( · 16 unmodified lines | ||
| 814 | 865 | client, |
| 815 | 866 | workspaceId: installation.workspaceId, |
| 816 | 867 | envelope: created, |
| 868 | + shouldOpenUrls: !noOpen, | |
| 817 | 869 | }); |
| 818 | 870 | |
| 819 | 871 | yield* printExecution(settled); |
| @@ -825,8 +877,9 @@ const resumeCommand = Command.make( · 5 unmodified lines | ||
| 825 | 877 | { |
| 826 | 878 | executionId: Options.text("execution-id"), |
| 827 | 879 | baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)), |
| 880 | + noOpen: Options.boolean("no-open").pipe(Options.withDefault(false)), | |
| 828 | 881 | }, |
| 829 | − ({ executionId, baseUrl }) => | |
| 882 | + ({ executionId, baseUrl, noOpen }) => | |
| 830 | 883 | Effect.gen(function* () { |
| 831 | 884 | yield* ensureServer(baseUrl); |
| 832 | 885 | const { installation, client } = yield* getLocalAuthedClient(baseUrl); |
| @@ -844,6 +897,7 @@ const resumeCommand = Command.make( · 11 unmodified lines | ||
| 844 | 897 | client, |
| 845 | 898 | workspaceId: installation.workspaceId, |
| 846 | 899 | envelope: execution, |
| 900 | + shouldOpenUrls: !noOpen, | |
| 847 | 901 | }); |
| 848 | 902 | |
| 849 | 903 | yield* printExecution(settled); |
| @@ -1,5 +1,4 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"; |
| 2 | −import * as Schema from "effect/Schema"; | |
| 3 | 2 | import { LocalInstallationSchema } from "#schema"; |
| 4 | 3 | |
| 5 | 4 | import { |
| @@ -16,11 +15,4 @@ export class LocalApi extends HttpApiGroup.make("local") · 10 unmodified lines | ||
| 16 | 15 | .addError(ControlPlaneNotFoundError) |
| 17 | 16 | .addError(ControlPlaneStorageError), |
| 18 | 17 | ) |
| 19 | − .add( | |
| 20 | − HttpApiEndpoint.get("oauthCallback")`/local/oauth/callback` | |
| 21 | − .addSuccess(Schema.String) | |
| 22 | − .addError(ControlPlaneBadRequestError) | |
| 23 | − .addError(ControlPlaneNotFoundError) | |
| 24 | − .addError(ControlPlaneStorageError), | |
| 25 | − ) | |
| 26 | 18 | .prefix("/v1") {} |
| @@ -1,7 +1,8 @@ | ||
| 1 | −import { HttpApiBuilder, HttpServerRequest } from "@effect/platform"; | |
| 2 | −import * as Effect from "effect/Effect"; | |
| 3 | 1 | import { |
| 4 | − completeSourceAuthCallback, | |
| 2 | + HttpApiBuilder, | |
| 3 | +} from "@effect/platform"; | |
| 4 | + | |
| 5 | +import { | |
| 5 | 6 | getLocalInstallation, |
| 6 | 7 | } from "../../runtime/local-operations"; |
| 7 | 8 | |
| @@ -14,20 +15,5 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 6 unmodified lines | ||
| 14 | 15 | handlers |
| 15 | 16 | .handle("installation", () => |
| 16 | 17 | getLocalInstallation(), |
| 17 | − ) | |
| 18 | − .handle("oauthCallback", () => | |
| 19 | − Effect.gen(function* () { | |
| 20 | − const request = yield* HttpServerRequest.HttpServerRequest; | |
| 21 | − const requestUrl = new URL(request.url, "http://127.0.0.1"); | |
| 22 | − | |
| 23 | − const source = yield* completeSourceAuthCallback({ | |
| 24 | − state: requestUrl.searchParams.get("state") ?? "", | |
| 25 | − code: requestUrl.searchParams.get("code"), | |
| 26 | − error: requestUrl.searchParams.get("error"), | |
| 27 | − errorDescription: requestUrl.searchParams.get("error_description"), | |
| 28 | − }); | |
| 29 | − | |
| 30 | − return `Source connected: ${source.id}. You can close this window.`; | |
| 31 | − }), | |
| 32 | 18 | ), |
| 33 | 19 | ); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; |
| 2 | 2 | import { |
| 3 | + ExecutionInteractionIdSchema, | |
| 3 | 4 | SourceAuthSchema, |
| 4 | 5 | SourceIdSchema, |
| 5 | 6 | SourceKindSchema, |
| @@ -72,6 +73,26 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 66 unmodified lines | ||
| 72 | 73 | const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema); |
| 73 | 74 | const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema); |
| 74 | 75 | |
| 76 | +const CredentialPageUrlParamsSchema = Schema.Struct({ | |
| 77 | + interactionId: ExecutionInteractionIdSchema, | |
| 78 | +}); | |
| 79 | + | |
| 80 | +const CredentialSubmitPayloadSchema = Schema.Struct({ | |
| 81 | + action: Schema.optional(Schema.Literal("submit", "continue", "cancel")), | |
| 82 | + token: Schema.optional(Schema.String), | |
| 83 | +}); | |
| 84 | + | |
| 85 | +const CredentialOauthCompleteUrlParamsSchema = Schema.Struct({ | |
| 86 | + state: Schema.String, | |
| 87 | + code: Schema.optional(Schema.String), | |
| 88 | + error: Schema.optional(Schema.String), | |
| 89 | + error_description: Schema.optional(Schema.String), | |
| 90 | +}); | |
| 91 | + | |
| 92 | +const HtmlSchema = HttpApiSchema.Text({ | |
| 93 | + contentType: "text/html", | |
| 94 | +}); | |
| 95 | + | |
| 75 | 96 | export class SourcesApi extends HttpApiGroup.make("sources") |
| 76 | 97 | .add( |
| 77 | 98 | HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources` |
| @@ -117,4 +138,29 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 39 unmodified lines | ||
| 117 | 138 | .addError(ControlPlaneForbiddenError) |
| 118 | 139 | .addError(ControlPlaneStorageError), |
| 119 | 140 | ) |
| 141 | + .add( | |
| 142 | + HttpApiEndpoint.get("credentialPage")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials` | |
| 143 | + .setUrlParams(CredentialPageUrlParamsSchema) | |
| 144 | + .addSuccess(HtmlSchema) | |
| 145 | + .addError(ControlPlaneBadRequestError) | |
| 146 | + .addError(ControlPlaneNotFoundError) | |
| 147 | + .addError(ControlPlaneStorageError), | |
| 148 | + ) | |
| 149 | + .add( | |
| 150 | + HttpApiEndpoint.post("credentialSubmit")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials` | |
| 151 | + .setUrlParams(CredentialPageUrlParamsSchema) | |
| 152 | + .setPayload(CredentialSubmitPayloadSchema) | |
| 153 | + .addSuccess(HtmlSchema) | |
| 154 | + .addError(ControlPlaneBadRequestError) | |
| 155 | + .addError(ControlPlaneNotFoundError) | |
| 156 | + .addError(ControlPlaneStorageError), | |
| 157 | + ) | |
| 158 | + .add( | |
| 159 | + HttpApiEndpoint.get("credentialComplete")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/credentials/oauth/complete` | |
| 160 | + .setUrlParams(CredentialOauthCompleteUrlParamsSchema) | |
| 161 | + .addSuccess(Schema.String) | |
| 162 | + .addError(ControlPlaneBadRequestError) | |
| 163 | + .addError(ControlPlaneNotFoundError) | |
| 164 | + .addError(ControlPlaneStorageError), | |
| 165 | + ) | |
| 120 | 166 | .prefix("/v1") {} |
| @@ -1,5 +1,13 @@ | ||
| 1 | −import { HttpApiBuilder } from "@effect/platform"; | |
| 2 | −import type { WorkspaceId } from "#schema"; | |
| 1 | +import { | |
| 2 | + HttpApiBuilder, | |
| 3 | + HttpServerResponse, | |
| 4 | +} from "@effect/platform"; | |
| 5 | +import type { | |
| 6 | + ExecutionInteraction, | |
| 7 | + Source, | |
| 8 | + WorkspaceId, | |
| 9 | +} from "#schema"; | |
| 10 | +import * as Effect from "effect/Effect"; | |
| 3 | 11 | |
| 4 | 12 | import { requirePermission, withPolicy } from "#domain"; |
| 5 | 13 | import { |
| @@ -9,7 +17,17 @@ import { · 3 unmodified lines | ||
| 9 | 17 | removeSource, |
| 10 | 18 | updateSource, |
| 11 | 19 | } from "../../runtime/sources-operations"; |
| 20 | +import { | |
| 21 | + completeSourceCredentialSetup, | |
| 22 | + getSourceCredentialInteraction, | |
| 23 | + submitSourceCredentialInteraction, | |
| 24 | +} from "../../runtime/local-operations"; | |
| 12 | 25 | |
| 26 | +import { | |
| 27 | + ControlPlaneBadRequestError, | |
| 28 | + ControlPlaneNotFoundError, | |
| 29 | + ControlPlaneStorageError, | |
| 30 | +} from "../errors"; | |
| 13 | 31 | import { ControlPlaneApi } from "../api"; |
| 14 | 32 | import { withWorkspaceRequestActor } from "../http-auth"; |
| 15 | 33 | |
| @@ -25,6 +43,446 @@ const requireWriteSources = (workspaceId: WorkspaceId) => · 9 unmodified lines | ||
| 25 | 43 | workspaceId, |
| 26 | 44 | }); |
| 27 | 45 | |
| 46 | +const escapeHtml = (value: string): string => | |
| 47 | + value | |
| 48 | + .replaceAll("&", "&") | |
| 49 | + .replaceAll("<", "<") | |
| 50 | + .replaceAll(">", ">") | |
| 51 | + .replaceAll('"', """) | |
| 52 | + .replaceAll("'", "'"); | |
| 53 | + | |
| 54 | +const credentialPageDocument = (input: { | |
| 55 | + title: string; | |
| 56 | + eyebrow: string; | |
| 57 | + message: string; | |
| 58 | + sourceLabel: string; | |
| 59 | + endpoint: string; | |
| 60 | + interactionId: string; | |
| 61 | + error?: string; | |
| 62 | + state?: "pending" | "stored" | "continued" | "cancelled" | "inactive"; | |
| 63 | +}) => `<!doctype html> | |
| 64 | +<html lang="en"> | |
| 65 | + <head> | |
| 66 | + <meta charset="utf-8" /> | |
| 67 | + <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 68 | + <title>${escapeHtml(input.title)}</title> | |
| 69 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 70 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 71 | + <link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" /> | |
| 72 | + <style> | |
| 73 | + :root { | |
| 74 | + --bg: #fafaf9; | |
| 75 | + --surface: #ffffff; | |
| 76 | + --text: #1c1917; | |
| 77 | + --text-secondary: #78716c; | |
| 78 | + --text-tertiary: #a8a29e; | |
| 79 | + --border: #e7e5e4; | |
| 80 | + --accent: #1d4ed8; | |
| 81 | + --accent-light: #eff6ff; | |
| 82 | + --green: #16a34a; | |
| 83 | + --green-light: #f0fdf4; | |
| 84 | + --green-border: #bbf7d0; | |
| 85 | + --red: #dc2626; | |
| 86 | + --red-light: #fef2f2; | |
| 87 | + --red-border: #fecaca; | |
| 88 | + --amber: #d97706; | |
| 89 | + --amber-light: #fffbeb; | |
| 90 | + --amber-border: #fde68a; | |
| 91 | + } | |
| 92 | + | |
| 93 | + * { box-sizing: border-box; margin: 0; padding: 0; } | |
| 94 | + | |
| 95 | + body { | |
| 96 | + min-height: 100vh; | |
| 97 | + background: var(--bg); | |
| 98 | + color: var(--text); | |
| 99 | + font-family: 'DM Sans', system-ui, -apple-system, sans-serif; | |
| 100 | + display: flex; | |
| 101 | + justify-content: center; | |
| 102 | + padding: 80px 24px 48px; | |
| 103 | + -webkit-font-smoothing: antialiased; | |
| 104 | + } | |
| 105 | + | |
| 106 | + .page { | |
| 107 | + width: 100%; | |
| 108 | + max-width: 540px; | |
| 109 | + } | |
| 110 | + | |
| 111 | + /* Status pill at the very top */ | |
| 112 | + .status-pill { | |
| 113 | + display: inline-flex; | |
| 114 | + align-items: center; | |
| 115 | + gap: 7px; | |
| 116 | + padding: 5px 14px 5px 10px; | |
| 117 | + border-radius: 999px; | |
| 118 | + font-family: 'DM Mono', monospace; | |
| 119 | + font-size: 12px; | |
| 120 | + font-weight: 500; | |
| 121 | + letter-spacing: 0.01em; | |
| 122 | + margin-bottom: 28px; | |
| 123 | + } | |
| 124 | + | |
| 125 | + .status-pill::before { | |
| 126 | + content: ''; | |
| 127 | + width: 7px; | |
| 128 | + height: 7px; | |
| 129 | + border-radius: 50%; | |
| 130 | + flex-shrink: 0; | |
| 131 | + } | |
| 132 | + | |
| 133 | + .status-pill[data-state='pending'] { | |
| 134 | + background: var(--amber-light); | |
| 135 | + color: var(--amber); | |
| 136 | + border: 1px solid var(--amber-border); | |
| 137 | + } | |
| 138 | + .status-pill[data-state='pending']::before { background: var(--amber); } | |
| 139 | + | |
| 140 | + .status-pill[data-state='stored'] { | |
| 141 | + background: var(--green-light); | |
| 142 | + color: var(--green); | |
| 143 | + border: 1px solid var(--green-border); | |
| 144 | + } | |
| 145 | + .status-pill[data-state='stored']::before { background: var(--green); } | |
| 146 | + | |
| 147 | + .status-pill[data-state='continued'] { | |
| 148 | + background: var(--green-light); | |
| 149 | + color: var(--green); | |
| 150 | + border: 1px solid var(--green-border); | |
| 151 | + } | |
| 152 | + .status-pill[data-state='continued']::before { background: var(--green); } | |
| 153 | + | |
| 154 | + .status-pill[data-state='cancelled'], | |
| 155 | + .status-pill[data-state='inactive'] { | |
| 156 | + background: #f5f5f4; | |
| 157 | + color: var(--text-secondary); | |
| 158 | + border: 1px solid var(--border); | |
| 159 | + } | |
| 160 | + .status-pill[data-state='cancelled']::before, | |
| 161 | + .status-pill[data-state='inactive']::before { background: var(--text-tertiary); } | |
| 162 | + | |
| 163 | + /* Title area */ | |
| 164 | + h1 { | |
| 165 | + font-size: 28px; | |
| 166 | + font-weight: 600; | |
| 167 | + letter-spacing: -0.025em; | |
| 168 | + line-height: 1.2; | |
| 169 | + margin-bottom: 10px; | |
| 170 | + } | |
| 171 | + | |
| 172 | + .subtitle { | |
| 173 | + font-size: 15px; | |
| 174 | + line-height: 1.6; | |
| 175 | + color: var(--text-secondary); | |
| 176 | + } | |
| 177 | + | |
| 178 | + /* Divider */ | |
| 179 | + hr { | |
| 180 | + border: none; | |
| 181 | + height: 1px; | |
| 182 | + background: var(--border); | |
| 183 | + margin: 36px 0; | |
| 184 | + } | |
| 185 | + | |
| 186 | + /* Source detail rows */ | |
| 187 | + .detail { | |
| 188 | + display: flex; | |
| 189 | + flex-direction: column; | |
| 190 | + gap: 6px; | |
| 191 | + margin-bottom: 24px; | |
| 192 | + } | |
| 193 | + | |
| 194 | + .detail-label { | |
| 195 | + font-size: 13px; | |
| 196 | + font-weight: 500; | |
| 197 | + color: var(--text-secondary); | |
| 198 | + } | |
| 199 | + | |
| 200 | + .detail-value { | |
| 201 | + font-size: 15px; | |
| 202 | + line-height: 1.5; | |
| 203 | + word-break: break-word; | |
| 204 | + } | |
| 205 | + | |
| 206 | + .detail-mono { | |
| 207 | + font-family: 'DM Mono', monospace; | |
| 208 | + font-size: 13px; | |
| 209 | + line-height: 1.5; | |
| 210 | + padding: 10px 14px; | |
| 211 | + background: #f5f5f4; | |
| 212 | + border: 1px solid var(--border); | |
| 213 | + border-radius: 8px; | |
| 214 | + word-break: break-all; | |
| 215 | + color: var(--text); | |
| 216 | + } | |
| 217 | + | |
| 218 | + .detail-id { | |
| 219 | + font-family: 'DM Mono', monospace; | |
| 220 | + font-size: 12px; | |
| 221 | + color: var(--text-tertiary); | |
| 222 | + } | |
| 223 | + | |
| 224 | + /* Result card for stored/cancelled/inactive */ | |
| 225 | + .result-card { | |
| 226 | + padding: 32px; | |
| 227 | + border-radius: 12px; | |
| 228 | + text-align: center; | |
| 229 | + } | |
| 230 | + | |
| 231 | + .result-card.success { | |
| 232 | + background: var(--green-light); | |
| 233 | + border: 1px solid var(--green-border); | |
| 234 | + } | |
| 235 | + | |
| 236 | + .result-card.neutral { | |
| 237 | + background: #f5f5f4; | |
| 238 | + border: 1px solid var(--border); | |
| 239 | + } | |
| 240 | + | |
| 241 | + .result-card p { | |
| 242 | + font-size: 15px; | |
| 243 | + line-height: 1.6; | |
| 244 | + color: var(--text-secondary); | |
| 245 | + } | |
| 246 | + | |
| 247 | + .result-card .result-heading { | |
| 248 | + font-weight: 600; | |
| 249 | + font-size: 15px; | |
| 250 | + color: var(--text); | |
| 251 | + margin-bottom: 6px; | |
| 252 | + } | |
| 253 | + | |
| 254 | + /* Form */ | |
| 255 | + .alert { | |
| 256 | + padding: 12px 14px; | |
| 257 | + border-radius: 8px; | |
| 258 | + background: var(--red-light); | |
| 259 | + border: 1px solid var(--red-border); | |
| 260 | + color: var(--red); | |
| 261 | + font-size: 14px; | |
| 262 | + line-height: 1.5; | |
| 263 | + margin-bottom: 20px; | |
| 264 | + } | |
| 265 | + | |
| 266 | + .field { margin-bottom: 20px; } | |
| 267 | + | |
| 268 | + label { | |
| 269 | + display: block; | |
| 270 | + font-size: 14px; | |
| 271 | + font-weight: 500; | |
| 272 | + margin-bottom: 6px; | |
| 273 | + } | |
| 274 | + | |
| 275 | + input[type='password'] { | |
| 276 | + width: 100%; | |
| 277 | + padding: 10px 14px; | |
| 278 | + background: var(--surface); | |
| 279 | + border: 1px solid var(--border); | |
| 280 | + border-radius: 8px; | |
| 281 | + font-family: 'DM Mono', monospace; | |
| 282 | + font-size: 14px; | |
| 283 | + color: var(--text); | |
| 284 | + transition: border-color 0.15s, box-shadow 0.15s; | |
| 285 | + } | |
| 286 | + | |
| 287 | + input[type='password']::placeholder { color: var(--text-tertiary); } | |
| 288 | + | |
| 289 | + input[type='password']:focus { | |
| 290 | + outline: none; | |
| 291 | + border-color: var(--accent); | |
| 292 | + box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.1); | |
| 293 | + } | |
| 294 | + | |
| 295 | + .hint { | |
| 296 | + margin-top: 8px; | |
| 297 | + font-size: 13px; | |
| 298 | + line-height: 1.5; | |
| 299 | + color: var(--text-tertiary); | |
| 300 | + } | |
| 301 | + | |
| 302 | + .actions { | |
| 303 | + display: flex; | |
| 304 | + gap: 10px; | |
| 305 | + margin-top: 24px; | |
| 306 | + } | |
| 307 | + | |
| 308 | + button { | |
| 309 | + appearance: none; | |
| 310 | + border: none; | |
| 311 | + border-radius: 8px; | |
| 312 | + padding: 10px 20px; | |
| 313 | + font-family: inherit; | |
| 314 | + font-size: 14px; | |
| 315 | + font-weight: 500; | |
| 316 | + cursor: pointer; | |
| 317 | + transition: background 0.15s; | |
| 318 | + } | |
| 319 | + | |
| 320 | + .primary { | |
| 321 | + background: var(--text); | |
| 322 | + color: var(--surface); | |
| 323 | + } | |
| 324 | + .primary:hover { background: #292524; } | |
| 325 | + | |
| 326 | + .secondary { | |
| 327 | + background: transparent; | |
| 328 | + color: var(--text-secondary); | |
| 329 | + border: 1px solid var(--border); | |
| 330 | + } | |
| 331 | + .secondary:hover { background: #f5f5f4; } | |
| 332 | + | |
| 333 | + @media (max-width: 540px) { | |
| 334 | + body { padding: 48px 20px 32px; } | |
| 335 | + h1 { font-size: 24px; } | |
| 336 | + } | |
| 337 | + </style> | |
| 338 | + </head> | |
| 339 | + <body> | |
| 340 | + <main class="page"> | |
| 341 | + <div class="status-pill" data-state="${escapeHtml(input.state ?? "pending")}">${escapeHtml(input.state ?? "pending")}</div> | |
| 342 | + | |
| 343 | + <h1>${escapeHtml(input.title)}</h1> | |
| 344 | + <p class="subtitle">${escapeHtml(input.message)}</p> | |
| 345 | + | |
| 346 | + <hr /> | |
| 347 | + | |
| 348 | + <div class="detail"> | |
| 349 | + <span class="detail-label">Source</span> | |
| 350 | + <span class="detail-value">${escapeHtml(input.sourceLabel)}</span> | |
| 351 | + </div> | |
| 352 | + | |
| 353 | + <div class="detail"> | |
| 354 | + <span class="detail-label">API endpoint</span> | |
| 355 | + <div class="detail-mono">${escapeHtml(input.endpoint)}</div> | |
| 356 | + </div> | |
| 357 | + | |
| 358 | + <div class="detail"> | |
| 359 | + <span class="detail-label">Interaction</span> | |
| 360 | + <span class="detail-id">${escapeHtml(input.interactionId)}</span> | |
| 361 | + </div> | |
| 362 | + | |
| 363 | + <hr /> | |
| 364 | + | |
| 365 | + ${ | |
| 366 | + input.error | |
| 367 | + ? `<p class="alert">${escapeHtml(input.error)}</p>` | |
| 368 | + : "" | |
| 369 | + } | |
| 370 | + ${ | |
| 371 | + input.state === "stored" | |
| 372 | + ? `<div class="result-card success"> | |
| 373 | + <p class="result-heading">Credential stored</p> | |
| 374 | + <p>Executor resumed the source connection request. You can close this window.</p> | |
| 375 | + </div>` | |
| 376 | + : input.state === "continued" | |
| 377 | + ? `<div class="result-card success"> | |
| 378 | + <p class="result-heading">Continuing without auth</p> | |
| 379 | + <p>Executor resumed the source connection without stored credentials. You can close this window.</p> | |
| 380 | + </div>` | |
| 381 | + : input.state === "cancelled" | |
| 382 | + ? `<div class="result-card neutral"> | |
| 383 | + <p class="result-heading">Request cancelled</p> | |
| 384 | + <p>Executor was notified. You can close this window.</p> | |
| 385 | + </div>` | |
| 386 | + : input.state === "inactive" | |
| 387 | + ? `<div class="result-card neutral"> | |
| 388 | + <p class="result-heading">No longer active</p> | |
| 389 | + <p>This credential request has expired. Start a new source connection flow if needed.</p> | |
| 390 | + </div>` | |
| 391 | + : `<form method="post"> | |
| 392 | + <div class="field"> | |
| 393 | + <label for="token">Personal access token</label> | |
| 394 | + <input | |
| 395 | + id="token" | |
| 396 | + name="token" | |
| 397 | + type="password" | |
| 398 | + autocomplete="off" | |
| 399 | + spellcheck="false" | |
| 400 | + placeholder="ghp_..." | |
| 401 | + /> | |
| 402 | + <p class="hint"> | |
| 403 | + Add a token to enable authenticated requests, or continue without auth if this API supports public access. | |
| 404 | + </p> | |
| 405 | + </div> | |
| 406 | + <div class="actions"> | |
| 407 | + <button class="primary" type="submit" name="action" value="submit">Connect token</button> | |
| 408 | + <button class="secondary" type="submit" name="action" value="continue" formnovalidate>Continue without auth</button> | |
| 409 | + <button class="secondary" type="submit" name="action" value="cancel" formnovalidate>Cancel</button> | |
| 410 | + </div> | |
| 411 | + </form>` | |
| 412 | + } | |
| 413 | + </main> | |
| 414 | + </body> | |
| 415 | +</html>`; | |
| 416 | + | |
| 417 | +const htmlResponse = (html: string, status = 200) => | |
| 418 | + HttpServerResponse.html(html).pipe( | |
| 419 | + HttpServerResponse.setStatus(status), | |
| 420 | + ); | |
| 421 | + | |
| 422 | +const credentialErrorResponse = (input: { | |
| 423 | + title: string; | |
| 424 | + message: string; | |
| 425 | + status: number; | |
| 426 | +}) => | |
| 427 | + htmlResponse( | |
| 428 | + credentialPageDocument({ | |
| 429 | + title: input.title, | |
| 430 | + eyebrow: "Executor", | |
| 431 | + message: input.message, | |
| 432 | + sourceLabel: "Unavailable", | |
| 433 | + endpoint: "Unavailable", | |
| 434 | + interactionId: "unavailable", | |
| 435 | + state: "inactive", | |
| 436 | + error: input.message, | |
| 437 | + }), | |
| 438 | + input.status, | |
| 439 | + ); | |
| 440 | + | |
| 441 | +const credentialErrorStatus = ( | |
| 442 | + error: ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError, | |
| 443 | +) => | |
| 444 | + error instanceof ControlPlaneNotFoundError | |
| 445 | + ? 404 | |
| 446 | + : error instanceof ControlPlaneBadRequestError | |
| 447 | + ? 400 | |
| 448 | + : 500; | |
| 449 | + | |
| 450 | +const credentialSubmitErrorResponse = (input: { | |
| 451 | + workspaceId: WorkspaceId; | |
| 452 | + sourceId: Source["id"]; | |
| 453 | + interactionId: ExecutionInteraction["id"]; | |
| 454 | + message: string; | |
| 455 | + status: number; | |
| 456 | +}) => | |
| 457 | + getSourceCredentialInteraction({ | |
| 458 | + workspaceId: input.workspaceId, | |
| 459 | + sourceId: input.sourceId, | |
| 460 | + interactionId: input.interactionId, | |
| 461 | + }).pipe( | |
| 462 | + Effect.match({ | |
| 463 | + onFailure: () => | |
| 464 | + credentialErrorResponse({ | |
| 465 | + title: "Source Credential Request Failed", | |
| 466 | + message: input.message, | |
| 467 | + status: input.status, | |
| 468 | + }), | |
| 469 | + onSuccess: (interaction) => | |
| 470 | + htmlResponse( | |
| 471 | + credentialPageDocument({ | |
| 472 | + title: "Configure Source Access", | |
| 473 | + eyebrow: "Executor", | |
| 474 | + message: interaction.message, | |
| 475 | + sourceLabel: interaction.sourceLabel, | |
| 476 | + endpoint: interaction.endpoint, | |
| 477 | + interactionId: interaction.interactionId, | |
| 478 | + error: input.message, | |
| 479 | + state: "pending", | |
| 480 | + }), | |
| 481 | + input.status, | |
| 482 | + ), | |
| 483 | + }), | |
| 484 | + ); | |
| 485 | + | |
| 28 | 486 | export const ControlPlaneSourcesLive = HttpApiBuilder.group( |
| 29 | 487 | ControlPlaneApi, |
| 30 | 488 | "sources", |
| @@ -74,5 +532,125 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 43 unmodified lines | ||
| 74 | 532 | }), |
| 75 | 533 | ), |
| 76 | 534 | ), |
| 535 | + ) | |
| 536 | + .handle("credentialPage", ({ path, urlParams }) => | |
| 537 | + getSourceCredentialInteraction({ | |
| 538 | + workspaceId: path.workspaceId, | |
| 539 | + sourceId: path.sourceId, | |
| 540 | + interactionId: urlParams.interactionId, | |
| 541 | + }).pipe( | |
| 542 | + Effect.map((interaction) => | |
| 543 | + htmlResponse( | |
| 544 | + credentialPageDocument({ | |
| 545 | + title: "Configure Source Access", | |
| 546 | + eyebrow: "Executor", | |
| 547 | + message: interaction.message, | |
| 548 | + sourceLabel: interaction.sourceLabel, | |
| 549 | + endpoint: interaction.endpoint, | |
| 550 | + interactionId: interaction.interactionId, | |
| 551 | + state: interaction.status === "pending" ? "pending" : "inactive", | |
| 552 | + }), | |
| 553 | + ) | |
| 554 | + ), | |
| 555 | + Effect.catchTags({ | |
| 556 | + ControlPlaneNotFoundError: (error) => | |
| 557 | + Effect.succeed( | |
| 558 | + credentialErrorResponse({ | |
| 559 | + title: "Source Credential Request Unavailable", | |
| 560 | + message: error.message, | |
| 561 | + status: credentialErrorStatus(error), | |
| 562 | + }), | |
| 563 | + ), | |
| 564 | + ControlPlaneStorageError: (error) => | |
| 565 | + Effect.succeed( | |
| 566 | + credentialErrorResponse({ | |
| 567 | + title: "Source Credential Request Failed", | |
| 568 | + message: error.message, | |
| 569 | + status: credentialErrorStatus(error), | |
| 570 | + }), | |
| 571 | + ), | |
| 572 | + }), | |
| 573 | + ), | |
| 574 | + ) | |
| 575 | + .handle("credentialSubmit", ({ path, urlParams, payload }) => | |
| 576 | + submitSourceCredentialInteraction({ | |
| 577 | + workspaceId: path.workspaceId, | |
| 578 | + sourceId: path.sourceId, | |
| 579 | + interactionId: urlParams.interactionId, | |
| 580 | + action: | |
| 581 | + payload.action === "cancel" | |
| 582 | + ? "cancel" | |
| 583 | + : payload.action === "continue" | |
| 584 | + ? "continue" | |
| 585 | + : "submit", | |
| 586 | + token: payload.token, | |
| 587 | + }).pipe( | |
| 588 | + Effect.map((result) => | |
| 589 | + htmlResponse( | |
| 590 | + credentialPageDocument({ | |
| 591 | + title: | |
| 592 | + result.kind === "stored" | |
| 593 | + ? "Source Credential Stored" | |
| 594 | + : result.kind === "continued" | |
| 595 | + ? "Source Access Updated" | |
| 596 | + : "Source Credential Request Cancelled", | |
| 597 | + eyebrow: "Executor", | |
| 598 | + message: | |
| 599 | + result.kind === "stored" | |
| 600 | + ? "Executor stored the credential and resumed the source connection." | |
| 601 | + : result.kind === "continued" | |
| 602 | + ? "Executor resumed the source connection without stored credentials." | |
| 603 | + : "Executor cancelled the source credential request.", | |
| 604 | + sourceLabel: result.sourceLabel, | |
| 605 | + endpoint: result.endpoint, | |
| 606 | + interactionId: urlParams.interactionId, | |
| 607 | + state: | |
| 608 | + result.kind === "stored" | |
| 609 | + ? "stored" | |
| 610 | + : result.kind === "continued" | |
| 611 | + ? "continued" | |
| 612 | + : "cancelled", | |
| 613 | + }), | |
| 614 | + ) | |
| 615 | + ), | |
| 616 | + Effect.catchTags({ | |
| 617 | + ControlPlaneBadRequestError: (error) => | |
| 618 | + credentialSubmitErrorResponse({ | |
| 619 | + workspaceId: path.workspaceId, | |
| 620 | + sourceId: path.sourceId, | |
| 621 | + interactionId: urlParams.interactionId, | |
| 622 | + message: error.message, | |
| 623 | + status: credentialErrorStatus(error), | |
| 624 | + }), | |
| 625 | + ControlPlaneNotFoundError: (error) => | |
| 626 | + Effect.succeed( | |
| 627 | + credentialErrorResponse({ | |
| 628 | + title: "Source Credential Request Unavailable", | |
| 629 | + message: error.message, | |
| 630 | + status: credentialErrorStatus(error), | |
| 631 | + }), | |
| 632 | + ), | |
| 633 | + ControlPlaneStorageError: (error) => | |
| 634 | + Effect.succeed( | |
| 635 | + credentialErrorResponse({ | |
| 636 | + title: "Source Credential Request Failed", | |
| 637 | + message: error.message, | |
| 638 | + status: credentialErrorStatus(error), | |
| 639 | + }), | |
| 640 | + ), | |
| 641 | + }), | |
| 642 | + ), | |
| 643 | + ) | |
| 644 | + .handle("credentialComplete", ({ path, urlParams }) => | |
| 645 | + completeSourceCredentialSetup({ | |
| 646 | + workspaceId: path.workspaceId, | |
| 647 | + sourceId: path.sourceId, | |
| 648 | + state: urlParams.state, | |
| 649 | + code: urlParams.code, | |
| 650 | + error: urlParams.error, | |
| 651 | + errorDescription: urlParams.error_description, | |
| 652 | + }).pipe( | |
| 653 | + Effect.map((source) => `Source connected: ${source.id}. You can close this window.`), | |
| 654 | + ), | |
| 77 | 655 | ), |
| 78 | 656 | ); |
| @@ -18,6 +18,20 @@ export const createExecutionInteractionsRepo = ( · 17 unmodified lines | ||
| 18 | 18 | client: DrizzleClient, |
| 19 | 19 | tables: DrizzleTables, |
| 20 | 20 | ) => ({ |
| 21 | + getById: (interactionId: ExecutionInteraction["id"]) => | |
| 22 | + client.use("rows.execution_interactions.get_by_id", async (db) => { | |
| 23 | + const rows = await db | |
| 24 | + .select() | |
| 25 | + .from(tables.executionInteractionsTable) | |
| 26 | + .where(eq(tables.executionInteractionsTable.id, interactionId)) | |
| 27 | + .limit(1); | |
| 28 | + | |
| 29 | + const row = firstOption(rows); | |
| 30 | + return Option.isSome(row) | |
| 31 | + ? Option.some(decodeExecutionInteraction(row.value)) | |
| 32 | + : Option.none<ExecutionInteraction>(); | |
| 33 | + }), | |
| 34 | + | |
| 21 | 35 | listByExecutionId: (executionId: ExecutionInteraction["executionId"]) => |
| 22 | 36 | client.use("rows.execution_interactions.list_by_execution_id", async (db) => { |
| 23 | 37 | const rows = await db |
| @@ -178,7 +178,7 @@ export const createToolArtifactsRepo = ( · 177 unmodified lines | ||
| 178 | 178 | }, |
| 179 | 179 | ) => |
| 180 | 180 | client.use("rows.tool_artifacts.search_by_workspace", async (db) => { |
| 181 | − const rows = await db | |
| 181 | + const baseQuery = db | |
| 182 | 182 | .select() |
| 183 | 183 | .from(tables.toolArtifactsTable) |
| 184 | 184 | .where( |
| @@ -191,8 +191,10 @@ export const createToolArtifactsRepo = ( · 6 unmodified lines | ||
| 191 | 191 | .orderBy( |
| 192 | 192 | asc(tables.toolArtifactsTable.searchNamespace), |
| 193 | 193 | asc(tables.toolArtifactsTable.path), |
| 194 | − ) | |
| 195 | − .limit(input.limit ?? 500); | |
| 194 | + ); | |
| 195 | + const rows = input.limit !== undefined | |
| 196 | + ? await baseQuery.limit(input.limit) | |
| 197 | + : await baseQuery; | |
| 196 | 198 | |
| 197 | 199 | return rows.map((row) => decodeStoredToolArtifactRecord(row)); |
| 198 | 200 | }), |
| @@ -1,10 +1,22 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | +import * as Fiber from "effect/Fiber"; | |
| 5 | +import * as Option from "effect/Option"; | |
| 4 | 6 | |
| 5 | 7 | import type { AccountId } from "#schema"; |
| 6 | − | |
| 7 | −import { createSqlControlPlaneRuntime } from "./index"; | |
| 8 | +import { | |
| 9 | + ExecutionIdSchema, | |
| 10 | + ExecutionInteractionIdSchema, | |
| 11 | + SecretMaterialIdSchema, | |
| 12 | + SourceIdSchema, | |
| 13 | +} from "#schema"; | |
| 14 | +import type { ToolPath } from "@executor-v3/codemode-core"; | |
| 15 | + | |
| 16 | +import { | |
| 17 | + createSqlControlPlaneRuntime, | |
| 18 | + LiveExecutionManagerService, | |
| 19 | +} from "./index"; | |
| 8 | 20 | import { withControlPlaneClient } from "./test-http-client"; |
| 9 | 21 | |
| 10 | 22 | const makeRuntime = Effect.acquireRelease( |
| @@ -102,6 +114,275 @@ describe("control-plane-runtime", () => { · 91 unmodified lines | ||
| 102 | 114 | }), |
| 103 | 115 | ); |
| 104 | 116 | |
| 117 | + it.scoped("captures credential requests through the local HTML flow without persisting raw tokens", () => | |
| 118 | + Effect.gen(function* () { | |
| 119 | + const runtime = yield* makeRuntime; | |
| 120 | + const installation = runtime.localInstallation; | |
| 121 | + const executionId = ExecutionIdSchema.make("exec_local_credential"); | |
| 122 | + const sourceId = SourceIdSchema.make("src_local_credential"); | |
| 123 | + const interactionSuffix = "executor.sources.add:test"; | |
| 124 | + const interactionId = ExecutionInteractionIdSchema.make( | |
| 125 | + `${executionId}:${interactionSuffix}`, | |
| 126 | + ); | |
| 127 | + const now = Date.now(); | |
| 128 | + | |
| 129 | + yield* runtime.persistence.rows.executions.insert({ | |
| 130 | + id: executionId, | |
| 131 | + workspaceId: installation.workspaceId, | |
| 132 | + createdByAccountId: installation.accountId, | |
| 133 | + status: "running", | |
| 134 | + code: "return await tools.executor.sources.add(...)", | |
| 135 | + resultJson: null, | |
| 136 | + errorText: null, | |
| 137 | + logsJson: null, | |
| 138 | + startedAt: now, | |
| 139 | + completedAt: null, | |
| 140 | + createdAt: now, | |
| 141 | + updatedAt: now, | |
| 142 | + }); | |
| 143 | + | |
| 144 | + yield* runtime.persistence.rows.sources.insert({ | |
| 145 | + id: sourceId, | |
| 146 | + workspaceId: installation.workspaceId, | |
| 147 | + name: "GitHub", | |
| 148 | + kind: "openapi", | |
| 149 | + endpoint: "https://api.github.com", | |
| 150 | + status: "auth_required", | |
| 151 | + enabled: true, | |
| 152 | + namespace: "github", | |
| 153 | + transport: null, | |
| 154 | + queryParamsJson: null, | |
| 155 | + headersJson: null, | |
| 156 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 157 | + defaultHeadersJson: null, | |
| 158 | + authKind: "none", | |
| 159 | + authHeaderName: null, | |
| 160 | + authPrefix: null, | |
| 161 | + sourceHash: null, | |
| 162 | + lastError: null, | |
| 163 | + createdAt: now, | |
| 164 | + updatedAt: now, | |
| 165 | + }); | |
| 166 | + | |
| 167 | + const interactionFiber = yield* Effect.gen(function* () { | |
| 168 | + const liveExecutionManager = yield* LiveExecutionManagerService; | |
| 169 | + const onElicitation = liveExecutionManager.createOnElicitation({ | |
| 170 | + rows: runtime.persistence.rows, | |
| 171 | + executionId, | |
| 172 | + }); | |
| 173 | + | |
| 174 | + return yield* onElicitation({ | |
| 175 | + interactionId: interactionSuffix, | |
| 176 | + path: "executor.sources.add" as ToolPath, | |
| 177 | + sourceKey: "executor", | |
| 178 | + args: { | |
| 179 | + kind: "openapi", | |
| 180 | + endpoint: "https://api.github.com", | |
| 181 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 182 | + name: "GitHub", | |
| 183 | + workspaceId: installation.workspaceId, | |
| 184 | + sourceId, | |
| 185 | + }, | |
| 186 | + elicitation: { | |
| 187 | + mode: "url", | |
| 188 | + message: "Open the secure credential page to connect GitHub", | |
| 189 | + url: `http://127.0.0.1/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/${encodeURIComponent(sourceId)}/credentials?interactionId=${encodeURIComponent(interactionId)}`, | |
| 190 | + elicitationId: interactionSuffix, | |
| 191 | + }, | |
| 192 | + }); | |
| 193 | + }).pipe( | |
| 194 | + Effect.provide(runtime.runtimeLayer), | |
| 195 | + Effect.fork, | |
| 196 | + ); | |
| 197 | + | |
| 198 | + yield* Effect.yieldNow(); | |
| 199 | + | |
| 200 | + const pendingInteraction = yield* runtime.persistence.rows.executionInteractions | |
| 201 | + .getPendingByExecutionId(executionId); | |
| 202 | + assertTrue(Option.isSome(pendingInteraction)); | |
| 203 | + expect(pendingInteraction.value.id).toBe(interactionId); | |
| 204 | + | |
| 205 | + const page = yield* withControlPlaneClient( | |
| 206 | + { runtime }, | |
| 207 | + (client) => | |
| 208 | + client.sources.credentialPage({ | |
| 209 | + path: { | |
| 210 | + workspaceId: installation.workspaceId, | |
| 211 | + sourceId, | |
| 212 | + }, | |
| 213 | + urlParams: { | |
| 214 | + interactionId: pendingInteraction.value.id, | |
| 215 | + }, | |
| 216 | + }), | |
| 217 | + ); | |
| 218 | + expect(page).toContain("Configure Source Access"); | |
| 219 | + expect(page).toContain("GitHub"); | |
| 220 | + expect(page).toContain("Continue without auth"); | |
| 221 | + | |
| 222 | + const submittedPage = yield* withControlPlaneClient( | |
| 223 | + { runtime }, | |
| 224 | + (client) => | |
| 225 | + client.sources.credentialSubmit({ | |
| 226 | + path: { | |
| 227 | + workspaceId: installation.workspaceId, | |
| 228 | + sourceId, | |
| 229 | + }, | |
| 230 | + urlParams: { | |
| 231 | + interactionId: pendingInteraction.value.id, | |
| 232 | + }, | |
| 233 | + payload: { | |
| 234 | + action: "submit", | |
| 235 | + token: "ghp_local_test_token", | |
| 236 | + }, | |
| 237 | + }), | |
| 238 | + ); | |
| 239 | + expect(submittedPage).toContain("Credential Stored"); | |
| 240 | + | |
| 241 | + const response = yield* Fiber.join(interactionFiber); | |
| 242 | + expect(response.action).toBe("accept"); | |
| 243 | + expect(response.content?.authKind).toBe("bearer"); | |
| 244 | + expect(typeof response.content?.tokenSecretMaterialId).toBe("string"); | |
| 245 | + | |
| 246 | + const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 247 | + String(response.content?.tokenSecretMaterialId), | |
| 248 | + ); | |
| 249 | + const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById( | |
| 250 | + tokenSecretMaterialId, | |
| 251 | + ); | |
| 252 | + assertTrue(Option.isSome(storedSecret)); | |
| 253 | + expect(storedSecret.value.value).toBe("ghp_local_test_token"); | |
| 254 | + | |
| 255 | + const storedInteraction = yield* runtime.persistence.rows.executionInteractions.getById( | |
| 256 | + pendingInteraction.value.id, | |
| 257 | + ); | |
| 258 | + assertTrue(Option.isSome(storedInteraction)); | |
| 259 | + expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\""); | |
| 260 | + expect(storedInteraction.value.responseJson).toContain("tokenSecretMaterialId"); | |
| 261 | + expect(storedInteraction.value.responseJson).not.toContain("ghp_local_test_token"); | |
| 262 | + }), | |
| 263 | + ); | |
| 264 | + | |
| 265 | + it.scoped("allows continuing an OpenAPI source credential request without auth", () => | |
| 266 | + Effect.gen(function* () { | |
| 267 | + const runtime = yield* makeRuntime; | |
| 268 | + const installation = runtime.localInstallation; | |
| 269 | + const executionId = ExecutionIdSchema.make("exec_local_credential_continue"); | |
| 270 | + const sourceId = SourceIdSchema.make("src_local_credential_continue"); | |
| 271 | + const interactionSuffix = "executor.sources.add:continue"; | |
| 272 | + const interactionId = ExecutionInteractionIdSchema.make( | |
| 273 | + `${executionId}:${interactionSuffix}`, | |
| 274 | + ); | |
| 275 | + const now = Date.now(); | |
| 276 | + | |
| 277 | + yield* runtime.persistence.rows.executions.insert({ | |
| 278 | + id: executionId, | |
| 279 | + workspaceId: installation.workspaceId, | |
| 280 | + createdByAccountId: installation.accountId, | |
| 281 | + status: "running", | |
| 282 | + code: "return await tools.executor.sources.add(...)", | |
| 283 | + resultJson: null, | |
| 284 | + errorText: null, | |
| 285 | + logsJson: null, | |
| 286 | + startedAt: now, | |
| 287 | + completedAt: null, | |
| 288 | + createdAt: now, | |
| 289 | + updatedAt: now, | |
| 290 | + }); | |
| 291 | + | |
| 292 | + yield* runtime.persistence.rows.sources.insert({ | |
| 293 | + id: sourceId, | |
| 294 | + workspaceId: installation.workspaceId, | |
| 295 | + name: "GitHub", | |
| 296 | + kind: "openapi", | |
| 297 | + endpoint: "https://api.github.com", | |
| 298 | + status: "auth_required", | |
| 299 | + enabled: true, | |
| 300 | + namespace: "github", | |
| 301 | + transport: null, | |
| 302 | + queryParamsJson: null, | |
| 303 | + headersJson: null, | |
| 304 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 305 | + defaultHeadersJson: null, | |
| 306 | + authKind: "none", | |
| 307 | + authHeaderName: null, | |
| 308 | + authPrefix: null, | |
| 309 | + sourceHash: null, | |
| 310 | + lastError: null, | |
| 311 | + createdAt: now, | |
| 312 | + updatedAt: now, | |
| 313 | + }); | |
| 314 | + | |
| 315 | + const interactionFiber = yield* Effect.gen(function* () { | |
| 316 | + const liveExecutionManager = yield* LiveExecutionManagerService; | |
| 317 | + const onElicitation = liveExecutionManager.createOnElicitation({ | |
| 318 | + rows: runtime.persistence.rows, | |
| 319 | + executionId, | |
| 320 | + }); | |
| 321 | + | |
| 322 | + return yield* onElicitation({ | |
| 323 | + interactionId: interactionSuffix, | |
| 324 | + path: "executor.sources.add" as ToolPath, | |
| 325 | + sourceKey: "executor", | |
| 326 | + args: { | |
| 327 | + kind: "openapi", | |
| 328 | + endpoint: "https://api.github.com", | |
| 329 | + specUrl: "https://example.com/github-openapi.yaml", | |
| 330 | + name: "GitHub", | |
| 331 | + workspaceId: installation.workspaceId, | |
| 332 | + sourceId, | |
| 333 | + }, | |
| 334 | + elicitation: { | |
| 335 | + mode: "url", | |
| 336 | + message: "Open the secure credential page to connect GitHub", | |
| 337 | + url: `http://127.0.0.1/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/sources/${encodeURIComponent(sourceId)}/credentials?interactionId=${encodeURIComponent(interactionId)}`, | |
| 338 | + elicitationId: interactionSuffix, | |
| 339 | + }, | |
| 340 | + }); | |
| 341 | + }).pipe( | |
| 342 | + Effect.provide(runtime.runtimeLayer), | |
| 343 | + Effect.fork, | |
| 344 | + ); | |
| 345 | + | |
| 346 | + yield* Effect.yieldNow(); | |
| 347 | + | |
| 348 | + const pendingInteraction = yield* runtime.persistence.rows.executionInteractions | |
| 349 | + .getPendingByExecutionId(executionId); | |
| 350 | + assertTrue(Option.isSome(pendingInteraction)); | |
| 351 | + | |
| 352 | + const submittedPage = yield* withControlPlaneClient( | |
| 353 | + { runtime }, | |
| 354 | + (client) => | |
| 355 | + client.sources.credentialSubmit({ | |
| 356 | + path: { | |
| 357 | + workspaceId: installation.workspaceId, | |
| 358 | + sourceId, | |
| 359 | + }, | |
| 360 | + urlParams: { | |
| 361 | + interactionId: pendingInteraction.value.id, | |
| 362 | + }, | |
| 363 | + payload: { | |
| 364 | + action: "continue", | |
| 365 | + }, | |
| 366 | + }), | |
| 367 | + ); | |
| 368 | + | |
| 369 | + expect(submittedPage).toContain("Continuing without auth"); | |
| 370 | + | |
| 371 | + const response = yield* Fiber.join(interactionFiber); | |
| 372 | + expect(response.action).toBe("accept"); | |
| 373 | + expect(response.content).toEqual({ | |
| 374 | + authKind: "none", | |
| 375 | + }); | |
| 376 | + | |
| 377 | + const storedInteraction = yield* runtime.persistence.rows.executionInteractions.getById( | |
| 378 | + pendingInteraction.value.id, | |
| 379 | + ); | |
| 380 | + assertTrue(Option.isSome(storedInteraction)); | |
| 381 | + expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"none\""); | |
| 382 | + expect(storedInteraction.value.responseJson).not.toContain("tokenSecretMaterialId"); | |
| 383 | + }), | |
| 384 | + ); | |
| 385 | + | |
| 105 | 386 | it.scoped("scopes organization list/get to memberships", () => |
| 106 | 387 | Effect.gen(function* () { |
| 107 | 388 | const runtime = yield* makeRuntime; |
| @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | type ExecutorAddSourceInput, |
| 22 | + type ExecutorOpenApiSourceAuthInput, | |
| 22 | 23 | type RuntimeSourceAuthService, |
| 23 | 24 | } from "./source-auth-service"; |
| 24 | 25 | import { |
| @@ -26,19 +27,6 @@ import { · 1 unmodified line | ||
| 26 | 27 | deriveSchemaTypeSignature, |
| 27 | 28 | } from "./schema-type-signature"; |
| 28 | 29 | |
| 29 | −const ExecutorOpenApiSourceAuthInputSchema = Schema.Union( | |
| 30 | − Schema.Struct({ | |
| 31 | − kind: Schema.Literal("none"), | |
| 32 | − }), | |
| 33 | − Schema.Struct({ | |
| 34 | − kind: Schema.Literal("bearer"), | |
| 35 | − headerName: Schema.optional(Schema.NullOr(Schema.String)), | |
| 36 | − prefix: Schema.optional(Schema.NullOr(Schema.String)), | |
| 37 | − token: Schema.optional(Schema.NullOr(Schema.String)), | |
| 38 | − tokenEnvVar: Schema.optional(Schema.NullOr(Schema.String)), | |
| 39 | − }), | |
| 40 | −); | |
| 41 | − | |
| 42 | 30 | const ExecutorMcpSourceAddInputSchema = Schema.Struct({ |
| 43 | 31 | kind: Schema.optional(Schema.Literal("mcp")), |
| 44 | 32 | endpoint: Schema.String, |
| @@ -52,7 +40,6 @@ const ExecutorOpenApiSourceAddInputSchema = Schema.Struct({ · 7 unmodified lines | ||
| 52 | 40 | specUrl: Schema.String, |
| 53 | 41 | name: Schema.optional(Schema.NullOr(Schema.String)), |
| 54 | 42 | namespace: Schema.optional(Schema.NullOr(Schema.String)), |
| 55 | − auth: Schema.optional(Schema.NullOr(ExecutorOpenApiSourceAuthInputSchema)), | |
| 56 | 43 | }); |
| 57 | 44 | |
| 58 | 45 | const ExecutorSourcesAddSchema = Schema.Union( |
| @@ -100,7 +87,7 @@ export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ · 41 unmodified lines | ||
| 100 | 87 | ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.', |
| 101 | 88 | `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`, |
| 102 | 89 | " endpoint is the base API URL. specUrl is the OpenAPI document URL.", |
| 103 | − " If credentials are needed, executor prompts with form or URL interaction.", | |
| 90 | + " executor handles the credential setup for you.", | |
| 104 | 91 | ] as const; |
| 105 | 92 | |
| 106 | 93 | export const buildExecutorSourcesAddDescription = (): string => |
| @@ -133,11 +120,34 @@ const resolveOpenApiSourceLabel = (input: { · 26 unmodified lines | ||
| 133 | 120 | endpoint: string; |
| 134 | 121 | }): string => trimOrNull(input.name) ?? input.endpoint; |
| 135 | 122 | |
| 136 | −const promptForBearerToken = (input: { | |
| 137 | − args: Extract<ExecutorAddSourceInput, { kind: "openapi" }>; | |
| 123 | +const resolveLocalCredentialUrl = (input: { | |
| 124 | + baseUrl: string; | |
| 125 | + workspaceId: WorkspaceId; | |
| 126 | + sourceId: Source["id"]; | |
| 127 | + executionId: string; | |
| 128 | + interactionId: string; | |
| 129 | +}): string => | |
| 130 | + new URL( | |
| 131 | + `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/sources/${encodeURIComponent(input.sourceId)}/credentials?interactionId=${encodeURIComponent(`${input.executionId}:${input.interactionId}`)}`, | |
| 132 | + input.baseUrl, | |
| 133 | + ).toString(); | |
| 134 | + | |
| 135 | +const promptForSourceCredentialSelection = (input: { | |
| 136 | + args: { | |
| 137 | + workspaceId: WorkspaceId; | |
| 138 | + sourceId: Source["id"]; | |
| 139 | + kind: "openapi"; | |
| 140 | + endpoint: string; | |
| 141 | + specUrl: string; | |
| 142 | + name?: string | null; | |
| 143 | + namespace?: string | null; | |
| 144 | + }; | |
| 145 | + source: Source; | |
| 146 | + executionId: string; | |
| 138 | 147 | interactionId: string; |
| 139 | 148 | path: ToolPath; |
| 140 | 149 | sourceKey: string; |
| 150 | + localServerBaseUrl: string | null; | |
| 141 | 151 | metadata?: ToolMetadata; |
| 142 | 152 | invocation?: ToolInvocationContext; |
| 143 | 153 | onElicitation?: OnElicitation; |
| @@ -149,6 +159,12 @@ const promptForBearerToken = (input: { · 5 unmodified lines | ||
| 149 | 159 | ); |
| 150 | 160 | } |
| 151 | 161 | |
| 162 | + if (input.localServerBaseUrl === null) { | |
| 163 | + return yield* Effect.fail( | |
| 164 | + new Error("executor.sources.add requires a local server base URL for credential capture"), | |
| 165 | + ); | |
| 166 | + } | |
| 167 | + | |
| 152 | 168 | const response: ElicitationResponse = yield* input.onElicitation({ |
| 153 | 169 | interactionId: input.interactionId, |
| 154 | 170 | path: input.path, |
| @@ -157,40 +173,49 @@ const promptForBearerToken = (input: { · 2 unmodified lines | ||
| 157 | 173 | metadata: input.metadata, |
| 158 | 174 | context: input.invocation, |
| 159 | 175 | elicitation: { |
| 160 | − mode: "form", | |
| 161 | − message: `Enter the API token to connect ${resolveOpenApiSourceLabel(input.args)}`, | |
| 162 | − requestedSchema: { | |
| 163 | − type: "object", | |
| 164 | − properties: { | |
| 165 | − token: { | |
| 166 | − type: "string", | |
| 167 | − title: "API token", | |
| 168 | − }, | |
| 169 | − }, | |
| 170 | − required: ["token"], | |
| 171 | − additionalProperties: false, | |
| 172 | − }, | |
| 176 | + mode: "url", | |
| 177 | + message: `Open the secure credential page to connect ${input.source.name}`, | |
| 178 | + url: resolveLocalCredentialUrl({ | |
| 179 | + baseUrl: input.localServerBaseUrl, | |
| 180 | + workspaceId: input.args.workspaceId, | |
| 181 | + sourceId: input.args.sourceId, | |
| 182 | + executionId: input.executionId, | |
| 183 | + interactionId: input.interactionId, | |
| 184 | + }), | |
| 185 | + elicitationId: input.interactionId, | |
| 173 | 186 | }, |
| 174 | 187 | }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause)))); |
| 175 | 188 | |
| 176 | 189 | if (response.action !== "accept") { |
| 177 | 190 | return yield* Effect.fail( |
| 178 | − new Error(`Source add was not completed for ${resolveOpenApiSourceLabel(input.args)}`), | |
| 191 | + new Error(`Source credential setup was not completed for ${input.source.name}`), | |
| 179 | 192 | ); |
| 180 | 193 | } |
| 181 | 194 | |
| 182 | − const token = | |
| 183 | − response.content && typeof response.content.token === "string" | |
| 184 | − ? response.content.token.trim() | |
| 195 | + const authKind = | |
| 196 | + response.content && typeof response.content.authKind === "string" | |
| 197 | + ? response.content.authKind.trim() | |
| 198 | + : ""; | |
| 199 | + | |
| 200 | + if (authKind === "none") { | |
| 201 | + return { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput; | |
| 202 | + } | |
| 203 | + | |
| 204 | + const tokenSecretMaterialId = | |
| 205 | + response.content && typeof response.content.tokenSecretMaterialId === "string" | |
| 206 | + ? response.content.tokenSecretMaterialId.trim() | |
| 185 | 207 | : ""; |
| 186 | 208 | |
| 187 | − if (token.length === 0) { | |
| 209 | + if (authKind !== "bearer" || tokenSecretMaterialId.length === 0) { | |
| 188 | 210 | return yield* Effect.fail( |
| 189 | − new Error("API token was not provided for executor.sources.add"), | |
| 211 | + new Error("Credential capture did not return a valid source auth choice for executor.sources.add"), | |
| 190 | 212 | ); |
| 191 | 213 | } |
| 192 | 214 | |
| 193 | − return token; | |
| 215 | + return { | |
| 216 | + kind: "bearer", | |
| 217 | + tokenSecretMaterialId, | |
| 218 | + } satisfies ExecutorOpenApiSourceAuthInput; | |
| 194 | 219 | }); |
| 195 | 220 | |
| 196 | 221 | export const createExecutorToolMap = (input: { |
| @@ -205,30 +230,18 @@ export const createExecutorToolMap = (input: { · 8 unmodified lines | ||
| 205 | 230 | execute: async ( |
| 206 | 231 | args: |
| 207 | 232 | | { |
| 208 | − kind?: "mcp"; | |
| 209 | − endpoint: string; | |
| 210 | − name?: string | null; | |
| 211 | − namespace?: string | null; | |
| 212 | − } | |
| 233 | + kind?: "mcp"; | |
| 234 | + endpoint: string; | |
| 235 | + name?: string | null; | |
| 236 | + namespace?: string | null; | |
| 237 | + } | |
| 213 | 238 | | { |
| 214 | − kind: "openapi"; | |
| 215 | − endpoint: string; | |
| 216 | − specUrl: string; | |
| 217 | − name?: string | null; | |
| 218 | − namespace?: string | null; | |
| 219 | − auth?: | |
| 220 | − | { | |
| 221 | − kind: "none"; | |
| 222 | − } | |
| 223 | − | { | |
| 224 | − kind: "bearer"; | |
| 225 | − headerName?: string | null; | |
| 226 | − prefix?: string | null; | |
| 227 | − token?: string | null; | |
| 228 | − tokenEnvVar?: string | null; | |
| 229 | − } | |
| 230 | − | null; | |
| 231 | − }, | |
| 239 | + kind: "openapi"; | |
| 240 | + endpoint: string; | |
| 241 | + specUrl: string; | |
| 242 | + name?: string | null; | |
| 243 | + namespace?: string | null; | |
| 244 | + }, | |
| 232 | 245 | context, |
| 233 | 246 | ): Promise<Source> => { |
| 234 | 247 | const executionId = toExecutionId(context?.invocation?.runId); |
| @@ -238,50 +251,74 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines | ||
| 238 | 251 | const preparedArgs: ExecutorAddSourceInput = |
| 239 | 252 | args.kind === "openapi" |
| 240 | 253 | ? { |
| 241 | − ...args, | |
| 242 | − workspaceId: input.workspaceId, | |
| 243 | − executionId, | |
| 244 | − interactionId, | |
| 245 | − auth: | |
| 246 | − args.auth?.kind === "bearer" | |
| 247 | − && !trimOrNull(args.auth.token) | |
| 248 | − && !trimOrNull(args.auth.tokenEnvVar) | |
| 249 | − ? { | |
| 250 | − ...args.auth, | |
| 251 | − token: await Effect.runPromise( | |
| 252 | − promptForBearerToken({ | |
| 253 | − args: { | |
| 254 | − ...args, | |
| 255 | − auth: args.auth, | |
| 256 | − workspaceId: input.workspaceId, | |
| 257 | − executionId, | |
| 258 | − interactionId, | |
| 259 | − }, | |
| 260 | − interactionId, | |
| 261 | − path: context.path ?? asToolPath("executor.sources.add"), | |
| 262 | − sourceKey: context.sourceKey, | |
| 263 | − metadata: context.metadata, | |
| 264 | − invocation: context.invocation, | |
| 265 | − onElicitation: context.onElicitation, | |
| 266 | − }), | |
| 267 | − ), | |
| 268 | − } | |
| 269 | − : args.auth ?? null, | |
| 270 | − } | |
| 254 | + ...args, | |
| 255 | + workspaceId: input.workspaceId, | |
| 256 | + executionId, | |
| 257 | + interactionId, | |
| 258 | + } | |
| 271 | 259 | : { |
| 272 | − kind: args.kind, | |
| 273 | − endpoint: args.endpoint, | |
| 274 | − name: args.name ?? null, | |
| 275 | − namespace: args.namespace ?? null, | |
| 276 | − workspaceId: input.workspaceId, | |
| 277 | − executionId, | |
| 278 | − interactionId, | |
| 279 | − }; | |
| 260 | + kind: args.kind, | |
| 261 | + endpoint: args.endpoint, | |
| 262 | + name: args.name ?? null, | |
| 263 | + namespace: args.namespace ?? null, | |
| 264 | + workspaceId: input.workspaceId, | |
| 265 | + executionId, | |
| 266 | + interactionId, | |
| 267 | + }; | |
| 280 | 268 | const result = await Effect.runPromise( |
| 281 | 269 | input.sourceAuthService.addExecutorSource( |
| 282 | 270 | preparedArgs, |
| 283 | 271 | context?.onElicitation |
| 284 | 272 | ? { |
| 273 | + mcpDiscoveryElicitation: { | |
| 274 | + onElicitation: context.onElicitation, | |
| 275 | + path: context.path ?? asToolPath("executor.sources.add"), | |
| 276 | + sourceKey: context.sourceKey, | |
| 277 | + args, | |
| 278 | + metadata: context.metadata, | |
| 279 | + invocation: context.invocation, | |
| 280 | + }, | |
| 281 | + } | |
| 282 | + : undefined, | |
| 283 | + ), | |
| 284 | + ); | |
| 285 | + | |
| 286 | + if (result.kind === "connected") { | |
| 287 | + return result.source; | |
| 288 | + } | |
| 289 | + | |
| 290 | + if (result.kind === "credential_required") { | |
| 291 | + const preparedOpenApiArgs = preparedArgs as Extract< | |
| 292 | + ExecutorAddSourceInput, | |
| 293 | + { kind: "openapi" } | |
| 294 | + >; | |
| 295 | + const selectedAuth = await Effect.runPromise( | |
| 296 | + promptForSourceCredentialSelection({ | |
| 297 | + args: { | |
| 298 | + ...preparedOpenApiArgs, | |
| 299 | + workspaceId: input.workspaceId, | |
| 300 | + sourceId: result.source.id, | |
| 301 | + }, | |
| 302 | + source: result.source, | |
| 303 | + executionId, | |
| 304 | + interactionId, | |
| 305 | + path: context?.path ?? asToolPath("executor.sources.add"), | |
| 306 | + sourceKey: context?.sourceKey ?? "executor", | |
| 307 | + localServerBaseUrl: input.sourceAuthService.getLocalServerBaseUrl(), | |
| 308 | + metadata: context?.metadata, | |
| 309 | + invocation: context?.invocation, | |
| 310 | + onElicitation: context?.onElicitation, | |
| 311 | + }), | |
| 312 | + ); | |
| 313 | + | |
| 314 | + const completed = await Effect.runPromise( | |
| 315 | + input.sourceAuthService.addExecutorSource( | |
| 316 | + { | |
| 317 | + ...preparedOpenApiArgs, | |
| 318 | + auth: selectedAuth, | |
| 319 | + }, | |
| 320 | + context?.onElicitation | |
| 321 | + ? { | |
| 285 | 322 | mcpDiscoveryElicitation: { |
| 286 | 323 | onElicitation: context.onElicitation, |
| 287 | 324 | path: context.path ?? asToolPath("executor.sources.add"), |
| @@ -291,12 +328,15 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines | ||
| 291 | 328 | invocation: context.invocation, |
| 292 | 329 | }, |
| 293 | 330 | } |
| 294 | − : undefined, | |
| 295 | − ), | |
| 296 | − ); | |
| 331 | + : undefined, | |
| 332 | + ), | |
| 333 | + ); | |
| 297 | 334 | |
| 298 | − if (result.kind === "connected") { | |
| 299 | − return result.source; | |
| 335 | + if (completed.kind === "connected") { | |
| 336 | + return completed.source; | |
| 337 | + } | |
| 338 | + | |
| 339 | + throw new Error(`Source add was not completed for ${result.source.id}`); | |
| 300 | 340 | } |
| 301 | 341 | |
| 302 | 342 | if (!context?.onElicitation) { |
| @@ -332,13 +372,13 @@ export const createExecutorToolMap = (input: { · 29 unmodified lines | ||
| 332 | 372 | ); |
| 333 | 373 | }, |
| 334 | 374 | }, |
| 335 | − metadata: { | |
| 336 | − inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT, | |
| 337 | − outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE, | |
| 338 | − inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON, | |
| 339 | − outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON, | |
| 340 | − sourceKey: "executor", | |
| 341 | − interaction: "auto", | |
| 342 | − }, | |
| 343 | − }), | |
| 375 | + metadata: { | |
| 376 | + inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT, | |
| 377 | + outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE, | |
| 378 | + inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON, | |
| 379 | + outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON, | |
| 380 | + sourceKey: "executor", | |
| 381 | + interaction: "auto", | |
| 382 | + }, | |
| 383 | + }), | |
| 344 | 384 | }); |
| @@ -103,10 +103,19 @@ const createRuntimeLayerFromContext = ( · 102 unmodified lines | ||
| 103 | 103 | | RuntimeSourceAuthServiceTag |
| 104 | 104 | | RuntimeExecutionResolverService |
| 105 | 105 | >, |
| 106 | −): RuntimeControlPlaneLayer => | |
| 107 | − Layer.succeedContext( | |
| 108 | − context.pipe(Context.pick(...runtimeContextTags)), | |
| 109 | − ); | |
| 106 | +): RuntimeControlPlaneLayer => { | |
| 107 | + const runtimeContext = context.pipe( | |
| 108 | + Context.pick(...runtimeContextTags), | |
| 109 | + ) as Context.Context< | |
| 110 | + ControlPlaneActorResolver | |
| 111 | + | ControlPlaneStore | |
| 112 | + | LiveExecutionManagerService | |
| 113 | + | RuntimeSourceAuthServiceTag | |
| 114 | + | RuntimeExecutionResolverService | |
| 115 | + >; | |
| 116 | + | |
| 117 | + return Layer.succeedContext(runtimeContext); | |
| 118 | +}; | |
| 110 | 119 | |
| 111 | 120 | export const createRuntimeControlPlaneLayer = ( |
| 112 | 121 | options: RuntimeControlPlaneOptions = {}, |
| @@ -1,7 +1,19 @@ | ||
| 1 | 1 | import { ControlPlanePersistenceError } from "#persistence"; |
| 2 | +import type { | |
| 3 | + Execution, | |
| 4 | + ExecutionInteraction, | |
| 5 | + Source, | |
| 6 | + WorkspaceId, | |
| 7 | +} from "#schema"; | |
| 8 | +import { | |
| 9 | + SourceIdSchema, | |
| 10 | + WorkspaceIdSchema, | |
| 11 | +} from "#schema"; | |
| 2 | 12 | import * as Effect from "effect/Effect"; |
| 13 | +import * as Option from "effect/Option"; | |
| 3 | 14 | |
| 4 | 15 | import { loadLocalInstallation } from "./local-installation"; |
| 16 | +import { LiveExecutionManagerService } from "./live-execution"; | |
| 5 | 17 | import { |
| 6 | 18 | operationErrors, |
| 7 | 19 | } from "./operation-errors"; |
| @@ -10,9 +22,145 @@ import { ControlPlaneStore } from "./store"; · 2 unmodified lines | ||
| 10 | 22 | |
| 11 | 23 | const localOps = { |
| 12 | 24 | installation: operationErrors("local.installation.get"), |
| 13 | − oauthCallback: operationErrors("local.oauth.callback"), | |
| 25 | + sourceCredentialComplete: operationErrors("sources.credentials.complete"), | |
| 26 | + sourceCredentialPage: operationErrors("sources.credentials.page"), | |
| 27 | + sourceCredentialSubmit: operationErrors("sources.credentials.submit"), | |
| 14 | 28 | } as const; |
| 15 | 29 | |
| 30 | +type SourceCredentialInteraction = { | |
| 31 | + interactionId: ExecutionInteraction["id"]; | |
| 32 | + executionId: Execution["id"]; | |
| 33 | + status: ExecutionInteraction["status"]; | |
| 34 | + message: string; | |
| 35 | + workspaceId: WorkspaceId; | |
| 36 | + sourceId: Source["id"]; | |
| 37 | + sourceLabel: string; | |
| 38 | + endpoint: string; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const isRecord = (value: unknown): value is Record<string, unknown> => | |
| 42 | + typeof value === "object" && value !== null; | |
| 43 | + | |
| 44 | +const asString = (value: unknown): string | null => | |
| 45 | + typeof value === "string" ? value : null; | |
| 46 | + | |
| 47 | +const trimOrNull = (value: string | null | undefined): string | null => { | |
| 48 | + if (value === null || value === undefined) { | |
| 49 | + return null; | |
| 50 | + } | |
| 51 | + | |
| 52 | + const trimmed = value.trim(); | |
| 53 | + return trimmed.length > 0 ? trimmed : null; | |
| 54 | +}; | |
| 55 | + | |
| 56 | +const decodeSourceCredentialInteraction = ( | |
| 57 | + interaction: ExecutionInteraction, | |
| 58 | +): Omit<SourceCredentialInteraction, "sourceLabel" | "endpoint"> | null => { | |
| 59 | + try { | |
| 60 | + const payload = JSON.parse(interaction.payloadJson) as unknown; | |
| 61 | + if (!isRecord(payload)) { | |
| 62 | + return null; | |
| 63 | + } | |
| 64 | + | |
| 65 | + const args = payload.args; | |
| 66 | + const elicitation = payload.elicitation; | |
| 67 | + if (!isRecord(args) || !isRecord(elicitation)) { | |
| 68 | + return null; | |
| 69 | + } | |
| 70 | + | |
| 71 | + if ( | |
| 72 | + payload.path !== "executor.sources.add" | |
| 73 | + || args.kind !== "openapi" | |
| 74 | + || elicitation.mode !== "url" | |
| 75 | + ) { | |
| 76 | + return null; | |
| 77 | + } | |
| 78 | + | |
| 79 | + const workspaceId = trimOrNull(asString(args.workspaceId)); | |
| 80 | + const sourceId = trimOrNull(asString(args.sourceId)); | |
| 81 | + const message = trimOrNull(asString(elicitation.message)); | |
| 82 | + if (workspaceId === null || sourceId === null || message === null) { | |
| 83 | + return null; | |
| 84 | + } | |
| 85 | + | |
| 86 | + return { | |
| 87 | + interactionId: interaction.id, | |
| 88 | + executionId: interaction.executionId, | |
| 89 | + status: interaction.status, | |
| 90 | + message, | |
| 91 | + workspaceId: WorkspaceIdSchema.make(workspaceId), | |
| 92 | + sourceId: SourceIdSchema.make(sourceId), | |
| 93 | + }; | |
| 94 | + } catch { | |
| 95 | + return null; | |
| 96 | + } | |
| 97 | +}; | |
| 98 | + | |
| 99 | +const loadSourceCredentialInteraction = (input: { | |
| 100 | + workspaceId: WorkspaceId; | |
| 101 | + sourceId: Source["id"]; | |
| 102 | + interactionId: ExecutionInteraction["id"]; | |
| 103 | + operation: | |
| 104 | + | typeof localOps.sourceCredentialPage | |
| 105 | + | typeof localOps.sourceCredentialSubmit; | |
| 106 | +}) => | |
| 107 | + Effect.gen(function* () { | |
| 108 | + const store = yield* ControlPlaneStore; | |
| 109 | + const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 110 | + | |
| 111 | + const stored = yield* store.executionInteractions.getById(input.interactionId).pipe( | |
| 112 | + Effect.mapError((error) => | |
| 113 | + error instanceof ControlPlanePersistenceError | |
| 114 | + ? input.operation.storage(error) | |
| 115 | + : input.operation.unknownStorage( | |
| 116 | + error, | |
| 117 | + `Failed loading execution interaction ${input.interactionId}`, | |
| 118 | + ), | |
| 119 | + ), | |
| 120 | + ); | |
| 121 | + | |
| 122 | + if (Option.isNone(stored)) { | |
| 123 | + return yield* Effect.fail( | |
| 124 | + input.operation.notFound( | |
| 125 | + "Source credential request not found", | |
| 126 | + `interactionId=${input.interactionId}`, | |
| 127 | + ), | |
| 128 | + ); | |
| 129 | + } | |
| 130 | + | |
| 131 | + const decoded = decodeSourceCredentialInteraction(stored.value); | |
| 132 | + if ( | |
| 133 | + decoded === null | |
| 134 | + || decoded.workspaceId !== input.workspaceId | |
| 135 | + || decoded.sourceId !== input.sourceId | |
| 136 | + ) { | |
| 137 | + return yield* Effect.fail( | |
| 138 | + input.operation.notFound( | |
| 139 | + "Source credential request not found", | |
| 140 | + `workspaceId=${input.workspaceId} sourceId=${input.sourceId} interactionId=${input.interactionId}`, | |
| 141 | + ), | |
| 142 | + ); | |
| 143 | + } | |
| 144 | + | |
| 145 | + const source = yield* sourceAuthService.getSourceById({ | |
| 146 | + workspaceId: input.workspaceId, | |
| 147 | + sourceId: input.sourceId, | |
| 148 | + }).pipe( | |
| 149 | + Effect.mapError((error) => | |
| 150 | + input.operation.unknownStorage( | |
| 151 | + error, | |
| 152 | + `Failed loading source ${input.sourceId}`, | |
| 153 | + ), | |
| 154 | + ), | |
| 155 | + ); | |
| 156 | + | |
| 157 | + return { | |
| 158 | + ...decoded, | |
| 159 | + sourceLabel: source.name, | |
| 160 | + endpoint: source.endpoint, | |
| 161 | + } satisfies SourceCredentialInteraction; | |
| 162 | + }); | |
| 163 | + | |
| 16 | 164 | export const getLocalInstallation = () => |
| 17 | 165 | Effect.gen(function* () { |
| 18 | 166 | const store = yield* ControlPlaneStore; |
| @@ -40,7 +188,147 @@ export const getLocalInstallation = () => · 21 unmodified lines | ||
| 40 | 188 | return installation; |
| 41 | 189 | }); |
| 42 | 190 | |
| 43 | −export const completeSourceAuthCallback = (input: { | |
| 191 | +export const getSourceCredentialInteraction = (input: { | |
| 192 | + workspaceId: WorkspaceId; | |
| 193 | + sourceId: Source["id"]; | |
| 194 | + interactionId: ExecutionInteraction["id"]; | |
| 195 | +}) => | |
| 196 | + loadSourceCredentialInteraction({ | |
| 197 | + ...input, | |
| 198 | + operation: localOps.sourceCredentialPage, | |
| 199 | + }); | |
| 200 | + | |
| 201 | +export const submitSourceCredentialInteraction = (input: { | |
| 202 | + workspaceId: WorkspaceId; | |
| 203 | + sourceId: Source["id"]; | |
| 204 | + interactionId: ExecutionInteraction["id"]; | |
| 205 | + action: "submit" | "continue" | "cancel"; | |
| 206 | + token?: string | null; | |
| 207 | +}) => | |
| 208 | + Effect.gen(function* () { | |
| 209 | + const interaction = yield* loadSourceCredentialInteraction({ | |
| 210 | + workspaceId: input.workspaceId, | |
| 211 | + sourceId: input.sourceId, | |
| 212 | + interactionId: input.interactionId, | |
| 213 | + operation: localOps.sourceCredentialSubmit, | |
| 214 | + }); | |
| 215 | + | |
| 216 | + if (interaction.status !== "pending") { | |
| 217 | + return yield* Effect.fail( | |
| 218 | + localOps.sourceCredentialSubmit.badRequest( | |
| 219 | + "Source credential request is no longer active", | |
| 220 | + `interactionId=${interaction.interactionId} status=${interaction.status}`, | |
| 221 | + ), | |
| 222 | + ); | |
| 223 | + } | |
| 224 | + | |
| 225 | + const liveExecutionManager = yield* LiveExecutionManagerService; | |
| 226 | + | |
| 227 | + if (input.action === "cancel") { | |
| 228 | + const resumed = yield* liveExecutionManager.resolveInteraction({ | |
| 229 | + executionId: interaction.executionId, | |
| 230 | + response: { | |
| 231 | + action: "cancel", | |
| 232 | + }, | |
| 233 | + }); | |
| 234 | + | |
| 235 | + if (!resumed) { | |
| 236 | + return yield* Effect.fail( | |
| 237 | + localOps.sourceCredentialSubmit.badRequest( | |
| 238 | + "Source credential request is no longer resumable", | |
| 239 | + `interactionId=${interaction.interactionId}`, | |
| 240 | + ), | |
| 241 | + ); | |
| 242 | + } | |
| 243 | + | |
| 244 | + return { | |
| 245 | + kind: "cancelled" as const, | |
| 246 | + sourceLabel: interaction.sourceLabel, | |
| 247 | + endpoint: interaction.endpoint, | |
| 248 | + }; | |
| 249 | + } | |
| 250 | + | |
| 251 | + if (input.action === "continue") { | |
| 252 | + const resumed = yield* liveExecutionManager.resolveInteraction({ | |
| 253 | + executionId: interaction.executionId, | |
| 254 | + response: { | |
| 255 | + action: "accept", | |
| 256 | + content: { | |
| 257 | + authKind: "none", | |
| 258 | + }, | |
| 259 | + }, | |
| 260 | + }); | |
| 261 | + | |
| 262 | + if (!resumed) { | |
| 263 | + return yield* Effect.fail( | |
| 264 | + localOps.sourceCredentialSubmit.badRequest( | |
| 265 | + "Source credential request is no longer resumable", | |
| 266 | + `interactionId=${interaction.interactionId}`, | |
| 267 | + ), | |
| 268 | + ); | |
| 269 | + } | |
| 270 | + | |
| 271 | + return { | |
| 272 | + kind: "continued" as const, | |
| 273 | + sourceLabel: interaction.sourceLabel, | |
| 274 | + endpoint: interaction.endpoint, | |
| 275 | + }; | |
| 276 | + } | |
| 277 | + | |
| 278 | + const token = trimOrNull(input.token); | |
| 279 | + if (token === null) { | |
| 280 | + return yield* Effect.fail( | |
| 281 | + localOps.sourceCredentialSubmit.badRequest( | |
| 282 | + "Missing token", | |
| 283 | + `interactionId=${interaction.interactionId}`, | |
| 284 | + ), | |
| 285 | + ); | |
| 286 | + } | |
| 287 | + | |
| 288 | + const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 289 | + const tokenSecretMaterialId = yield* sourceAuthService.storeSecretMaterial({ | |
| 290 | + purpose: "auth_material", | |
| 291 | + value: token, | |
| 292 | + }).pipe( | |
| 293 | + Effect.mapError((error) => | |
| 294 | + error instanceof ControlPlanePersistenceError | |
| 295 | + ? localOps.sourceCredentialSubmit.storage(error) | |
| 296 | + : localOps.sourceCredentialSubmit.unknownStorage( | |
| 297 | + error, | |
| 298 | + `Failed storing credential material for interaction ${interaction.interactionId}`, | |
| 299 | + ), | |
| 300 | + ), | |
| 301 | + ); | |
| 302 | + const resumed = yield* liveExecutionManager.resolveInteraction({ | |
| 303 | + executionId: interaction.executionId, | |
| 304 | + response: { | |
| 305 | + action: "accept", | |
| 306 | + content: { | |
| 307 | + authKind: "bearer", | |
| 308 | + tokenSecretMaterialId, | |
| 309 | + }, | |
| 310 | + }, | |
| 311 | + }); | |
| 312 | + | |
| 313 | + if (!resumed) { | |
| 314 | + return yield* Effect.fail( | |
| 315 | + localOps.sourceCredentialSubmit.badRequest( | |
| 316 | + "Source credential request is no longer resumable", | |
| 317 | + `interactionId=${interaction.interactionId}`, | |
| 318 | + ), | |
| 319 | + ); | |
| 320 | + } | |
| 321 | + | |
| 322 | + return { | |
| 323 | + kind: "stored" as const, | |
| 324 | + sourceLabel: interaction.sourceLabel, | |
| 325 | + endpoint: interaction.endpoint, | |
| 326 | + }; | |
| 327 | + }); | |
| 328 | + | |
| 329 | +export const completeSourceCredentialSetup = (input: { | |
| 330 | + workspaceId: WorkspaceId; | |
| 331 | + sourceId: Source["id"]; | |
| 44 | 332 | state: string; |
| 45 | 333 | code?: string | null; |
| 46 | 334 | error?: string | null; |
| @@ -49,11 +337,11 @@ export const completeSourceAuthCallback = (input: { · 2 unmodified lines | ||
| 49 | 337 | Effect.gen(function* () { |
| 50 | 338 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 51 | 339 | |
| 52 | − return yield* sourceAuthService.completeSourceAuthCallback(input).pipe( | |
| 340 | + return yield* sourceAuthService.completeSourceCredentialSetup(input).pipe( | |
| 53 | 341 | Effect.mapError((error) => |
| 54 | − localOps.oauthCallback.unknownStorage( | |
| 342 | + localOps.sourceCredentialComplete.unknownStorage( | |
| 55 | 343 | error, |
| 56 | − "Failed completing source auth callback", | |
| 344 | + "Failed completing source credential setup", | |
| 57 | 345 | ), |
| 58 | 346 | ), |
| 59 | 347 | ); |
| @@ -18,6 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | } from "#persistence"; |
| 19 | 19 | import { |
| 20 | 20 | ExecutionIdSchema, |
| 21 | + type SecretMaterialId, | |
| 21 | 22 | SecretMaterialIdSchema, |
| 22 | 23 | Source, |
| 23 | 24 | SourceAuthSession, |
| @@ -52,7 +53,6 @@ import { · 28 unmodified lines | ||
| 52 | 53 | } from "./tool-artifacts"; |
| 53 | 54 | |
| 54 | 55 | export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane"; |
| 55 | −const OAUTH_CALLBACK_PATH = "/v1/local/oauth/callback"; | |
| 56 | 56 | |
| 57 | 57 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 58 | 58 | if (value === null || value === undefined) { |
| @@ -101,8 +101,15 @@ const decodeJson = <A>(input: { · 42 unmodified lines | ||
| 101 | 101 | } |
| 102 | 102 | }; |
| 103 | 103 | |
| 104 | −const resolveRedirectUrl = (baseUrl: string): string => | |
| 105 | − new URL(OAUTH_CALLBACK_PATH, baseUrl).toString(); | |
| 104 | +const resolveSourceCredentialOauthCompleteUrl = (input: { | |
| 105 | + baseUrl: string; | |
| 106 | + workspaceId: WorkspaceId; | |
| 107 | + sourceId: Source["id"]; | |
| 108 | +}): string => | |
| 109 | + new URL( | |
| 110 | + `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/sources/${encodeURIComponent(input.sourceId)}/credentials/oauth/complete`, | |
| 111 | + input.baseUrl, | |
| 112 | + ).toString(); | |
| 106 | 113 | |
| 107 | 114 | const normalizeEndpoint = (endpoint: string): string => { |
| 108 | 115 | const url = new URL(endpoint.trim()); |
| @@ -347,7 +354,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 238 unmodified lines | ||
| 347 | 354 | captured.tokens = tokens; |
| 348 | 355 | }, |
| 349 | 356 | redirectToAuthorization: () => { |
| 350 | − throw new Error("Unexpected redirect during OAuth callback completion"); | |
| 357 | + throw new Error("Unexpected redirect while completing source credential setup"); | |
| 351 | 358 | }, |
| 352 | 359 | saveCodeVerifier: () => undefined, |
| 353 | 360 | codeVerifier: () => { |
| @@ -373,7 +380,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 19 unmodified lines | ||
| 373 | 380 | }); |
| 374 | 381 | |
| 375 | 382 | if (result !== "AUTHORIZED" || !captured.tokens) { |
| 376 | − return yield* Effect.fail(new Error("OAuth callback did not complete authorization")); | |
| 383 | + return yield* Effect.fail(new Error("OAuth redirect did not complete source credential setup")); | |
| 377 | 384 | } |
| 378 | 385 | |
| 379 | 386 | return { |
| @@ -455,6 +462,10 @@ export type ExecutorSourceAddResult = · 75 unmodified lines | ||
| 455 | 462 | kind: "connected"; |
| 456 | 463 | source: Source; |
| 457 | 464 | } |
| 465 | + | { | |
| 466 | + kind: "credential_required"; | |
| 467 | + source: Source; | |
| 468 | + } | |
| 458 | 469 | | { |
| 459 | 470 | kind: "oauth_required"; |
| 460 | 471 | source: Source; |
| @@ -472,6 +483,7 @@ export type ExecutorOpenApiSourceAuthInput = · 11 unmodified lines | ||
| 472 | 483 | prefix?: string | null; |
| 473 | 484 | token?: string | null; |
| 474 | 485 | tokenEnvVar?: string | null; |
| 486 | + tokenSecretMaterialId?: string | null; | |
| 475 | 487 | }; |
| 476 | 488 | |
| 477 | 489 | export type ExecutorAddSourceInput = |
| @@ -496,6 +508,17 @@ export type ExecutorAddSourceInput = · 18 unmodified lines | ||
| 496 | 508 | auth?: ExecutorOpenApiSourceAuthInput | null; |
| 497 | 509 | }; |
| 498 | 510 | |
| 511 | +const shouldPromptForOpenApiCredentialSetup = (input: { | |
| 512 | + existing?: Source; | |
| 513 | + auth?: ExecutorOpenApiSourceAuthInput | null; | |
| 514 | +}): boolean => { | |
| 515 | + if (input.auth !== undefined) { | |
| 516 | + return false; | |
| 517 | + } | |
| 518 | + | |
| 519 | + return !(input.existing?.kind === "openapi" && input.existing.auth.kind === "bearer"); | |
| 520 | +}; | |
| 521 | + | |
| 499 | 522 | const materializeExecutorOpenApiAuth = (input: { |
| 500 | 523 | rows: SqlControlPlaneRows; |
| 501 | 524 | existing?: Source; |
| @@ -515,10 +538,21 @@ const materializeExecutorOpenApiAuth = (input: { · 13 unmodified lines | ||
| 515 | 538 | const prefix = auth.prefix ?? "Bearer "; |
| 516 | 539 | const token = trimOrNull(auth.token); |
| 517 | 540 | const tokenEnvVar = trimOrNull(auth.tokenEnvVar); |
| 541 | + const tokenSecretMaterialId = trimOrNull(auth.tokenSecretMaterialId); | |
| 542 | + | |
| 543 | + if ( | |
| 544 | + token === null | |
| 545 | + && tokenEnvVar === null | |
| 546 | + && tokenSecretMaterialId === null | |
| 547 | + && input.existing?.kind === "openapi" | |
| 548 | + && input.existing.auth.kind === "bearer" | |
| 549 | + ) { | |
| 550 | + return input.existing.auth; | |
| 551 | + } | |
| 518 | 552 | |
| 519 | − if (token === null && tokenEnvVar === null) { | |
| 553 | + if (token === null && tokenEnvVar === null && tokenSecretMaterialId === null) { | |
| 520 | 554 | return yield* Effect.fail( |
| 521 | − new Error("Bearer auth requires either token or tokenEnvVar"), | |
| 555 | + new Error("Bearer auth requires token, tokenEnvVar, or tokenSecretMaterialId"), | |
| 522 | 556 | ); |
| 523 | 557 | } |
| 524 | 558 | |
| @@ -530,6 +564,11 @@ const materializeExecutorOpenApiAuth = (input: { · 5 unmodified lines | ||
| 530 | 564 | value: token, |
| 531 | 565 | }), |
| 532 | 566 | } |
| 567 | + : tokenSecretMaterialId !== null | |
| 568 | + ? { | |
| 569 | + providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | |
| 570 | + handle: tokenSecretMaterialId, | |
| 571 | + } | |
| 533 | 572 | : { |
| 534 | 573 | providerId: "env", |
| 535 | 574 | handle: tokenEnvVar!, |
| @@ -548,13 +587,20 @@ type RuntimeSourceAuthServiceShape = { · 12 unmodified lines | ||
| 548 | 587 | workspaceId: WorkspaceId; |
| 549 | 588 | sourceId: Source["id"]; |
| 550 | 589 | }) => Effect.Effect<Source, Error, never>; |
| 590 | + getLocalServerBaseUrl: () => string | null; | |
| 591 | + storeSecretMaterial: (input: { | |
| 592 | + purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token"; | |
| 593 | + value: string; | |
| 594 | + }) => Effect.Effect<SecretMaterialId, Error, never>; | |
| 551 | 595 | addExecutorSource: ( |
| 552 | 596 | input: ExecutorAddSourceInput, |
| 553 | 597 | options?: { |
| 554 | 598 | mcpDiscoveryElicitation?: McpDiscoveryElicitationContext; |
| 555 | 599 | }, |
| 556 | 600 | ) => Effect.Effect<ExecutorSourceAddResult, Error, never>; |
| 557 | − completeSourceAuthCallback: (input: { | |
| 601 | + completeSourceCredentialSetup: (input: { | |
| 602 | + workspaceId: WorkspaceId; | |
| 603 | + sourceId: Source["id"]; | |
| 558 | 604 | state: string; |
| 559 | 605 | code?: string | null; |
| 560 | 606 | error?: string | null; |
| @@ -602,6 +648,14 @@ export const createRuntimeSourceAuthService = (input: { · 41 unmodified lines | ||
| 602 | 648 | }); |
| 603 | 649 | |
| 604 | 650 | return { |
| 651 | + getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, | |
| 652 | + | |
| 653 | + storeSecretMaterial: ({ purpose, value }) => | |
| 654 | + upsertSecretMaterial(input.rows, { | |
| 655 | + purpose, | |
| 656 | + value, | |
| 657 | + }), | |
| 658 | + | |
| 605 | 659 | getSourceById: ({ workspaceId, sourceId }) => |
| 606 | 660 | loadSourceById(input.rows, { |
| 607 | 661 | workspaceId, |
| @@ -632,12 +686,56 @@ export const createRuntimeSourceAuthService = (input: { · 24 unmodified lines | ||
| 632 | 686 | trimOrNull(sourceInput.namespace) |
| 633 | 687 | ?? existing?.namespace |
| 634 | 688 | ?? defaultNamespaceFromName(chosenName); |
| 689 | + const now = Date.now(); | |
| 690 | + | |
| 691 | + if (shouldPromptForOpenApiCredentialSetup({ | |
| 692 | + existing, | |
| 693 | + auth: sourceInput.auth, | |
| 694 | + })) { | |
| 695 | + const draftSource = existing | |
| 696 | + ? yield* updateSourceFromPayload({ | |
| 697 | + source: existing, | |
| 698 | + payload: { | |
| 699 | + name: chosenName, | |
| 700 | + endpoint: normalizedEndpoint, | |
| 701 | + namespace: chosenNamespace, | |
| 702 | + kind: "openapi", | |
| 703 | + status: "auth_required", | |
| 704 | + enabled: true, | |
| 705 | + specUrl: normalizedSpecUrl, | |
| 706 | + auth: { kind: "none" }, | |
| 707 | + lastError: null, | |
| 708 | + }, | |
| 709 | + now, | |
| 710 | + }) | |
| 711 | + : yield* createSourceFromPayload({ | |
| 712 | + workspaceId: sourceInput.workspaceId, | |
| 713 | + sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | |
| 714 | + payload: { | |
| 715 | + name: chosenName, | |
| 716 | + kind: "openapi", | |
| 717 | + endpoint: normalizedEndpoint, | |
| 718 | + namespace: chosenNamespace, | |
| 719 | + status: "auth_required", | |
| 720 | + enabled: true, | |
| 721 | + specUrl: normalizedSpecUrl, | |
| 722 | + auth: { kind: "none" }, | |
| 723 | + }, | |
| 724 | + now, | |
| 725 | + }); | |
| 726 | + | |
| 727 | + const persistedDraft = yield* persistSource(input.rows, draftSource); | |
| 728 | + return { | |
| 729 | + kind: "credential_required", | |
| 730 | + source: persistedDraft, | |
| 731 | + } satisfies ExecutorSourceAddResult; | |
| 732 | + } | |
| 733 | + | |
| 635 | 734 | const auth = yield* materializeExecutorOpenApiAuth({ |
| 636 | 735 | rows: input.rows, |
| 637 | 736 | existing, |
| 638 | 737 | auth: sourceInput.auth, |
| 639 | 738 | }); |
| 640 | − const now = Date.now(); | |
| 641 | 739 | |
| 642 | 740 | const draftSource = existing |
| 643 | 741 | ? yield* updateSourceFromPayload({ |
| @@ -812,13 +910,17 @@ export const createRuntimeSourceAuthService = (input: { · 168 unmodified lines | ||
| 812 | 910 | const localServerBaseUrl = input.getLocalServerBaseUrl?.(); |
| 813 | 911 | if (!localServerBaseUrl) { |
| 814 | 912 | return yield* Effect.fail( |
| 815 | − new Error("Local executor server base URL is unavailable for OAuth callback handling"), | |
| 913 | + new Error("Local executor server base URL is unavailable for source credential setup"), | |
| 816 | 914 | ); |
| 817 | 915 | } |
| 818 | 916 | |
| 819 | 917 | const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`); |
| 820 | 918 | const state = crypto.randomUUID(); |
| 821 | − const redirectUrl = resolveRedirectUrl(localServerBaseUrl); | |
| 919 | + const redirectUrl = resolveSourceCredentialOauthCompleteUrl({ | |
| 920 | + baseUrl: localServerBaseUrl, | |
| 921 | + workspaceId: sourceInput.workspaceId, | |
| 922 | + sourceId: persistedDraft.id, | |
| 923 | + }); | |
| 822 | 924 | const oauthStart = yield* startOAuthAuthorization({ |
| 823 | 925 | endpoint: normalizedEndpoint, |
| 824 | 926 | redirectUrl, |
| @@ -864,7 +966,14 @@ export const createRuntimeSourceAuthService = (input: { · 39 unmodified lines | ||
| 864 | 966 | } satisfies ExecutorSourceAddResult; |
| 865 | 967 | }), |
| 866 | 968 | |
| 867 | − completeSourceAuthCallback: ({ state, code, error, errorDescription }) => | |
| 969 | + completeSourceCredentialSetup: ({ | |
| 970 | + workspaceId, | |
| 971 | + sourceId, | |
| 972 | + state, | |
| 973 | + code, | |
| 974 | + error, | |
| 975 | + errorDescription, | |
| 976 | + }) => | |
| 868 | 977 | Effect.gen(function* () { |
| 869 | 978 | const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state); |
| 870 | 979 | if (Option.isNone(sessionOption)) { |
| @@ -872,6 +981,14 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line | ||
| 872 | 981 | } |
| 873 | 982 | |
| 874 | 983 | const session = sessionOption.value; |
| 984 | + if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { | |
| 985 | + return yield* Effect.fail( | |
| 986 | + new Error( | |
| 987 | + `Source auth session ${session.id} does not match workspaceId=${workspaceId} sourceId=${sourceId}`, | |
| 988 | + ), | |
| 989 | + ); | |
| 990 | + } | |
| 991 | + | |
| 875 | 992 | const source = yield* loadSourceById(input.rows, { |
| 876 | 993 | workspaceId: session.workspaceId, |
| 877 | 994 | sourceId: session.sourceId, |
| @@ -1015,9 +1132,7 @@ export const createRuntimeSourceAuthService = (input: { · 137 unmodified lines | ||
| 1015 | 1132 | } satisfies RuntimeSourceAuthServiceShape; |
| 1016 | 1133 | }; |
| 1017 | 1134 | |
| 1018 | −export type RuntimeSourceAuthService = ReturnType< | |
| 1019 | − typeof createRuntimeSourceAuthService | |
| 1020 | −>; | |
| 1135 | +export type RuntimeSourceAuthService = RuntimeSourceAuthServiceShape; | |
| 1021 | 1136 | |
| 1022 | 1137 | export class RuntimeSourceAuthServiceTag extends Context.Tag( |
| 1023 | 1138 | "#runtime/RuntimeSourceAuthServiceTag", |
| @@ -1045,6 +1160,10 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 21 unmodified lines | ||
| 1045 | 1160 | kind: Schema.Literal("connected"), |
| 1046 | 1161 | source: SourceSchema, |
| 1047 | 1162 | }), |
| 1163 | + Schema.Struct({ | |
| 1164 | + kind: Schema.Literal("credential_required"), | |
| 1165 | + source: SourceSchema, | |
| 1166 | + }), | |
| 1048 | 1167 | Schema.Struct({ |
| 1049 | 1168 | kind: Schema.Literal("oauth_required"), |
| 1050 | 1169 | source: SourceSchema, |
| @@ -16,6 +16,7 @@ import { describe, expect, it } from "@effect/vitest"; · 15 unmodified lines | ||
| 16 | 16 | import { |
| 17 | 17 | AccountIdSchema, |
| 18 | 18 | ExecutionIdSchema, |
| 19 | + SecretMaterialIdSchema, | |
| 19 | 20 | SourceIdSchema, |
| 20 | 21 | WorkspaceIdSchema, |
| 21 | 22 | } from "#schema"; |
| @@ -413,13 +414,126 @@ const persistConnectedEchoTool = (input: { · 391 unmodified lines | ||
| 413 | 414 | }); |
| 414 | 415 | }); |
| 415 | 416 | |
| 417 | +const persistConnectedGithubSearchArtifacts = (input: { | |
| 418 | + persistence: SqlControlPlanePersistence; | |
| 419 | + workspaceId: WorkspaceId; | |
| 420 | + sourceId: SourceId; | |
| 421 | +}) => | |
| 422 | + Effect.gen(function* () { | |
| 423 | + const now = Date.now(); | |
| 424 | + | |
| 425 | + yield* input.persistence.rows.sources.insert({ | |
| 426 | + id: input.sourceId, | |
| 427 | + workspaceId: input.workspaceId, | |
| 428 | + name: "GitHub", | |
| 429 | + kind: "openapi", | |
| 430 | + endpoint: "https://api.github.com", | |
| 431 | + status: "connected", | |
| 432 | + enabled: true, | |
| 433 | + namespace: "github", | |
| 434 | + transport: null, | |
| 435 | + queryParamsJson: null, | |
| 436 | + headersJson: null, | |
| 437 | + specUrl: "https://example.com/github-openapi.json", | |
| 438 | + defaultHeadersJson: null, | |
| 439 | + authKind: "none", | |
| 440 | + authHeaderName: null, | |
| 441 | + authPrefix: null, | |
| 442 | + sourceHash: null, | |
| 443 | + lastError: null, | |
| 444 | + createdAt: now, | |
| 445 | + updatedAt: now, | |
| 446 | + }); | |
| 447 | + | |
| 448 | + yield* input.persistence.rows.toolArtifacts.replaceForSource({ | |
| 449 | + workspaceId: input.workspaceId, | |
| 450 | + sourceId: input.sourceId, | |
| 451 | + artifacts: [ | |
| 452 | + { | |
| 453 | + artifact: { | |
| 454 | + workspaceId: input.workspaceId, | |
| 455 | + path: "github.actions/get-environment-secret", | |
| 456 | + toolId: "actions/get-environment-secret", | |
| 457 | + sourceId: input.sourceId, | |
| 458 | + title: "Get an environment secret", | |
| 459 | + description: | |
| 460 | + "Gets a single environment secret without revealing its encrypted value. Authenticated users must have collaborator access to a repository to create, update, or read secrets.", | |
| 461 | + searchNamespace: "github.actions", | |
| 462 | + searchText: | |
| 463 | + "github.actions/get-environment-secret github.actions get an environment secret gets a single environment secret without revealing its encrypted value authenticated users must have collaborator access to a repository to create update or read secrets GET /repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", | |
| 464 | + inputSchemaJson: null, | |
| 465 | + outputSchemaJson: null, | |
| 466 | + providerKind: "openapi", | |
| 467 | + mcpToolName: null, | |
| 468 | + openApiMethod: "get", | |
| 469 | + openApiPathTemplate: "/repos/{owner}/{repo}/environments/{environment_name}/secrets/{secret_name}", | |
| 470 | + openApiOperationHash: "op_actions_get_environment_secret", | |
| 471 | + openApiRequestBodyRequired: null, | |
| 472 | + createdAt: now, | |
| 473 | + updatedAt: now, | |
| 474 | + }, | |
| 475 | + }, | |
| 476 | + { | |
| 477 | + artifact: { | |
| 478 | + workspaceId: input.workspaceId, | |
| 479 | + path: "github.actions/get-self-hosted-runner-for-org", | |
| 480 | + toolId: "actions/get-self-hosted-runner-for-org", | |
| 481 | + sourceId: input.sourceId, | |
| 482 | + title: "Get a self-hosted runner for an organization", | |
| 483 | + description: | |
| 484 | + "Gets a specific self-hosted runner configured in an organization. Authenticated users must have admin access to the organization to use this endpoint.", | |
| 485 | + searchNamespace: "github.actions", | |
| 486 | + searchText: | |
| 487 | + "github.actions/get-self-hosted-runner-for-org github.actions get a self hosted runner for an organization gets a specific self hosted runner configured in an organization authenticated users must have admin access to the organization to use this endpoint GET /orgs/{org}/actions/runners/{runner_id}", | |
| 488 | + inputSchemaJson: null, | |
| 489 | + outputSchemaJson: null, | |
| 490 | + providerKind: "openapi", | |
| 491 | + mcpToolName: null, | |
| 492 | + openApiMethod: "get", | |
| 493 | + openApiPathTemplate: "/orgs/{org}/actions/runners/{runner_id}", | |
| 494 | + openApiOperationHash: "op_actions_get_self_hosted_runner_for_org", | |
| 495 | + openApiRequestBodyRequired: null, | |
| 496 | + createdAt: now, | |
| 497 | + updatedAt: now, | |
| 498 | + }, | |
| 499 | + }, | |
| 500 | + { | |
| 501 | + artifact: { | |
| 502 | + workspaceId: input.workspaceId, | |
| 503 | + path: "github.users/get-authenticated", | |
| 504 | + toolId: "users/get-authenticated", | |
| 505 | + sourceId: input.sourceId, | |
| 506 | + title: "Get the authenticated user", | |
| 507 | + description: | |
| 508 | + "Gets the authenticated user. OAuth app tokens and personal access tokens need the user scope to include private profile information.", | |
| 509 | + searchNamespace: "github.users", | |
| 510 | + searchText: | |
| 511 | + "github.users/get-authenticated github.users get the authenticated user gets the authenticated user oauth app tokens and personal access tokens need the user scope to include private profile information GET /user", | |
| 512 | + inputSchemaJson: null, | |
| 513 | + outputSchemaJson: null, | |
| 514 | + providerKind: "openapi", | |
| 515 | + mcpToolName: null, | |
| 516 | + openApiMethod: "get", | |
| 517 | + openApiPathTemplate: "/user", | |
| 518 | + openApiOperationHash: "op_users_get_authenticated", | |
| 519 | + openApiRequestBodyRequired: null, | |
| 520 | + createdAt: now, | |
| 521 | + updatedAt: now, | |
| 522 | + }, | |
| 523 | + }, | |
| 524 | + ], | |
| 525 | + }); | |
| 526 | + }); | |
| 527 | + | |
| 416 | 528 | const makeResolver = (persistence: SqlControlPlanePersistence) => |
| 417 | 529 | createWorkspaceExecutionEnvironmentResolver({ |
| 418 | 530 | rows: persistence.rows, |
| 419 | 531 | sourceAuthService: { |
| 532 | + getLocalServerBaseUrl: () => null, | |
| 533 | + storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")), | |
| 420 | 534 | getSourceById: () => Effect.fail(new Error("not implemented in test")), |
| 421 | 535 | addExecutorSource: () => Effect.fail(new Error("not implemented in test")), |
| 422 | − completeSourceAuthCallback: () => Effect.fail(new Error("not implemented in test")), | |
| 536 | + completeSourceCredentialSetup: () => Effect.fail(new Error("not implemented in test")), | |
| 423 | 537 | } as RuntimeSourceAuthService, |
| 424 | 538 | }); |
| 425 | 539 | |
| @@ -429,6 +543,7 @@ const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) · 3 unmodified lines | ||
| 429 | 543 | sourceAuthService: createRuntimeSourceAuthService({ |
| 430 | 544 | rows: persistence.rows, |
| 431 | 545 | liveExecutionManager: createLiveExecutionManager(), |
| 546 | + getLocalServerBaseUrl: () => "http://127.0.0.1:8788", | |
| 432 | 547 | }), |
| 433 | 548 | }); |
| 434 | 549 | |
| @@ -477,6 +592,54 @@ describe("workspace-execution-environment", () => { · 42 unmodified lines | ||
| 477 | 592 | }), |
| 478 | 593 | ); |
| 479 | 594 | |
| 595 | + it.scoped("prefers namespace and path matches over noisy GitHub boilerplate", () => | |
| 596 | + Effect.gen(function* () { | |
| 597 | + const persistence = yield* makePersistence; | |
| 598 | + const workspaceId = WorkspaceIdSchema.make("ws_github_discovery"); | |
| 599 | + const sourceId = SourceIdSchema.make("src_github_discovery"); | |
| 600 | + yield* persistConnectedGithubSearchArtifacts({ | |
| 601 | + persistence, | |
| 602 | + workspaceId, | |
| 603 | + sourceId, | |
| 604 | + }); | |
| 605 | + | |
| 606 | + const resolveEnvironment = makeResolver(persistence); | |
| 607 | + const environment = yield* resolveEnvironment({ | |
| 608 | + workspaceId, | |
| 609 | + accountId: AccountIdSchema.make("acc_github_discovery"), | |
| 610 | + executionId: ExecutionIdSchema.make("exec_github_discovery"), | |
| 611 | + }); | |
| 612 | + | |
| 613 | + const firstQuery = (yield* environment.toolInvoker.invoke({ | |
| 614 | + path: "discover", | |
| 615 | + args: { | |
| 616 | + query: "get authenticated user", | |
| 617 | + limit: 5, | |
| 618 | + }, | |
| 619 | + })) as { | |
| 620 | + bestPath: string | null; | |
| 621 | + results: Array<{ path: string }>; | |
| 622 | + }; | |
| 623 | + | |
| 624 | + expect(firstQuery.bestPath).toBe("github.users/get-authenticated"); | |
| 625 | + expect(firstQuery.results[0]?.path).toBe("github.users/get-authenticated"); | |
| 626 | + | |
| 627 | + const secondQuery = (yield* environment.toolInvoker.invoke({ | |
| 628 | + path: "discover", | |
| 629 | + args: { | |
| 630 | + query: "users get myself current user profile", | |
| 631 | + limit: 5, | |
| 632 | + }, | |
| 633 | + })) as { | |
| 634 | + bestPath: string | null; | |
| 635 | + results: Array<{ path: string }>; | |
| 636 | + }; | |
| 637 | + | |
| 638 | + expect(secondQuery.bestPath).toBe("github.users/get-authenticated"); | |
| 639 | + expect(secondQuery.results[0]?.path).toBe("github.users/get-authenticated"); | |
| 640 | + }), | |
| 641 | + ); | |
| 642 | + | |
| 480 | 643 | it.scoped("invokes persisted MCP tools on demand without re-listing", () => |
| 481 | 644 | Effect.gen(function* () { |
| 482 | 645 | const server = yield* makeCountedMcpServer; |
| @@ -518,10 +681,22 @@ describe("workspace-execution-environment", () => { · 35 unmodified lines | ||
| 518 | 681 | const persistence = yield* makePersistence; |
| 519 | 682 | const workspaceId = WorkspaceIdSchema.make("ws_add_openapi"); |
| 520 | 683 | const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence); |
| 684 | + const now = Date.now(); | |
| 685 | + const tokenSecretMaterialId = SecretMaterialIdSchema.make( | |
| 686 | + "sec_test_openapi_bearer", | |
| 687 | + ); | |
| 688 | + yield* persistence.rows.secretMaterials.upsert({ | |
| 689 | + id: tokenSecretMaterialId, | |
| 690 | + purpose: "auth_material", | |
| 691 | + value: "ghp_test_token", | |
| 692 | + createdAt: now, | |
| 693 | + updatedAt: now, | |
| 694 | + }); | |
| 521 | 695 | let capturedElicitation: |
| 522 | 696 | | { |
| 523 | 697 | mode?: string; |
| 524 | − requestedSchema?: Record<string, unknown>; | |
| 698 | + url?: string; | |
| 699 | + elicitationId?: string; | |
| 525 | 700 | } |
| 526 | 701 | | null = null; |
| 527 | 702 | |
| @@ -533,13 +708,15 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines | ||
| 533 | 708 | Effect.sync(() => { |
| 534 | 709 | capturedElicitation = request.elicitation as { |
| 535 | 710 | mode?: string; |
| 536 | − requestedSchema?: Record<string, unknown>; | |
| 711 | + url?: string; | |
| 712 | + elicitationId?: string; | |
| 537 | 713 | }; |
| 538 | 714 | |
| 539 | 715 | return { |
| 540 | 716 | action: "accept" as const, |
| 541 | 717 | content: { |
| 542 | − token: "ghp_test_token", | |
| 718 | + authKind: "bearer", | |
| 719 | + tokenSecretMaterialId, | |
| 543 | 720 | }, |
| 544 | 721 | }; |
| 545 | 722 | }), |
| @@ -553,9 +730,6 @@ describe("workspace-execution-environment", () => { · 7 unmodified lines | ||
| 553 | 730 | specUrl: specServer.specUrl, |
| 554 | 731 | name: "GitHub", |
| 555 | 732 | namespace: "github", |
| 556 | − auth: { | |
| 557 | − kind: "bearer", | |
| 558 | − }, | |
| 559 | 733 | }, |
| 560 | 734 | context: { |
| 561 | 735 | runId: "exec_add_openapi", |
| @@ -576,11 +750,13 @@ describe("workspace-execution-environment", () => { · 14 unmodified lines | ||
| 576 | 750 | expect(added.auth.kind).toBe("bearer"); |
| 577 | 751 | expect(added.auth.token?.providerId).toBe("control-plane"); |
| 578 | 752 | expect(capturedElicitation).toMatchObject({ |
| 579 | − mode: "form", | |
| 580 | − requestedSchema: { | |
| 581 | − type: "object", | |
| 582 | − }, | |
| 753 | + mode: "url", | |
| 754 | + url: expect.stringContaining("/v1/workspaces/"), | |
| 755 | + elicitationId: expect.stringContaining("executor.sources.add:"), | |
| 583 | 756 | }); |
| 757 | + expect((capturedElicitation as { url?: string } | null)?.url).toContain( | |
| 758 | + encodeURIComponent("exec_add_openapi:executor.sources.add:"), | |
| 759 | + ); | |
| 584 | 760 | |
| 585 | 761 | const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId( |
| 586 | 762 | workspaceId, |
| @@ -656,7 +832,9 @@ describe("workspace-execution-environment", () => { · 69 unmodified lines | ||
| 656 | 832 | expect(described?.path).toBe("executor.sources.add"); |
| 657 | 833 | expect(described?.description).toContain("Source add input shapes:"); |
| 658 | 834 | expect(described?.description).toContain("specUrl"); |
| 835 | + expect(described?.description).toContain("credential setup"); | |
| 659 | 836 | expect(described?.inputHint).toContain("endpoint"); |
| 837 | + expect(described?.inputHint).not.toContain("auth"); | |
| 660 | 838 | |
| 661 | 839 | const inputSchema = described?.inputSchemaJson |
| 662 | 840 | ? JSON.parse(described.inputSchemaJson) as Record<string, unknown> |
| @@ -664,6 +842,7 @@ describe("workspace-execution-environment", () => { · 1 unmodified line | ||
| 664 | 842 | expect(inputSchema).not.toBeNull(); |
| 665 | 843 | expect(JSON.stringify(inputSchema)).toContain("specUrl"); |
| 666 | 844 | expect(JSON.stringify(inputSchema)).toContain("endpoint"); |
| 845 | + expect(JSON.stringify(inputSchema)).not.toContain("\"auth\""); | |
| 667 | 846 | }), |
| 668 | 847 | ); |
| 669 | 848 | }); |
| @@ -55,17 +55,157 @@ const tokenize = (value: string): string[] => · 54 unmodified lines | ||
| 55 | 55 | value |
| 56 | 56 | .trim() |
| 57 | 57 | .toLowerCase() |
| 58 | − .split(/\s+/) | |
| 58 | + .split(/[^a-z0-9]+/) | |
| 59 | 59 | .filter(Boolean); |
| 60 | 60 | |
| 61 | +const LOW_SIGNAL_QUERY_TOKENS = new Set([ | |
| 62 | + "a", | |
| 63 | + "an", | |
| 64 | + "the", | |
| 65 | + "am", | |
| 66 | + "as", | |
| 67 | + "for", | |
| 68 | + "from", | |
| 69 | + "get", | |
| 70 | + "i", | |
| 71 | + "in", | |
| 72 | + "is", | |
| 73 | + "list", | |
| 74 | + "me", | |
| 75 | + "my", | |
| 76 | + "of", | |
| 77 | + "on", | |
| 78 | + "or", | |
| 79 | + "signed", | |
| 80 | + "to", | |
| 81 | + "who", | |
| 82 | +]); | |
| 83 | + | |
| 84 | +const singularizeToken = (value: string): string => | |
| 85 | + value.length > 3 && value.endsWith("s") | |
| 86 | + ? value.slice(0, -1) | |
| 87 | + : value; | |
| 88 | + | |
| 89 | +const tokenEquals = (left: string, right: string): boolean => | |
| 90 | + left === right || singularizeToken(left) === singularizeToken(right); | |
| 91 | + | |
| 92 | +const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean => | |
| 93 | + tokens.some((token) => tokenEquals(token, queryToken)); | |
| 94 | + | |
| 95 | +const hasSubstringMatch = (value: string, queryToken: string): boolean => { | |
| 96 | + if (value.includes(queryToken)) { | |
| 97 | + return true; | |
| 98 | + } | |
| 99 | + | |
| 100 | + const singular = singularizeToken(queryToken); | |
| 101 | + return singular !== queryToken && value.includes(singular); | |
| 102 | +}; | |
| 103 | + | |
| 104 | +const queryTokenWeight = (token: string): number => | |
| 105 | + LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1; | |
| 106 | + | |
| 61 | 107 | const scoreArtifact = ( |
| 62 | 108 | queryTokens: readonly string[], |
| 63 | − searchText: string, | |
| 64 | −): number => | |
| 65 | − queryTokens.reduce( | |
| 66 | − (total, token) => total + (searchText.includes(token) ? 1 : 0), | |
| 67 | − 0, | |
| 68 | − ); | |
| 109 | + artifact: StoredToolArtifactRecord, | |
| 110 | +): number => { | |
| 111 | + const pathText = artifact.path.toLowerCase(); | |
| 112 | + const namespaceText = artifact.searchNamespace.toLowerCase(); | |
| 113 | + const toolIdText = artifact.toolId.toLowerCase(); | |
| 114 | + const titleText = artifact.title?.toLowerCase() ?? ""; | |
| 115 | + const descriptionText = artifact.description?.toLowerCase() ?? ""; | |
| 116 | + const templateText = artifact.openApiPathTemplate?.toLowerCase() ?? ""; | |
| 117 | + | |
| 118 | + const pathTokens = tokenize(`${artifact.path} ${artifact.toolId}`); | |
| 119 | + const namespaceTokens = tokenize(artifact.searchNamespace); | |
| 120 | + const titleTokens = tokenize(artifact.title ?? ""); | |
| 121 | + const templateTokens = tokenize(artifact.openApiPathTemplate ?? ""); | |
| 122 | + | |
| 123 | + let score = 0; | |
| 124 | + let structuralHits = 0; | |
| 125 | + let namespaceHits = 0; | |
| 126 | + let pathHits = 0; | |
| 127 | + | |
| 128 | + for (const token of queryTokens) { | |
| 129 | + const weight = queryTokenWeight(token); | |
| 130 | + | |
| 131 | + if (hasTokenMatch(pathTokens, token)) { | |
| 132 | + score += 12 * weight; | |
| 133 | + structuralHits += 1; | |
| 134 | + pathHits += 1; | |
| 135 | + continue; | |
| 136 | + } | |
| 137 | + | |
| 138 | + if (hasTokenMatch(namespaceTokens, token)) { | |
| 139 | + score += 11 * weight; | |
| 140 | + structuralHits += 1; | |
| 141 | + namespaceHits += 1; | |
| 142 | + continue; | |
| 143 | + } | |
| 144 | + | |
| 145 | + if (hasTokenMatch(titleTokens, token)) { | |
| 146 | + score += 9 * weight; | |
| 147 | + structuralHits += 1; | |
| 148 | + continue; | |
| 149 | + } | |
| 150 | + | |
| 151 | + if (hasTokenMatch(templateTokens, token)) { | |
| 152 | + score += 8 * weight; | |
| 153 | + structuralHits += 1; | |
| 154 | + continue; | |
| 155 | + } | |
| 156 | + | |
| 157 | + if (hasSubstringMatch(pathText, token) || hasSubstringMatch(toolIdText, token)) { | |
| 158 | + score += 6 * weight; | |
| 159 | + structuralHits += 1; | |
| 160 | + pathHits += 1; | |
| 161 | + continue; | |
| 162 | + } | |
| 163 | + | |
| 164 | + if (hasSubstringMatch(namespaceText, token)) { | |
| 165 | + score += 5 * weight; | |
| 166 | + structuralHits += 1; | |
| 167 | + namespaceHits += 1; | |
| 168 | + continue; | |
| 169 | + } | |
| 170 | + | |
| 171 | + if (hasSubstringMatch(titleText, token) || hasSubstringMatch(templateText, token)) { | |
| 172 | + score += 4 * weight; | |
| 173 | + structuralHits += 1; | |
| 174 | + continue; | |
| 175 | + } | |
| 176 | + | |
| 177 | + if (hasSubstringMatch(descriptionText, token)) { | |
| 178 | + score += 0.5 * weight; | |
| 179 | + } | |
| 180 | + } | |
| 181 | + | |
| 182 | + const strongTokens = queryTokens.filter((token) => queryTokenWeight(token) >= 1); | |
| 183 | + if (strongTokens.length >= 2) { | |
| 184 | + for (let index = 0; index < strongTokens.length - 1; index += 1) { | |
| 185 | + const current = strongTokens[index]!; | |
| 186 | + const next = strongTokens[index + 1]!; | |
| 187 | + const phrases = [ | |
| 188 | + `${current}-${next}`, | |
| 189 | + `${current}.${next}`, | |
| 190 | + `${current}/${next}`, | |
| 191 | + ]; | |
| 192 | + | |
| 193 | + if (phrases.some((phrase) => pathText.includes(phrase) || templateText.includes(phrase))) { | |
| 194 | + score += 10; | |
| 195 | + } | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + if (namespaceHits > 0 && pathHits > 0) { | |
| 200 | + score += 8; | |
| 201 | + } | |
| 202 | + | |
| 203 | + if (structuralHits === 0 && score > 0) { | |
| 204 | + score *= 0.25; | |
| 205 | + } | |
| 206 | + | |
| 207 | + return score; | |
| 208 | +}; | |
| 69 | 209 | |
| 70 | 210 | const toDescriptor = (input: { |
| 71 | 211 | artifact: StoredToolArtifactRecord; |
| @@ -263,7 +403,6 @@ const createWorkspaceToolCatalog = (input: { · 191 unmodified lines | ||
| 263 | 403 | : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, { |
| 264 | 404 | namespace, |
| 265 | 405 | query, |
| 266 | − limit: Math.max(limit * 8, 50), | |
| 267 | 406 | }).pipe( |
| 268 | 407 | Effect.mapError((cause) => |
| 269 | 408 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -279,7 +418,7 @@ const createWorkspaceToolCatalog = (input: { · 9 unmodified lines | ||
| 279 | 418 | const persistedHits: SearchHit[] = persisted |
| 280 | 419 | .map((artifact) => ({ |
| 281 | 420 | path: asToolPath(artifact.path), |
| 282 | − score: scoreArtifact(queryTokens, artifact.searchText), | |
| 421 | + score: scoreArtifact(queryTokens, artifact), | |
| 283 | 422 | })) |
| 284 | 423 | .filter((hit) => hit.score > 0); |
| 285 | 424 | |
| @@ -10,7 +10,7 @@ export type InProcessExecutorOptions = { · 9 unmodified lines | ||
| 10 | 10 | allowFetch?: boolean; |
| 11 | 11 | }; |
| 12 | 12 | |
| 13 | −const DEFAULT_TIMEOUT_MS = 30_000; | |
| 13 | +const DEFAULT_TIMEOUT_MS = 5 * 60_000; | |
| 14 | 14 | |
| 15 | 15 | const formatLogArg = (value: unknown): string => { |
| 16 | 16 | if (typeof value === "string") { |
| @@ -179,4 +179,3 @@ export const makeInProcessExecutor = ( · 162 unmodified lines | ||
| 179 | 179 | execute: (code: string, toolInvoker: ToolInvoker) => |
| 180 | 180 | runInProcess(options, code, toolInvoker), |
| 181 | 181 | }); |
| 182 | − | |