History

Expand agent-facing Executor configuration tools (#841)

* feat(sdk): add built-in core-tools plugin with agent secret-create flow Introduces `coreToolsPlugin` in @executor-js/sdk exposing three static tools agents can call directly: - `scopes.list` — enumerate visible scopes by name; agents pick a scope by asking the user, not by guessing or defaulting. - `secrets.list` — visible secrets as `{id, name, provider}`; values never cross the agent boundary. - `secrets.create` — pre-allocates a secret id and returns a URL to the existing /secrets web form, pre-filled with name + id. The user enters the value in the browser; the agent confirms by re-listing. The plugin auto-registers when `coreTools: { webBaseUrl }` is set on ExecutorConfig, so hosts opt in with one line. apps/local wires it to the daemon's own port — same host as the API, no separate UI server. The /secrets web side reads the prefill params via TanStack Router `validateSearch`, opens the add modal pre-filled, and locks the id via a new `initialIdOverride` prop on SecretForm. URL prefilling works fully through the dev:cli vite proxy. * fix(sdk): use typed core tools errors * feat: expand agent executor configuration tools * feat: expose source presets to agents * fix: resolve agent source install scopes * fix: clarify secret creation for agents * fix: render open object schema previews * fix: expose typed source configure tools * fix: stabilize agent openapi preview flow * fix: let secret handoff choose scope * fix: explain secret handoff completion * fix: explain oauth handoff completion * fix: clarify oauth credential scope * fix: satisfy core tools ci * fix: clarify preset and resume guidance

Rhys Sullivan committed 5 months ago

Commit c93f9331 parent 365f70454 files · +3895 −156Browse files

Showing the changes to scripts (1 of 54 files). Show all

Unified · Split

scripts/agent-config-smoke.ts+340−0
@@ -0,0 +1,340 @@
1#!/usr/bin/env bun
2/**
3 * End-to-end smoke for agent-driven Executor configuration.
4 *
5 * This intentionally drives the public dev CLI for agent actions, while using
6 * the local HTTP browser/API boundary for sensitive values and OAuth callback
7 * completion. Fake test credentials are used, but the shape matches the real
8 * flow: agents get handoff URLs, users/browsers enter secrets and complete
9 * OAuth outside the context window, then agents verify and bind the resulting
10 * ids.
11 */
12import { mkdtemp, mkdir, rm } from "node:fs/promises";
13import { tmpdir } from "node:os";
14import { dirname, join, resolve } from "node:path";
15import { fileURLToPath } from "node:url";
16
17import { serveOAuthTestServer, type OAuthTestServerShape } from "../packages/core/sdk/src/testing";
18import {
19 type GraphqlTestServerShape,
20 makeGreetingGraphqlSchema,
21 serveGraphqlTestServer,
22} from "../packages/plugins/graphql/src/testing/index";
23import { Effect } from "effect";
24
25const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26const cliEntrypoint = join(repoRoot, "apps/cli/src/main.ts");
27
28type CliContext = {
29 readonly dataDir: string;
30 readonly scopeDir: string;
31 readonly baseUrl: string;
32};
33
34type CliResult = {
35 readonly stdout: string;
36 readonly stderr: string;
37 readonly text: string;
38 readonly exitCode: number;
39};
40
41const assert = (condition: unknown, message: string): asserts condition => {
42 if (!condition) throw new Error(message);
43};
44
45const runCli = async (
46 ctx: CliContext,
47 args: readonly string[],
48 options: { readonly allowFailure?: boolean } = {},
49): Promise<CliResult> => {
50 const proc = Bun.spawn([process.execPath, "run", cliEntrypoint, ...args], {
51 cwd: repoRoot,
52 env: {
53 ...process.env,
54 EXECUTOR_DEV: "1",
55 EXECUTOR_DATA_DIR: ctx.dataDir,
56 EXECUTOR_SCOPE_DIR: ctx.scopeDir,
57 },
58 stdout: "pipe",
59 stderr: "pipe",
60 });
61
62 const [stdout, stderr, exitCode] = await Promise.all([
63 new Response(proc.stdout).text(),
64 new Response(proc.stderr).text(),
65 proc.exited,
66 ]);
67 const result = { stdout, stderr, text: `${stdout}${stderr}`, exitCode };
68
69 if (exitCode !== 0 && !options.allowFailure) {
70 throw new Error(`CLI failed (${exitCode}) for ${args.join(" ")}\n${result.text}`);
71 }
72
73 return result;
74};
75
76const parseJsonOutput = <T>(result: CliResult): T => {
77 const start = result.stdout.indexOf("{");
78 const end = result.stdout.lastIndexOf("}");
79 assert(start >= 0 && end > start, `CLI output did not contain JSON:\n${result.text}`);
80 return JSON.parse(result.stdout.slice(start, end + 1)) as T;
81};
82
83const toolData = <T>(value: unknown): T => {
84 const result = value as { readonly ok?: boolean; readonly data?: T; readonly error?: unknown };
85 assert(result.ok === true, `Tool returned failure: ${JSON.stringify(value)}`);
86 return result.data as T;
87};
88
89const extractExecutionId = (text: string): string => {
90 const match = /executionId:\s*(exec_[A-Za-z0-9_]+)/.exec(text);
91 assert(match?.[1], `CLI output did not contain an execution id:\n${text}`);
92 return match[1];
93};
94
95const callTool = (ctx: CliContext, path: readonly string[], args: unknown = {}) =>
96 runCli(ctx, [
97 "call",
98 ...path,
99 JSON.stringify(args),
100 "--base-url",
101 ctx.baseUrl,
102 "--scope",
103 ctx.scopeDir,
104 ]);
105
106const approvePausedCall = async (ctx: CliContext, paused: CliResult): Promise<CliResult> => {
107 const executionId = extractExecutionId(paused.text);
108 return await runCli(ctx, [
109 "resume",
110 "--execution-id",
111 executionId,
112 "--base-url",
113 ctx.baseUrl,
114 "--scope",
115 ctx.scopeDir,
116 ]);
117};
118
119const postJson = async (url: string, payload: unknown): Promise<unknown> => {
120 const response = await fetch(url, {
121 method: "POST",
122 headers: { "content-type": "application/json" },
123 body: JSON.stringify(payload),
124 });
125 const bodyText = await response.text();
126 if (!response.ok) {
127 throw new Error(`POST ${url} failed with HTTP ${response.status}: ${bodyText}`);
128 }
129 return bodyText.length > 0 ? JSON.parse(bodyText) : null;
130};
131
132const firstScope = (value: unknown): { readonly id: string; readonly name: string } => {
133 const data = toolData<{
134 readonly scopes: readonly { readonly id: string; readonly name: string }[];
135 }>(value);
136 const scopes = data.scopes;
137 const scope = scopes?.[0];
138 assert(scope, `scopes.list returned no scopes: ${JSON.stringify(value)}`);
139 return scope;
140};
141
142const createSecretPlaceholder = async (
143 ctx: CliContext,
144 scope: string,
145 expectedHandoffScope: string,
146 name: string,
147) => {
148 const result = toolData<{ readonly id: string; readonly url: string }>(
149 parseJsonOutput(
150 await callTool(ctx, ["executor", "coreTools", "secrets", "create"], { name, scope }),
151 ),
152 );
153 const handoff = new URL(result.url);
154 assert(handoff.pathname === "/secrets", `Expected /secrets handoff URL, got ${result.url}`);
155 assert(
156 handoff.searchParams.get("scope") === expectedHandoffScope,
157 `Secret handoff omitted scope: ${result.url}`,
158 );
159 return result;
160};
161
162const setSecretViaBrowserBoundary = async (
163 ctx: CliContext,
164 browserBaseUrl: string,
165 scopeId: string,
166 input: { readonly id: string; readonly name: string; readonly value: string },
167) => {
168 await postJson(`${browserBaseUrl}/api/scopes/${encodeURIComponent(scopeId)}/secrets`, input);
169 const status = toolData<{ readonly status: "resolved" | "missing" }>(
170 parseJsonOutput(
171 await callTool(ctx, ["executor", "coreTools", "secrets", "status"], { id: input.id }),
172 ),
173 );
174 assert(status.status === "resolved", `Secret ${input.id} was not resolved`);
175};
176
177const runSmoke = async (oauth: OAuthTestServerShape, graph: GraphqlTestServerShape) => {
178 const root = await mkdtemp(join(tmpdir(), "executor-agent-config-smoke-"));
179 const ctx: CliContext = {
180 dataDir: join(root, "data"),
181 scopeDir: join(root, "scope"),
182 baseUrl: `http://127.0.0.1:${64180 + Math.floor(Math.random() * 1000)}`,
183 };
184 await mkdir(ctx.dataDir, { recursive: true });
185 await mkdir(ctx.scopeDir, { recursive: true });
186
187 try {
188 console.log("[agent-config-smoke] start dev CLI daemon");
189 const port = new URL(ctx.baseUrl).port;
190 await runCli(ctx, ["daemon", "run", "--port", port, "--scope", ctx.scopeDir]);
191
192 console.log("[agent-config-smoke] discover scope");
193 const scope = firstScope(
194 parseJsonOutput(await callTool(ctx, ["executor", "coreTools", "scopes", "list"])),
195 );
196
197 console.log("[agent-config-smoke] create browser secret handoffs");
198 const clientId = await createSecretPlaceholder(ctx, ctx.scopeDir, scope.id, "OAuth client id");
199 const clientSecret = await createSecretPlaceholder(
200 ctx,
201 ctx.scopeDir,
202 scope.id,
203 "OAuth client secret",
204 );
205 const browserBaseUrl = new URL(clientId.url).origin;
206 await setSecretViaBrowserBoundary(ctx, browserBaseUrl, scope.id, {
207 id: clientId.id,
208 name: "OAuth client id",
209 value: "test-client",
210 });
211 await setSecretViaBrowserBoundary(ctx, browserBaseUrl, scope.id, {
212 id: clientSecret.id,
213 name: "OAuth client secret",
214 value: "test-secret",
215 });
216
217 console.log("[agent-config-smoke] start OAuth handoff through core tool");
218 const oauthStart = toolData<{
219 readonly sessionId: string;
220 readonly authorizationUrl: string | null;
221 }>(
222 parseJsonOutput(
223 await callTool(ctx, ["executor", "coreTools", "oauth", "start"], {
224 scope: ctx.scopeDir,
225 endpoint: oauth.resourceUrl,
226 connectionId: "agent-smoke-oauth",
227 pluginId: "graphql",
228 identityLabel: "Agent Smoke OAuth",
229 strategy: {
230 kind: "authorization-code",
231 authorizationEndpoint: oauth.authorizationEndpoint,
232 tokenEndpoint: oauth.tokenEndpoint,
233 clientIdSecretId: clientId.id,
234 clientSecretSecretId: clientSecret.id,
235 scopes: ["read"],
236 },
237 }),
238 ),
239 );
240 assert(oauthStart.authorizationUrl, "OAuth start did not return an authorization URL");
241
242 console.log("[agent-config-smoke] complete OAuth callback through browser URL");
243 const callback = await Effect.runPromise(
244 oauth.completeAuthorizationCodeFlow({
245 authorizationUrl: oauthStart.authorizationUrl,
246 }),
247 );
248 const callbackResponse = await fetch(callback.callbackUrl);
249 const callbackHtml = await callbackResponse.text();
250 assert(callbackResponse.ok, `OAuth callback failed: ${callbackHtml}`);
251 assert(
252 callbackHtml.includes("Authentication complete"),
253 "OAuth callback did not render success",
254 );
255
256 const callbackBaseUrl = new URL(callback.callbackUrl).origin;
257 const pollResponse = await fetch(
258 `${callbackBaseUrl}/api/oauth/await/${encodeURIComponent(callback.state)}`,
259 );
260 const pollResult = (await pollResponse.json()) as { readonly ok?: boolean };
261 assert(pollResult.ok === true, `OAuth await result was not ok: ${JSON.stringify(pollResult)}`);
262
263 const connections = toolData<{
264 readonly connections: readonly { readonly id: string }[];
265 }>(parseJsonOutput(await callTool(ctx, ["executor", "coreTools", "connections", "list"])));
266 assert(
267 connections.connections.some((connection) => connection.id === "agent-smoke-oauth"),
268 `OAuth connection was not listed: ${JSON.stringify(connections)}`,
269 );
270
271 console.log("[agent-config-smoke] add OAuth-backed GraphQL source through approval flow");
272 const addSource = await approvePausedCall(
273 ctx,
274 await callTool(ctx, ["executor", "graphql", "addSource"], {
275 scope: ctx.scopeDir,
276 endpoint: graph.endpoint,
277 name: "Agent Smoke GraphQL",
278 namespace: "agent_smoke_graphql",
279 oauth2: {
280 kind: "oauth2",
281 securitySchemeName: "OAuth2",
282 flow: "authorizationCode",
283 tokenUrl: oauth.tokenEndpoint,
284 authorizationUrl: oauth.authorizationEndpoint,
285 clientIdSlot: "auth:oauth2:client-id",
286 clientSecretSlot: null,
287 connectionSlot: "auth:oauth2:connection",
288 scopes: ["read"],
289 },
290 credentials: {
291 scope: scope.id,
292 auth: {
293 oauth2: {
294 connection: { kind: "connection", connectionId: "agent-smoke-oauth" },
295 },
296 },
297 },
298 }),
299 );
300 const added = parseJsonOutput<{ readonly ok: boolean }>(addSource);
301 assert(added.ok === true, `GraphQL addSource failed: ${addSource.text}`);
302
303 console.log("[agent-config-smoke] invoke configured OAuth-backed tool");
304 const invoked = parseJsonOutput<{ readonly ok: boolean; readonly data?: unknown }>(
305 await callTool(ctx, ["agent_smoke_graphql", "query", "hello"], { name: "Ada" }),
306 );
307 assert(
308 invoked.ok === true &&
309 JSON.stringify(invoked.data) === JSON.stringify({ hello: "Hello Ada" }),
310 `GraphQL invocation failed: ${JSON.stringify(invoked)}`,
311 );
312
313 console.log("[agent-config-smoke] passed");
314 } finally {
315 await runCli(ctx, ["daemon", "stop", "--base-url", ctx.baseUrl], {
316 allowFailure: true,
317 });
318 await rm(root, { recursive: true, force: true });
319 }
320};
321
322const main = async () => {
323 await Effect.runPromise(
324 Effect.scoped(
325 Effect.gen(function* () {
326 const oauth = yield* serveOAuthTestServer();
327 const graph = yield* serveGraphqlTestServer({
328 schema: makeGreetingGraphqlSchema(),
329 auth: {
330 validateAuthorization: oauth.acceptsAuthorizationHeader,
331 wwwAuthenticate: `Bearer resource_metadata="${oauth.protectedResourceMetadataUrl}/graphql"`,
332 },
333 });
334 yield* Effect.promise(() => runSmoke(oauth, graph));
335 }),
336 ),
337 );
338};
339
340await main();

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t