History

elicit credentials

Rhys Sullivan committed 8 months ago

Commit 4b6e5d71 parent 53347ce15 files · +1930 −204Browse files

Unified · Split

15 files
  1. apps/executor/src/cli/main.ts +67 −13
  2. packages/control-plane/src/api/local/api.ts +0 −8
  3. packages/control-plane/src/api/local/http.ts +4 −18
  4. packages/control-plane/src/api/sources/api.ts +46 −0
  5. packages/control-plane/src/api/sources/http.ts +580 −2
  6. packages/control-plane/src/persistence/repos/execution-interactions-repo.ts +14 −0
  7. packages/control-plane/src/persistence/repos/tool-artifacts-repo.ts +5 −3
  8. packages/control-plane/src/runtime/control-plane-runtime.test.ts +283 −2
  9. packages/control-plane/src/runtime/executor-tools.ts +152 −112
  10. packages/control-plane/src/runtime/index.ts +13 −4
  11. packages/control-plane/src/runtime/local-operations.ts +293 −5
  12. packages/control-plane/src/runtime/source-auth-service.ts +134 −15
  13. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +190 −11
  14. packages/control-plane/src/runtime/workspace-execution-environment.ts +148 −9
  15. packages/runtime-local-inproc/src/index.ts +1 −2
apps/executor/src/cli/main.ts+67−13
@@ -45,6 +45,27 @@ const toError = (cause: unknown): Error => · 44 unmodified lines
45const sleep = (ms: number) =>45const sleep = (ms: number) =>
46 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));46 Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, ms)));
47 47
48const 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
48const promptLine = (prompt: string): Effect.Effect<string, Error, never> =>69const promptLine = (prompt: string): Effect.Effect<string, Error, never> =>
49 Effect.tryPromise({70 Effect.tryPromise({
50 try: async () => {71 try: async () => {
@@ -254,8 +275,8 @@ const printRootHelp = (workflow: string) => · 203 unmodified lines
254 "",275 "",
255 "USAGE",276 "USAGE",
256 "",277 "",
257 " executor call [code] [--file text] [--stdin] [--base-url text]",278 " executor call [code] [--file text] [--stdin] [--base-url text] [--no-open]",
258 " executor resume --execution-id text [--base-url text]",279 " executor resume --execution-id text [--base-url text] [--no-open]",
259 "",280 "",
260 "CALL WORKFLOW",281 "CALL WORKFLOW",
261 "",282 "",
@@ -281,7 +302,7 @@ const printCallHelp = (workflow: string) => · 19 unmodified lines
281 "",302 "",
282 "USAGE",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 "DESCRIPTION",307 "DESCRIPTION",
287 "",308 "",
@@ -301,15 +322,18 @@ const printCallHelp = (workflow: string) => · 13 unmodified lines
301 " Read code from stdin.",322 " Read code from stdin.",
302 " --base-url text",323 " --base-url text",
303 " Override the executor server base URL.",324 " Override the executor server base URL.",
325 " --no-open",
326 " Print interaction URLs without opening a browser.",
304 "",327 "",
305 "EXAMPLES",328 "EXAMPLES",
306 "",329 "",
307 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',330 ' executor call \'const matches = await tools.discover({ query: "github issues", limit: 5 }); return matches;\'',
308 ' executor call \'const matches = await tools.discover({ query: "repo details", limit: 1 }); const path = matches.bestPath; return await tools.describe.tool({ path, includeSchemas: true });\'',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 ' executor call \'return await tools.executor.sources.add({ endpoint: "https://example.com/mcp", name: "Example", namespace: "example" });\'',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 " cat script.ts | executor call --stdin",334 " cat script.ts | executor call --stdin",
312 " executor call --file script.ts",335 " executor call --file script.ts",
336 " executor call --no-open --file script.ts",
313 " executor resume --execution-id exec_123",337 " executor resume --execution-id exec_123",
314 ].join("\n"));338 ].join("\n"));
315 });339 });
@@ -564,17 +588,37 @@ const promptStructuredInteraction = (parsed: { · 248 unmodified lines
564 return content;588 return content;
565 });589 });
566 590
567const promptInteraction = (interaction: ExecutionInteraction) =>591const 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
606const promptInteraction = (input: {
607 interaction: ExecutionInteraction;
608 shouldOpenUrls: boolean;
609}) =>
568 Effect.gen(function* () {610 Effect.gen(function* () {
569 const parsed = parseInteractionPayload(interaction);611 const parsed = parseInteractionPayload(input.interaction);
570 612
571 if (!process.stdin.isTTY || !process.stdout.isTTY || parsed === null) {613 if (!process.stdin.isTTY || !process.stdout.isTTY || parsed === null) {
572 return null;614 return null;
573 }615 }
574 616
575 if (parsed.mode === "url") {617 if (parsed.mode === "url") {
576 yield* Effect.sync(() => {618 yield* printUrlInteraction({
577 process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`);619 message: parsed.message,
620 url: parsed.url ?? null,
621 shouldOpen: input.shouldOpenUrls,
578 });622 });
579 return null;623 return null;
580 }624 }
@@ -708,6 +752,7 @@ const driveExecution = (input: { · 127 unmodified lines
708 client: ControlPlaneClient;752 client: ControlPlaneClient;
709 workspaceId: ExecutionEnvelope["execution"]["workspaceId"];753 workspaceId: ExecutionEnvelope["execution"]["workspaceId"];
710 envelope: ExecutionEnvelope;754 envelope: ExecutionEnvelope;
755 shouldOpenUrls: boolean;
711}) =>756}) =>
712 Effect.gen(function* () {757 Effect.gen(function* () {
713 let current = input.envelope;758 let current = input.envelope;
@@ -721,8 +766,10 @@ const driveExecution = (input: { · 7 unmodified lines
721 766
722 const parsed = parseInteractionPayload(pending);767 const parsed = parseInteractionPayload(pending);
723 if (parsed?.mode === "url") {768 if (parsed?.mode === "url") {
724 yield* Effect.sync(() => {769 yield* printUrlInteraction({
725 process.stdout.write(`${parsed.message}\n${parsed.url ?? ""}\n`);770 message: parsed.message,
771 url: parsed.url ?? null,
772 shouldOpen: input.shouldOpenUrls,
726 });773 });
727 774
728 if (!process.stdin.isTTY || !process.stdout.isTTY) {775 if (!process.stdin.isTTY || !process.stdout.isTTY) {
@@ -738,7 +785,10 @@ const driveExecution = (input: { · 9 unmodified lines
738 continue;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 if (responseJson === null) {792 if (responseJson === null) {
743 yield* Effect.sync(() => {793 yield* Effect.sync(() => {
744 console.log(JSON.stringify({794 console.log(JSON.stringify({
@@ -790,8 +840,9 @@ const callCommand = Command.make( · 45 unmodified lines
790 file: Options.text("file").pipe(Options.optional),840 file: Options.text("file").pipe(Options.optional),
791 stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),841 stdin: Options.boolean("stdin").pipe(Options.withDefault(false)),
792 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)),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 Effect.gen(function* () {846 Effect.gen(function* () {
796 const resolvedCode = yield* readCode({847 const resolvedCode = yield* readCode({
797 code: Option.getOrUndefined(code),848 code: Option.getOrUndefined(code),
@@ -814,6 +865,7 @@ const callCommand = Command.make( · 16 unmodified lines
814 client,865 client,
815 workspaceId: installation.workspaceId,866 workspaceId: installation.workspaceId,
816 envelope: created,867 envelope: created,
868 shouldOpenUrls: !noOpen,
817 });869 });
818 870
819 yield* printExecution(settled);871 yield* printExecution(settled);
@@ -825,8 +877,9 @@ const resumeCommand = Command.make( · 5 unmodified lines
825 {877 {
826 executionId: Options.text("execution-id"),878 executionId: Options.text("execution-id"),
827 baseUrl: Options.text("base-url").pipe(Options.withDefault(DEFAULT_SERVER_BASE_URL)),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 Effect.gen(function* () {883 Effect.gen(function* () {
831 yield* ensureServer(baseUrl);884 yield* ensureServer(baseUrl);
832 const { installation, client } = yield* getLocalAuthedClient(baseUrl);885 const { installation, client } = yield* getLocalAuthedClient(baseUrl);
@@ -844,6 +897,7 @@ const resumeCommand = Command.make( · 11 unmodified lines
844 client,897 client,
845 workspaceId: installation.workspaceId,898 workspaceId: installation.workspaceId,
846 envelope: execution,899 envelope: execution,
900 shouldOpenUrls: !noOpen,
847 });901 });
848 902
849 yield* printExecution(settled);903 yield* printExecution(settled);
packages/control-plane/src/api/local/api.ts+0−8
@@ -1,5 +1,4 @@
1import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform";
2import * as Schema from "effect/Schema";
3import { LocalInstallationSchema } from "#schema";2import { LocalInstallationSchema } from "#schema";
4 3
5import {4import {
@@ -16,11 +15,4 @@ export class LocalApi extends HttpApiGroup.make("local") · 10 unmodified lines
16 .addError(ControlPlaneNotFoundError)15 .addError(ControlPlaneNotFoundError)
17 .addError(ControlPlaneStorageError),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 .prefix("/v1") {}18 .prefix("/v1") {}
packages/control-plane/src/api/local/http.ts+4−18
@@ -1,7 +1,8 @@
1import { HttpApiBuilder, HttpServerRequest } from "@effect/platform";
2import * as Effect from "effect/Effect";
3import {1import {
4 completeSourceAuthCallback,2 HttpApiBuilder,
3} from "@effect/platform";
4
5import {
5 getLocalInstallation,6 getLocalInstallation,
6} from "../../runtime/local-operations";7} from "../../runtime/local-operations";
7 8
@@ -14,20 +15,5 @@ export const ControlPlaneLocalLive = HttpApiBuilder.group( · 6 unmodified lines
14 handlers15 handlers
15 .handle("installation", () =>16 .handle("installation", () =>
16 getLocalInstallation(),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);
packages/control-plane/src/api/sources/api.ts+46−0
@@ -1,5 +1,6 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 ExecutionInteractionIdSchema,
3 SourceAuthSchema,4 SourceAuthSchema,
4 SourceIdSchema,5 SourceIdSchema,
5 SourceKindSchema,6 SourceKindSchema,
@@ -72,6 +73,26 @@ export type UpdateSourcePayload = typeof UpdateSourcePayloadSchema.Type; · 66 unmodified lines
72const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);73const workspaceIdParam = HttpApiSchema.param("workspaceId", WorkspaceIdSchema);
73const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);74const sourceIdParam = HttpApiSchema.param("sourceId", SourceIdSchema);
74 75
76const CredentialPageUrlParamsSchema = Schema.Struct({
77 interactionId: ExecutionInteractionIdSchema,
78});
79
80const CredentialSubmitPayloadSchema = Schema.Struct({
81 action: Schema.optional(Schema.Literal("submit", "continue", "cancel")),
82 token: Schema.optional(Schema.String),
83});
84
85const 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
92const HtmlSchema = HttpApiSchema.Text({
93 contentType: "text/html",
94});
95
75export class SourcesApi extends HttpApiGroup.make("sources")96export class SourcesApi extends HttpApiGroup.make("sources")
76 .add(97 .add(
77 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`98 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
@@ -117,4 +138,29 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 39 unmodified lines
117 .addError(ControlPlaneForbiddenError)138 .addError(ControlPlaneForbiddenError)
118 .addError(ControlPlaneStorageError),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 .prefix("/v1") {}166 .prefix("/v1") {}
packages/control-plane/src/api/sources/http.ts+580−2
@@ -1,5 +1,13 @@
1import { HttpApiBuilder } from "@effect/platform";1import {
2import type { WorkspaceId } from "#schema";2 HttpApiBuilder,
3 HttpServerResponse,
4} from "@effect/platform";
5import type {
6 ExecutionInteraction,
7 Source,
8 WorkspaceId,
9} from "#schema";
10import * as Effect from "effect/Effect";
3 11
4import { requirePermission, withPolicy } from "#domain";12import { requirePermission, withPolicy } from "#domain";
5import {13import {
@@ -9,7 +17,17 @@ import { · 3 unmodified lines
9 removeSource,17 removeSource,
10 updateSource,18 updateSource,
11} from "../../runtime/sources-operations";19} from "../../runtime/sources-operations";
20import {
21 completeSourceCredentialSetup,
22 getSourceCredentialInteraction,
23 submitSourceCredentialInteraction,
24} from "../../runtime/local-operations";
12 25
26import {
27 ControlPlaneBadRequestError,
28 ControlPlaneNotFoundError,
29 ControlPlaneStorageError,
30} from "../errors";
13import { ControlPlaneApi } from "../api";31import { ControlPlaneApi } from "../api";
14import { withWorkspaceRequestActor } from "../http-auth";32import { withWorkspaceRequestActor } from "../http-auth";
15 33
@@ -25,6 +43,446 @@ const requireWriteSources = (workspaceId: WorkspaceId) => · 9 unmodified lines
25 workspaceId,43 workspaceId,
26 });44 });
27 45
46const escapeHtml = (value: string): string =>
47 value
48 .replaceAll("&", "&amp;")
49 .replaceAll("<", "&lt;")
50 .replaceAll(">", "&gt;")
51 .replaceAll('"', "&quot;")
52 .replaceAll("'", "&#39;");
53
54const 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
417const htmlResponse = (html: string, status = 200) =>
418 HttpServerResponse.html(html).pipe(
419 HttpServerResponse.setStatus(status),
420 );
421
422const 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
441const credentialErrorStatus = (
442 error: ControlPlaneBadRequestError | ControlPlaneNotFoundError | ControlPlaneStorageError,
443) =>
444 error instanceof ControlPlaneNotFoundError
445 ? 404
446 : error instanceof ControlPlaneBadRequestError
447 ? 400
448 : 500;
449
450const 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
28export const ControlPlaneSourcesLive = HttpApiBuilder.group(486export const ControlPlaneSourcesLive = HttpApiBuilder.group(
29 ControlPlaneApi,487 ControlPlaneApi,
30 "sources",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);
packages/control-plane/src/persistence/repos/execution-interactions-repo.ts+14−0
@@ -18,6 +18,20 @@ export const createExecutionInteractionsRepo = ( · 17 unmodified lines
18 client: DrizzleClient,18 client: DrizzleClient,
19 tables: DrizzleTables,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 listByExecutionId: (executionId: ExecutionInteraction["executionId"]) =>35 listByExecutionId: (executionId: ExecutionInteraction["executionId"]) =>
22 client.use("rows.execution_interactions.list_by_execution_id", async (db) => {36 client.use("rows.execution_interactions.list_by_execution_id", async (db) => {
23 const rows = await db37 const rows = await db
packages/control-plane/src/persistence/repos/tool-artifacts-repo.ts+5−3
@@ -178,7 +178,7 @@ export const createToolArtifactsRepo = ( · 177 unmodified lines
178 },178 },
179 ) =>179 ) =>
180 client.use("rows.tool_artifacts.search_by_workspace", async (db) => {180 client.use("rows.tool_artifacts.search_by_workspace", async (db) => {
181 const rows = await db181 const baseQuery = db
182 .select()182 .select()
183 .from(tables.toolArtifactsTable)183 .from(tables.toolArtifactsTable)
184 .where(184 .where(
@@ -191,8 +191,10 @@ export const createToolArtifactsRepo = ( · 6 unmodified lines
191 .orderBy(191 .orderBy(
192 asc(tables.toolArtifactsTable.searchNamespace),192 asc(tables.toolArtifactsTable.searchNamespace),
193 asc(tables.toolArtifactsTable.path),193 asc(tables.toolArtifactsTable.path),
194 )194 );
195 .limit(input.limit ?? 500);195 const rows = input.limit !== undefined
196 ? await baseQuery.limit(input.limit)
197 : await baseQuery;
196 198
197 return rows.map((row) => decodeStoredToolArtifactRecord(row));199 return rows.map((row) => decodeStoredToolArtifactRecord(row));
198 }),200 }),
packages/control-plane/src/runtime/control-plane-runtime.test.ts+283−2
@@ -1,10 +1,22 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { assertTrue } from "@effect/vitest/utils";2import { assertTrue } from "@effect/vitest/utils";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Option from "effect/Option";
4 6
5import type { AccountId } from "#schema";7import type { AccountId } from "#schema";
6 8import {
7import { createSqlControlPlaneRuntime } from "./index";9 ExecutionIdSchema,
10 ExecutionInteractionIdSchema,
11 SecretMaterialIdSchema,
12 SourceIdSchema,
13} from "#schema";
14import type { ToolPath } from "@executor-v3/codemode-core";
15
16import {
17 createSqlControlPlaneRuntime,
18 LiveExecutionManagerService,
19} from "./index";
8import { withControlPlaneClient } from "./test-http-client";20import { withControlPlaneClient } from "./test-http-client";
9 21
10const makeRuntime = Effect.acquireRelease(22const 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 it.scoped("scopes organization list/get to memberships", () =>386 it.scoped("scopes organization list/get to memberships", () =>
106 Effect.gen(function* () {387 Effect.gen(function* () {
107 const runtime = yield* makeRuntime;388 const runtime = yield* makeRuntime;
packages/control-plane/src/runtime/executor-tools.ts+152−112
@@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; · 18 unmodified lines
19 19
20import {20import {
21 type ExecutorAddSourceInput,21 type ExecutorAddSourceInput,
22 type ExecutorOpenApiSourceAuthInput,
22 type RuntimeSourceAuthService,23 type RuntimeSourceAuthService,
23} from "./source-auth-service";24} from "./source-auth-service";
24import {25import {
@@ -26,19 +27,6 @@ import { · 1 unmodified line
26 deriveSchemaTypeSignature,27 deriveSchemaTypeSignature,
27} from "./schema-type-signature";28} from "./schema-type-signature";
28 29
29const 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
42const ExecutorMcpSourceAddInputSchema = Schema.Struct({30const ExecutorMcpSourceAddInputSchema = Schema.Struct({
43 kind: Schema.optional(Schema.Literal("mcp")),31 kind: Schema.optional(Schema.Literal("mcp")),
44 endpoint: Schema.String,32 endpoint: Schema.String,
@@ -52,7 +40,6 @@ const ExecutorOpenApiSourceAddInputSchema = Schema.Struct({ · 7 unmodified lines
52 specUrl: Schema.String,40 specUrl: Schema.String,
53 name: Schema.optional(Schema.NullOr(Schema.String)),41 name: Schema.optional(Schema.NullOr(Schema.String)),
54 namespace: Schema.optional(Schema.NullOr(Schema.String)),42 namespace: Schema.optional(Schema.NullOr(Schema.String)),
55 auth: Schema.optional(Schema.NullOr(ExecutorOpenApiSourceAuthInputSchema)),
56});43});
57 44
58const ExecutorSourcesAddSchema = Schema.Union(45const ExecutorSourcesAddSchema = Schema.Union(
@@ -100,7 +87,7 @@ export const EXECUTOR_SOURCES_ADD_HELP_LINES = [ · 41 unmodified lines
100 ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.',87 ' Omit kind or set kind: "mcp". endpoint is the MCP server URL.',
101 `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`,88 `- OpenAPI: ${EXECUTOR_SOURCES_ADD_OPENAPI_INPUT_SIGNATURE}`,
102 " endpoint is the base API URL. specUrl is the OpenAPI document URL.",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] as const;91] as const;
105 92
106export const buildExecutorSourcesAddDescription = (): string =>93export const buildExecutorSourcesAddDescription = (): string =>
@@ -133,11 +120,34 @@ const resolveOpenApiSourceLabel = (input: { · 26 unmodified lines
133 endpoint: string;120 endpoint: string;
134}): string => trimOrNull(input.name) ?? input.endpoint;121}): string => trimOrNull(input.name) ?? input.endpoint;
135 122
136const promptForBearerToken = (input: {123const resolveLocalCredentialUrl = (input: {
137 args: Extract<ExecutorAddSourceInput, { kind: "openapi" }>;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
135const 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 interactionId: string;147 interactionId: string;
139 path: ToolPath;148 path: ToolPath;
140 sourceKey: string;149 sourceKey: string;
150 localServerBaseUrl: string | null;
141 metadata?: ToolMetadata;151 metadata?: ToolMetadata;
142 invocation?: ToolInvocationContext;152 invocation?: ToolInvocationContext;
143 onElicitation?: OnElicitation;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 const response: ElicitationResponse = yield* input.onElicitation({168 const response: ElicitationResponse = yield* input.onElicitation({
153 interactionId: input.interactionId,169 interactionId: input.interactionId,
154 path: input.path,170 path: input.path,
@@ -157,40 +173,49 @@ const promptForBearerToken = (input: { · 2 unmodified lines
157 metadata: input.metadata,173 metadata: input.metadata,
158 context: input.invocation,174 context: input.invocation,
159 elicitation: {175 elicitation: {
160 mode: "form",176 mode: "url",
161 message: `Enter the API token to connect ${resolveOpenApiSourceLabel(input.args)}`,177 message: `Open the secure credential page to connect ${input.source.name}`,
162 requestedSchema: {178 url: resolveLocalCredentialUrl({
163 type: "object",179 baseUrl: input.localServerBaseUrl,
164 properties: {180 workspaceId: input.args.workspaceId,
165 token: {181 sourceId: input.args.sourceId,
166 type: "string",182 executionId: input.executionId,
167 title: "API token",183 interactionId: input.interactionId,
168 },184 }),
169 },185 elicitationId: input.interactionId,
170 required: ["token"],
171 additionalProperties: false,
172 },
173 },186 },
174 }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause))));187 }).pipe(Effect.mapError((cause) => cause instanceof Error ? cause : new Error(String(cause))));
175 188
176 if (response.action !== "accept") {189 if (response.action !== "accept") {
177 return yield* Effect.fail(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 =195 const authKind =
183 response.content && typeof response.content.token === "string"196 response.content && typeof response.content.authKind === "string"
184 ? response.content.token.trim()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 return yield* Effect.fail(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
196export const createExecutorToolMap = (input: {221export const createExecutorToolMap = (input: {
@@ -205,30 +230,18 @@ export const createExecutorToolMap = (input: { · 8 unmodified lines
205 execute: async (230 execute: async (
206 args:231 args:
207 | {232 | {
208 kind?: "mcp";233 kind?: "mcp";
209 endpoint: string;234 endpoint: string;
210 name?: string | null;235 name?: string | null;
211 namespace?: string | null;236 namespace?: string | null;
212 }237 }
213 | {238 | {
214 kind: "openapi";239 kind: "openapi";
215 endpoint: string;240 endpoint: string;
216 specUrl: string;241 specUrl: string;
217 name?: string | null;242 name?: string | null;
218 namespace?: string | null;243 namespace?: string | null;
219 auth?:244 },
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 },
232 context,245 context,
233 ): Promise<Source> => {246 ): Promise<Source> => {
234 const executionId = toExecutionId(context?.invocation?.runId);247 const executionId = toExecutionId(context?.invocation?.runId);
@@ -238,50 +251,74 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines
238 const preparedArgs: ExecutorAddSourceInput =251 const preparedArgs: ExecutorAddSourceInput =
239 args.kind === "openapi"252 args.kind === "openapi"
240 ? {253 ? {
241 ...args,254 ...args,
242 workspaceId: input.workspaceId,255 workspaceId: input.workspaceId,
243 executionId,256 executionId,
244 interactionId,257 interactionId,
245 auth:258 }
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 }
271 : {259 : {
272 kind: args.kind,260 kind: args.kind,
273 endpoint: args.endpoint,261 endpoint: args.endpoint,
274 name: args.name ?? null,262 name: args.name ?? null,
275 namespace: args.namespace ?? null,263 namespace: args.namespace ?? null,
276 workspaceId: input.workspaceId,264 workspaceId: input.workspaceId,
277 executionId,265 executionId,
278 interactionId,266 interactionId,
279 };267 };
280 const result = await Effect.runPromise(268 const result = await Effect.runPromise(
281 input.sourceAuthService.addExecutorSource(269 input.sourceAuthService.addExecutorSource(
282 preparedArgs,270 preparedArgs,
283 context?.onElicitation271 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 mcpDiscoveryElicitation: {322 mcpDiscoveryElicitation: {
286 onElicitation: context.onElicitation,323 onElicitation: context.onElicitation,
287 path: context.path ?? asToolPath("executor.sources.add"),324 path: context.path ?? asToolPath("executor.sources.add"),
@@ -291,12 +328,15 @@ export const createExecutorToolMap = (input: { · 3 unmodified lines
291 invocation: context.invocation,328 invocation: context.invocation,
292 },329 },
293 }330 }
294 : undefined,331 : undefined,
295 ),332 ),
296 );333 );
297 334
298 if (result.kind === "connected") {335 if (completed.kind === "connected") {
299 return result.source;336 return completed.source;
337 }
338
339 throw new Error(`Source add was not completed for ${result.source.id}`);
300 }340 }
301 341
302 if (!context?.onElicitation) {342 if (!context?.onElicitation) {
@@ -332,13 +372,13 @@ export const createExecutorToolMap = (input: { · 29 unmodified lines
332 );372 );
333 },373 },
334 },374 },
335 metadata: {375 metadata: {
336 inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT,376 inputHint: EXECUTOR_SOURCES_ADD_INPUT_HINT,
337 outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE,377 outputHint: EXECUTOR_SOURCES_ADD_OUTPUT_SIGNATURE,
338 inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON,378 inputSchemaJson: EXECUTOR_SOURCES_ADD_INPUT_SCHEMA_JSON,
339 outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON,379 outputSchemaJson: EXECUTOR_SOURCES_ADD_OUTPUT_SCHEMA_JSON,
340 sourceKey: "executor",380 sourceKey: "executor",
341 interaction: "auto",381 interaction: "auto",
342 },382 },
343 }),383 }),
344});384});
packages/control-plane/src/runtime/index.ts+13−4
@@ -103,10 +103,19 @@ const createRuntimeLayerFromContext = ( · 102 unmodified lines
103 | RuntimeSourceAuthServiceTag103 | RuntimeSourceAuthServiceTag
104 | RuntimeExecutionResolverService104 | RuntimeExecutionResolverService
105 >,105 >,
106): RuntimeControlPlaneLayer =>106): RuntimeControlPlaneLayer => {
107 Layer.succeedContext(107 const runtimeContext = context.pipe(
108 context.pipe(Context.pick(...runtimeContextTags)),108 Context.pick(...runtimeContextTags),
109 );109 ) as Context.Context<
110 ControlPlaneActorResolver
111 | ControlPlaneStore
112 | LiveExecutionManagerService
113 | RuntimeSourceAuthServiceTag
114 | RuntimeExecutionResolverService
115 >;
116
117 return Layer.succeedContext(runtimeContext);
118};
110 119
111export const createRuntimeControlPlaneLayer = (120export const createRuntimeControlPlaneLayer = (
112 options: RuntimeControlPlaneOptions = {},121 options: RuntimeControlPlaneOptions = {},
packages/control-plane/src/runtime/local-operations.ts+293−5
@@ -1,7 +1,19 @@
1import { ControlPlanePersistenceError } from "#persistence";1import { ControlPlanePersistenceError } from "#persistence";
2import type {
3 Execution,
4 ExecutionInteraction,
5 Source,
6 WorkspaceId,
7} from "#schema";
8import {
9 SourceIdSchema,
10 WorkspaceIdSchema,
11} from "#schema";
2import * as Effect from "effect/Effect";12import * as Effect from "effect/Effect";
13import * as Option from "effect/Option";
3 14
4import { loadLocalInstallation } from "./local-installation";15import { loadLocalInstallation } from "./local-installation";
16import { LiveExecutionManagerService } from "./live-execution";
5import {17import {
6 operationErrors,18 operationErrors,
7} from "./operation-errors";19} from "./operation-errors";
@@ -10,9 +22,145 @@ import { ControlPlaneStore } from "./store"; · 2 unmodified lines
10 22
11const localOps = {23const localOps = {
12 installation: operationErrors("local.installation.get"),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} as const;28} as const;
15 29
30type 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
41const isRecord = (value: unknown): value is Record<string, unknown> =>
42 typeof value === "object" && value !== null;
43
44const asString = (value: unknown): string | null =>
45 typeof value === "string" ? value : null;
46
47const 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
56const 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
99const 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
16export const getLocalInstallation = () =>164export const getLocalInstallation = () =>
17 Effect.gen(function* () {165 Effect.gen(function* () {
18 const store = yield* ControlPlaneStore;166 const store = yield* ControlPlaneStore;
@@ -40,7 +188,147 @@ export const getLocalInstallation = () => · 21 unmodified lines
40 return installation;188 return installation;
41 });189 });
42 190
43export const completeSourceAuthCallback = (input: {191export 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
201export 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
329export const completeSourceCredentialSetup = (input: {
330 workspaceId: WorkspaceId;
331 sourceId: Source["id"];
44 state: string;332 state: string;
45 code?: string | null;333 code?: string | null;
46 error?: string | null;334 error?: string | null;
@@ -49,11 +337,11 @@ export const completeSourceAuthCallback = (input: { · 2 unmodified lines
49 Effect.gen(function* () {337 Effect.gen(function* () {
50 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;338 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
51 339
52 return yield* sourceAuthService.completeSourceAuthCallback(input).pipe(340 return yield* sourceAuthService.completeSourceCredentialSetup(input).pipe(
53 Effect.mapError((error) =>341 Effect.mapError((error) =>
54 localOps.oauthCallback.unknownStorage(342 localOps.sourceCredentialComplete.unknownStorage(
55 error,343 error,
56 "Failed completing source auth callback",344 "Failed completing source credential setup",
57 ),345 ),
58 ),346 ),
59 );347 );
packages/control-plane/src/runtime/source-auth-service.ts+134−15
@@ -18,6 +18,7 @@ import { · 17 unmodified lines
18} from "#persistence";18} from "#persistence";
19import {19import {
20 ExecutionIdSchema,20 ExecutionIdSchema,
21 type SecretMaterialId,
21 SecretMaterialIdSchema,22 SecretMaterialIdSchema,
22 Source,23 Source,
23 SourceAuthSession,24 SourceAuthSession,
@@ -52,7 +53,6 @@ import { · 28 unmodified lines
52} from "./tool-artifacts";53} from "./tool-artifacts";
53 54
54export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane";55export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane";
55const OAUTH_CALLBACK_PATH = "/v1/local/oauth/callback";
56 56
57const trimOrNull = (value: string | null | undefined): string | null => {57const trimOrNull = (value: string | null | undefined): string | null => {
58 if (value === null || value === undefined) {58 if (value === null || value === undefined) {
@@ -101,8 +101,15 @@ const decodeJson = <A>(input: { · 42 unmodified lines
101 }101 }
102};102};
103 103
104const resolveRedirectUrl = (baseUrl: string): string =>104const resolveSourceCredentialOauthCompleteUrl = (input: {
105 new URL(OAUTH_CALLBACK_PATH, baseUrl).toString();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
107const normalizeEndpoint = (endpoint: string): string => {114const normalizeEndpoint = (endpoint: string): string => {
108 const url = new URL(endpoint.trim());115 const url = new URL(endpoint.trim());
@@ -347,7 +354,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 238 unmodified lines
347 captured.tokens = tokens;354 captured.tokens = tokens;
348 },355 },
349 redirectToAuthorization: () => {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 saveCodeVerifier: () => undefined,359 saveCodeVerifier: () => undefined,
353 codeVerifier: () => {360 codeVerifier: () => {
@@ -373,7 +380,7 @@ const exchangeOAuthAuthorizationCode = (input: { · 19 unmodified lines
373 });380 });
374 381
375 if (result !== "AUTHORIZED" || !captured.tokens) {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 return {386 return {
@@ -455,6 +462,10 @@ export type ExecutorSourceAddResult = · 75 unmodified lines
455 kind: "connected";462 kind: "connected";
456 source: Source;463 source: Source;
457 }464 }
465 | {
466 kind: "credential_required";
467 source: Source;
468 }
458 | {469 | {
459 kind: "oauth_required";470 kind: "oauth_required";
460 source: Source;471 source: Source;
@@ -472,6 +483,7 @@ export type ExecutorOpenApiSourceAuthInput = · 11 unmodified lines
472 prefix?: string | null;483 prefix?: string | null;
473 token?: string | null;484 token?: string | null;
474 tokenEnvVar?: string | null;485 tokenEnvVar?: string | null;
486 tokenSecretMaterialId?: string | null;
475 };487 };
476 488
477export type ExecutorAddSourceInput =489export type ExecutorAddSourceInput =
@@ -496,6 +508,17 @@ export type ExecutorAddSourceInput = · 18 unmodified lines
496 auth?: ExecutorOpenApiSourceAuthInput | null;508 auth?: ExecutorOpenApiSourceAuthInput | null;
497 };509 };
498 510
511const 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
499const materializeExecutorOpenApiAuth = (input: {522const materializeExecutorOpenApiAuth = (input: {
500 rows: SqlControlPlaneRows;523 rows: SqlControlPlaneRows;
501 existing?: Source;524 existing?: Source;
@@ -515,10 +538,21 @@ const materializeExecutorOpenApiAuth = (input: { · 13 unmodified lines
515 const prefix = auth.prefix ?? "Bearer ";538 const prefix = auth.prefix ?? "Bearer ";
516 const token = trimOrNull(auth.token);539 const token = trimOrNull(auth.token);
517 const tokenEnvVar = trimOrNull(auth.tokenEnvVar);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 return yield* Effect.fail(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 value: token,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 providerId: "env",573 providerId: "env",
535 handle: tokenEnvVar!,574 handle: tokenEnvVar!,
@@ -548,13 +587,20 @@ type RuntimeSourceAuthServiceShape = { · 12 unmodified lines
548 workspaceId: WorkspaceId;587 workspaceId: WorkspaceId;
549 sourceId: Source["id"];588 sourceId: Source["id"];
550 }) => Effect.Effect<Source, Error, never>;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 addExecutorSource: (595 addExecutorSource: (
552 input: ExecutorAddSourceInput,596 input: ExecutorAddSourceInput,
553 options?: {597 options?: {
554 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;598 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
555 },599 },
556 ) => Effect.Effect<ExecutorSourceAddResult, Error, never>;600 ) => Effect.Effect<ExecutorSourceAddResult, Error, never>;
557 completeSourceAuthCallback: (input: {601 completeSourceCredentialSetup: (input: {
602 workspaceId: WorkspaceId;
603 sourceId: Source["id"];
558 state: string;604 state: string;
559 code?: string | null;605 code?: string | null;
560 error?: string | null;606 error?: string | null;
@@ -602,6 +648,14 @@ export const createRuntimeSourceAuthService = (input: { · 41 unmodified lines
602 });648 });
603 649
604 return {650 return {
651 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
652
653 storeSecretMaterial: ({ purpose, value }) =>
654 upsertSecretMaterial(input.rows, {
655 purpose,
656 value,
657 }),
658
605 getSourceById: ({ workspaceId, sourceId }) =>659 getSourceById: ({ workspaceId, sourceId }) =>
606 loadSourceById(input.rows, {660 loadSourceById(input.rows, {
607 workspaceId,661 workspaceId,
@@ -632,12 +686,56 @@ export const createRuntimeSourceAuthService = (input: { · 24 unmodified lines
632 trimOrNull(sourceInput.namespace)686 trimOrNull(sourceInput.namespace)
633 ?? existing?.namespace687 ?? existing?.namespace
634 ?? defaultNamespaceFromName(chosenName);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 const auth = yield* materializeExecutorOpenApiAuth({734 const auth = yield* materializeExecutorOpenApiAuth({
636 rows: input.rows,735 rows: input.rows,
637 existing,736 existing,
638 auth: sourceInput.auth,737 auth: sourceInput.auth,
639 });738 });
640 const now = Date.now();
641 739
642 const draftSource = existing740 const draftSource = existing
643 ? yield* updateSourceFromPayload({741 ? yield* updateSourceFromPayload({
@@ -812,13 +910,17 @@ export const createRuntimeSourceAuthService = (input: { · 168 unmodified lines
812 const localServerBaseUrl = input.getLocalServerBaseUrl?.();910 const localServerBaseUrl = input.getLocalServerBaseUrl?.();
813 if (!localServerBaseUrl) {911 if (!localServerBaseUrl) {
814 return yield* Effect.fail(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 const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);917 const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
820 const state = crypto.randomUUID();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 const oauthStart = yield* startOAuthAuthorization({924 const oauthStart = yield* startOAuthAuthorization({
823 endpoint: normalizedEndpoint,925 endpoint: normalizedEndpoint,
824 redirectUrl,926 redirectUrl,
@@ -864,7 +966,14 @@ export const createRuntimeSourceAuthService = (input: { · 39 unmodified lines
864 } satisfies ExecutorSourceAddResult;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 Effect.gen(function* () {977 Effect.gen(function* () {
869 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);978 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
870 if (Option.isNone(sessionOption)) {979 if (Option.isNone(sessionOption)) {
@@ -872,6 +981,14 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line
872 }981 }
873 982
874 const session = sessionOption.value;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 const source = yield* loadSourceById(input.rows, {992 const source = yield* loadSourceById(input.rows, {
876 workspaceId: session.workspaceId,993 workspaceId: session.workspaceId,
877 sourceId: session.sourceId,994 sourceId: session.sourceId,
@@ -1015,9 +1132,7 @@ export const createRuntimeSourceAuthService = (input: { · 137 unmodified lines
1015 } satisfies RuntimeSourceAuthServiceShape;1132 } satisfies RuntimeSourceAuthServiceShape;
1016};1133};
1017 1134
1018export type RuntimeSourceAuthService = ReturnType<1135export type RuntimeSourceAuthService = RuntimeSourceAuthServiceShape;
1019 typeof createRuntimeSourceAuthService
1020>;
1021 1136
1022export class RuntimeSourceAuthServiceTag extends Context.Tag(1137export class RuntimeSourceAuthServiceTag extends Context.Tag(
1023 "#runtime/RuntimeSourceAuthServiceTag",1138 "#runtime/RuntimeSourceAuthServiceTag",
@@ -1045,6 +1160,10 @@ export const ExecutorAddSourceResultSchema = Schema.Union( · 21 unmodified lines
1045 kind: Schema.Literal("connected"),1160 kind: Schema.Literal("connected"),
1046 source: SourceSchema,1161 source: SourceSchema,
1047 }),1162 }),
1163 Schema.Struct({
1164 kind: Schema.Literal("credential_required"),
1165 source: SourceSchema,
1166 }),
1048 Schema.Struct({1167 Schema.Struct({
1049 kind: Schema.Literal("oauth_required"),1168 kind: Schema.Literal("oauth_required"),
1050 source: SourceSchema,1169 source: SourceSchema,
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+190−11
@@ -16,6 +16,7 @@ import { describe, expect, it } from "@effect/vitest"; · 15 unmodified lines
16import {16import {
17 AccountIdSchema,17 AccountIdSchema,
18 ExecutionIdSchema,18 ExecutionIdSchema,
19 SecretMaterialIdSchema,
19 SourceIdSchema,20 SourceIdSchema,
20 WorkspaceIdSchema,21 WorkspaceIdSchema,
21} from "#schema";22} from "#schema";
@@ -413,13 +414,126 @@ const persistConnectedEchoTool = (input: { · 391 unmodified lines
413 });414 });
414 });415 });
415 416
417const 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
416const makeResolver = (persistence: SqlControlPlanePersistence) =>528const makeResolver = (persistence: SqlControlPlanePersistence) =>
417 createWorkspaceExecutionEnvironmentResolver({529 createWorkspaceExecutionEnvironmentResolver({
418 rows: persistence.rows,530 rows: persistence.rows,
419 sourceAuthService: {531 sourceAuthService: {
532 getLocalServerBaseUrl: () => null,
533 storeSecretMaterial: () => Effect.fail(new Error("not implemented in test")),
420 getSourceById: () => Effect.fail(new Error("not implemented in test")),534 getSourceById: () => Effect.fail(new Error("not implemented in test")),
421 addExecutorSource: () => Effect.fail(new Error("not implemented in test")),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 } as RuntimeSourceAuthService,537 } as RuntimeSourceAuthService,
424 });538 });
425 539
@@ -429,6 +543,7 @@ const makeResolverWithLiveSourceAuth = (persistence: SqlControlPlanePersistence) · 3 unmodified lines
429 sourceAuthService: createRuntimeSourceAuthService({543 sourceAuthService: createRuntimeSourceAuthService({
430 rows: persistence.rows,544 rows: persistence.rows,
431 liveExecutionManager: createLiveExecutionManager(),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 it.scoped("invokes persisted MCP tools on demand without re-listing", () =>643 it.scoped("invokes persisted MCP tools on demand without re-listing", () =>
481 Effect.gen(function* () {644 Effect.gen(function* () {
482 const server = yield* makeCountedMcpServer;645 const server = yield* makeCountedMcpServer;
@@ -518,10 +681,22 @@ describe("workspace-execution-environment", () => { · 35 unmodified lines
518 const persistence = yield* makePersistence;681 const persistence = yield* makePersistence;
519 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");682 const workspaceId = WorkspaceIdSchema.make("ws_add_openapi");
520 const resolveEnvironment = makeResolverWithLiveSourceAuth(persistence);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 let capturedElicitation:695 let capturedElicitation:
522 | {696 | {
523 mode?: string;697 mode?: string;
524 requestedSchema?: Record<string, unknown>;698 url?: string;
699 elicitationId?: string;
525 }700 }
526 | null = null;701 | null = null;
527 702
@@ -533,13 +708,15 @@ describe("workspace-execution-environment", () => { · 5 unmodified lines
533 Effect.sync(() => {708 Effect.sync(() => {
534 capturedElicitation = request.elicitation as {709 capturedElicitation = request.elicitation as {
535 mode?: string;710 mode?: string;
536 requestedSchema?: Record<string, unknown>;711 url?: string;
712 elicitationId?: string;
537 };713 };
538 714
539 return {715 return {
540 action: "accept" as const,716 action: "accept" as const,
541 content: {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 specUrl: specServer.specUrl,730 specUrl: specServer.specUrl,
554 name: "GitHub",731 name: "GitHub",
555 namespace: "github",732 namespace: "github",
556 auth: {
557 kind: "bearer",
558 },
559 },733 },
560 context: {734 context: {
561 runId: "exec_add_openapi",735 runId: "exec_add_openapi",
@@ -576,11 +750,13 @@ describe("workspace-execution-environment", () => { · 14 unmodified lines
576 expect(added.auth.kind).toBe("bearer");750 expect(added.auth.kind).toBe("bearer");
577 expect(added.auth.token?.providerId).toBe("control-plane");751 expect(added.auth.token?.providerId).toBe("control-plane");
578 expect(capturedElicitation).toMatchObject({752 expect(capturedElicitation).toMatchObject({
579 mode: "form",753 mode: "url",
580 requestedSchema: {754 url: expect.stringContaining("/v1/workspaces/"),
581 type: "object",755 elicitationId: expect.stringContaining("executor.sources.add:"),
582 },
583 });756 });
757 expect((capturedElicitation as { url?: string } | null)?.url).toContain(
758 encodeURIComponent("exec_add_openapi:executor.sources.add:"),
759 );
584 760
585 const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId(761 const storedArtifacts = yield* persistence.rows.toolArtifacts.listByWorkspaceId(
586 workspaceId,762 workspaceId,
@@ -656,7 +832,9 @@ describe("workspace-execution-environment", () => { · 69 unmodified lines
656 expect(described?.path).toBe("executor.sources.add");832 expect(described?.path).toBe("executor.sources.add");
657 expect(described?.description).toContain("Source add input shapes:");833 expect(described?.description).toContain("Source add input shapes:");
658 expect(described?.description).toContain("specUrl");834 expect(described?.description).toContain("specUrl");
835 expect(described?.description).toContain("credential setup");
659 expect(described?.inputHint).toContain("endpoint");836 expect(described?.inputHint).toContain("endpoint");
837 expect(described?.inputHint).not.toContain("auth");
660 838
661 const inputSchema = described?.inputSchemaJson839 const inputSchema = described?.inputSchemaJson
662 ? JSON.parse(described.inputSchemaJson) as Record<string, unknown>840 ? JSON.parse(described.inputSchemaJson) as Record<string, unknown>
@@ -664,6 +842,7 @@ describe("workspace-execution-environment", () => { · 1 unmodified line
664 expect(inputSchema).not.toBeNull();842 expect(inputSchema).not.toBeNull();
665 expect(JSON.stringify(inputSchema)).toContain("specUrl");843 expect(JSON.stringify(inputSchema)).toContain("specUrl");
666 expect(JSON.stringify(inputSchema)).toContain("endpoint");844 expect(JSON.stringify(inputSchema)).toContain("endpoint");
845 expect(JSON.stringify(inputSchema)).not.toContain("\"auth\"");
667 }),846 }),
668 );847 );
669});848});
packages/control-plane/src/runtime/workspace-execution-environment.ts+148−9
@@ -55,17 +55,157 @@ const tokenize = (value: string): string[] => · 54 unmodified lines
55 value55 value
56 .trim()56 .trim()
57 .toLowerCase()57 .toLowerCase()
58 .split(/\s+/)58 .split(/[^a-z0-9]+/)
59 .filter(Boolean);59 .filter(Boolean);
60 60
61const 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
84const singularizeToken = (value: string): string =>
85 value.length > 3 && value.endsWith("s")
86 ? value.slice(0, -1)
87 : value;
88
89const tokenEquals = (left: string, right: string): boolean =>
90 left === right || singularizeToken(left) === singularizeToken(right);
91
92const hasTokenMatch = (tokens: readonly string[], queryToken: string): boolean =>
93 tokens.some((token) => tokenEquals(token, queryToken));
94
95const 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
104const queryTokenWeight = (token: string): number =>
105 LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1;
106
61const scoreArtifact = (107const scoreArtifact = (
62 queryTokens: readonly string[],108 queryTokens: readonly string[],
63 searchText: string,109 artifact: StoredToolArtifactRecord,
64): number =>110): number => {
65 queryTokens.reduce(111 const pathText = artifact.path.toLowerCase();
66 (total, token) => total + (searchText.includes(token) ? 1 : 0),112 const namespaceText = artifact.searchNamespace.toLowerCase();
67 0,113 const toolIdText = artifact.toolId.toLowerCase();
68 );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
70const toDescriptor = (input: {210const toDescriptor = (input: {
71 artifact: StoredToolArtifactRecord;211 artifact: StoredToolArtifactRecord;
@@ -263,7 +403,6 @@ const createWorkspaceToolCatalog = (input: { · 191 unmodified lines
263 : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, {403 : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, {
264 namespace,404 namespace,
265 query,405 query,
266 limit: Math.max(limit * 8, 50),
267 }).pipe(406 }).pipe(
268 Effect.mapError((cause) =>407 Effect.mapError((cause) =>
269 cause instanceof Error ? cause : new Error(String(cause)),408 cause instanceof Error ? cause : new Error(String(cause)),
@@ -279,7 +418,7 @@ const createWorkspaceToolCatalog = (input: { · 9 unmodified lines
279 const persistedHits: SearchHit[] = persisted418 const persistedHits: SearchHit[] = persisted
280 .map((artifact) => ({419 .map((artifact) => ({
281 path: asToolPath(artifact.path),420 path: asToolPath(artifact.path),
282 score: scoreArtifact(queryTokens, artifact.searchText),421 score: scoreArtifact(queryTokens, artifact),
283 }))422 }))
284 .filter((hit) => hit.score > 0);423 .filter((hit) => hit.score > 0);
285 424
packages/runtime-local-inproc/src/index.ts+1−2
@@ -10,7 +10,7 @@ export type InProcessExecutorOptions = { · 9 unmodified lines
10 allowFetch?: boolean;10 allowFetch?: boolean;
11};11};
12 12
13const DEFAULT_TIMEOUT_MS = 30_000;13const DEFAULT_TIMEOUT_MS = 5 * 60_000;
14 14
15const formatLogArg = (value: unknown): string => {15const formatLogArg = (value: unknown): string => {
16 if (typeof value === "string") {16 if (typeof value === "string") {
@@ -179,4 +179,3 @@ export const makeInProcessExecutor = ( · 162 unmodified lines
179 execute: (code: string, toolInvoker: ToolInvoker) =>179 execute: (code: string, toolInvoker: ToolInvoker) =>
180 runInProcess(options, code, toolInvoker),180 runInProcess(options, code, toolInvoker),
181});181});
182