History

update executor tools

Route confidential OAuth-app secrets through a browser handoff instead of the agent context, and approval-gate connection/oauth/policy writes.

Rhys Sullivan committed 4 months ago

Commit 2bca9431 parent 10858448 files · +853 −13Browse files

Unified · Split

8 files
  1. e2e/scenarios/connect-handoff.test.ts +9 −5
  2. e2e/scenarios/no-auth-connection.test.ts +4 −1
  3. e2e/scenarios/oauth-client-handoff.test.ts +478 −0
  4. e2e/scenarios/policy-tool-approval.test.ts +144 −0
  5. packages/core/sdk/src/client.ts +13 −0
  6. packages/core/sdk/src/core-tools.ts +130 −3
  7. packages/react/src/components/add-account-modal.tsx +54 −4
  8. packages/react/src/pages/integration-detail.tsx +21 −0
e2e/scenarios/connect-handoff.test.ts+9−5
@@ -89,13 +89,15 @@ const sent = await t({ · 88 unmodified lines
8989 return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error };
9090 `;
9191
92−/** Run `execute`, auto-approving a paused execution (policy elicitation) once,
93− * and parse the sandbox's JSON return value. */
92+/** Run `execute`, auto-approving any paused executions (approval-gated tools
93+ * pause once per gated call) and parse the sandbox's JSON return value. */
9494 const executeJson = (session: McpSession, code: string) =>
9595 Effect.gen(function* () {
9696 let result = yield* session.call("execute", { code });
97− if (result.text.includes("executionId:")) {
97+ let guard = 0;
98+ while (result.text.includes("executionId:") && guard < 10) {
9899 result = yield* session.approvePaused(result.text);
100+ guard += 1;
99101 }
100102 expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
101103 return JSON.parse(result.text) as Record<string, unknown>;
@@ -148,10 +150,12 @@ scenario( · 46 unmodified lines
148150 orgSlug: orgSlug!,
149151 }).pipe(
150152 // Best-effort cleanup even on failure: drop the created connection(s)
151− // over MCP, then the integration over the API.
153+ // over MCP, then the integration over the API. `connections.remove` is
154+ // approval-gated, so the cleanup execute pauses per connection;
155+ // `executeJson` auto-approves each pause so the removes actually run.
152156 Effect.ensuring(
153157 Effect.gen(function* () {
154− yield* session.call("execute", { code: removeConnectionsCode(integration) });
158+ yield* executeJson(session, removeConnectionsCode(integration));
155159 yield* client.openapi.removeSpec({ params: { slug: integration } });
156160 }).pipe(Effect.ignore),
157161 ),
e2e/scenarios/no-auth-connection.test.ts+4−1
@@ -192,9 +192,12 @@ scenario( · 191 unmodified lines
192192 }).pipe(
193193 // Selfhost shares one workspace identity — leaked connections fail other
194194 // scenarios' zero-state assertions, so drop everything this run made.
195+ // `connections.remove` is approval-gated, so the cleanup execute pauses
196+ // per connection; `executeJson` auto-approves each pause so the removes
197+ // actually run.
195198 Effect.ensuring(
196199 Effect.gen(function* () {
197− yield* session.call("execute", { code: removeConnectionsCode(integration) });
200+ yield* executeJson(session, removeConnectionsCode(integration));
198201 yield* client.openapi.removeSpec({ params: { slug: integration } });
199202 }).pipe(Effect.ignore),
200203 ),
e2e/scenarios/oauth-client-handoff.test.ts+478−0
@@ -0,0 +1,478 @@
1+// Secrets never cross the agent boundary: a confidential OAuth app is registered
2+// through a BROWSER handoff, not by passing the client secret as a tool argument.
3+//
4+// This is the regression guard for the secret-leak fix. The agent-facing
5+// `oauth.clients.create` used to take a `clientSecret`, which meant a confidential
6+// app's secret flowed through the LLM context window. That field is gone; the
7+// agent now calls `oauth.clients.createHandoff`, which returns a deep link into
8+// the web UI where the HUMAN types the secret, exactly like a pasted connection
9+// credential (`connections.createHandoff`).
10+//
11+// Three scenarios:
12+// 1. `createHandoff` returns a correct, secret-free deep link and is NOT
13+// approval-gated (it is the safe path). A smuggled `clientSecret` argument
14+// never appears in the URL.
15+// 2. `oauth.clients.create` still pauses for human approval (the write gate
16+// survived the secret removal) and writes nothing when declined.
17+// 3. Watch-it: the agent hands off, a human opens the URL and types the secret
18+// into the Register-OAuth-app form (auto-opened and pre-filled from the
19+// handoff), the app registers, and the agent completes a client-credentials
20+// connection against the Microsoft emulator without ever seeing the secret.
21+import { randomBytes } from "node:crypto";
22+
23+import { expect } from "@effect/vitest";
24+import { Effect } from "effect";
25+import { AccountHttpApi } from "@executor-js/api";
26+import { composePluginApi } from "@executor-js/api/server";
27+import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28+import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
29+import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
30+import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
31+
32+import { scenario } from "../src/scenario";
33+import { Api, Browser, Mcp, Target } from "../src/services";
34+import type { McpSession } from "../src/surfaces/mcp";
35+
36+const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);
37+
38+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
39+
40+// A sentinel the agent tries to smuggle as a `clientSecret` argument the handoff
41+// tool does not declare. The secret must never reach the returned URL (nor any
42+// part of the agent-visible response), proving the boundary drops it.
43+const SMUGGLED_SECRET = "SMUGGLED_SECRET_DO_NOT_LEAK_4f1a9c";
44+
45+/** Run `execute`, auto-approving any paused (approval-gated) calls, and parse the
46+ * sandbox's JSON return value. Mirrors the connect-handoff helper. */
47+const executeJson = (session: McpSession, code: string) =>
48+ Effect.gen(function* () {
49+ let result = yield* session.call("execute", { code });
50+ let guard = 0;
51+ while (result.text.includes("executionId:") && guard < 10) {
52+ result = yield* session.approvePaused(result.text);
53+ guard += 1;
54+ }
55+ expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true);
56+ return JSON.parse(result.text) as Record<string, unknown>;
57+ });
58+
59+// ---------------------------------------------------------------------------
60+// 1. The handoff URL: secret-free, correctly addressed, and not approval-gated.
61+// ---------------------------------------------------------------------------
62+
63+const createHandoffCode = (input: {
64+ readonly integration: string;
65+ readonly slug: string;
66+ readonly clientId: string;
67+ readonly authorizationUrl: string;
68+ readonly tokenUrl: string;
69+}) => `
70+const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
71+ integration: ${JSON.stringify(input.integration)},
72+ owner: "user",
73+ slug: ${JSON.stringify(input.slug)},
74+ grant: "client_credentials",
75+ clientId: ${JSON.stringify(input.clientId)},
76+ authorizationUrl: ${JSON.stringify(input.authorizationUrl)},
77+ tokenUrl: ${JSON.stringify(input.tokenUrl)},
78+});
79+return handoff.ok
80+ ? { ok: true, url: handoff.data.url, instructions: handoff.data.instructions }
81+ : { ok: false, error: handoff.error };
82+`;
83+
84+// The same call, but with a `clientSecret` the tool does not declare. Whatever
85+// the boundary does with the excess field (drop or reject), the secret VALUE
86+// must not appear anywhere in the agent-visible result.
87+const smuggleSecretCode = (input: {
88+ readonly integration: string;
89+ readonly slug: string;
90+ readonly clientId: string;
91+ readonly tokenUrl: string;
92+}) => `
93+const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
94+ integration: ${JSON.stringify(input.integration)},
95+ slug: ${JSON.stringify(input.slug)},
96+ grant: "client_credentials",
97+ clientId: ${JSON.stringify(input.clientId)},
98+ tokenUrl: ${JSON.stringify(input.tokenUrl)},
99+ clientSecret: ${JSON.stringify(SMUGGLED_SECRET)},
100+});
101+return JSON.stringify(handoff);
102+`;
103+
104+scenario(
105+ "OAuth client · createHandoff returns a secret-free deep link and is not approval-gated",
106+ {},
107+ Effect.gen(function* () {
108+ const target = yield* Target;
109+ const { client: makeApiClient } = yield* Api;
110+ const mcp = yield* Mcp;
111+ const identity = yield* target.newIdentity();
112+ const session = mcp.session(identity);
113+
114+ // The bound org's slug, read from the same surface the console shell reads —
115+ // the handoff URL must canonicalize onto exactly this org.
116+ const accountClient = yield* makeApiClient(AccountHttpApi, identity);
117+ const me = yield* accountClient.account.me();
118+ const orgSlug = me.organization?.slug;
119+ expect(orgSlug, "the bound organization advertises a URL slug").toBeTruthy();
120+
121+ const integration = unique("oauthhf");
122+ const clientSlug = unique("app");
123+ const clientId = unique("client-id");
124+ const authorizationUrl = "https://issuer.example.com/oauth2/v2.0/authorize";
125+ const tokenUrl = "https://issuer.example.com/oauth2/v2.0/token";
126+
127+ // createHandoff is a pure URL builder — it must NOT pause for approval (it is
128+ // the safe path that routes the secret to the human, mirroring
129+ // `connections.createHandoff`).
130+ const raw = yield* session.call("execute", {
131+ code: createHandoffCode({
132+ integration,
133+ slug: clientSlug,
134+ clientId,
135+ authorizationUrl,
136+ tokenUrl,
137+ }),
138+ });
139+ expect(raw.text, "createHandoff is not approval-gated (no pause)").not.toContain(
140+ "executionId:",
141+ );
142+ expect(raw.ok, `createHandoff execute completed (got: ${raw.text.slice(0, 400)})`).toBe(true);
143+ const handoff = JSON.parse(raw.text) as { ok: boolean; url?: string; instructions?: string };
144+ expect(handoff.ok, `createHandoff succeeded: ${raw.text.slice(0, 400)}`).toBe(true);
145+
146+ const url = new URL(String(handoff.url));
147+ expect(url.origin, `handoff URL (${handoff.url}) targets this deployment`).toBe(
148+ new URL(target.baseUrl).origin,
149+ );
150+ expect(url.pathname, `handoff URL (${handoff.url}) carries the bound org slug`).toBe(
151+ `/${orgSlug}/integrations/${integration}`,
152+ );
153+ // The flags that flip the integration's Add-account flow into the
154+ // Register-OAuth-app form, pre-filled with the agent's non-secret fields.
155+ expect(url.searchParams.get("addAccount")).toBe("1");
156+ expect(url.searchParams.get("oauthClient")).toBe("1");
157+ expect(url.searchParams.get("owner")).toBe("user");
158+ expect(url.searchParams.get("clientSlug")).toBe(clientSlug);
159+ expect(url.searchParams.get("grant")).toBe("client_credentials");
160+ expect(url.searchParams.get("clientId")).toBe(clientId);
161+ expect(url.searchParams.get("authorizationUrl")).toBe(authorizationUrl);
162+ expect(url.searchParams.get("tokenUrl")).toBe(tokenUrl);
163+
164+ // No secret leaves the host: there is no secret-bearing field on the tool,
165+ // so the URL carries none.
166+ expect(url.searchParams.get("clientSecret"), "no clientSecret param").toBeNull();
167+ expect(url.searchParams.get("secret"), "no secret param").toBeNull();
168+
169+ // The instructions steer the agent away from ever asking for the secret.
170+ expect(String(handoff.instructions)).toMatch(/client secret/i);
171+ expect(String(handoff.instructions)).toMatch(/chat/i);
172+
173+ // Adversarial: an agent that tries to smuggle a secret as an undeclared
174+ // argument gets it dropped — the value never appears in the response.
175+ const smuggled = yield* session.call("execute", {
176+ code: smuggleSecretCode({ integration, slug: clientSlug, clientId, tokenUrl }),
177+ });
178+ expect(
179+ smuggled.text.includes(SMUGGLED_SECRET),
180+ "a smuggled client secret never reaches the agent-visible handoff result",
181+ ).toBe(false);
182+ }),
183+);
184+
185+// ---------------------------------------------------------------------------
186+// 2. The write path stays gated: `oauth.clients.create` (now secret-free) still
187+// pauses for human approval, and a decline writes nothing.
188+// ---------------------------------------------------------------------------
189+
190+const createPublicClientCode = (slug: string) => `
191+const result = await tools.executor.coreTools.oauth.clients.create({
192+ owner: "user",
193+ slug: ${JSON.stringify(slug)},
194+ authorizationUrl: "https://issuer.example.com/authorize",
195+ tokenUrl: "https://issuer.example.com/token",
196+ grant: "authorization_code",
197+ clientId: ${JSON.stringify(unique("public-client"))},
198+});
199+return JSON.stringify(result);
200+`;
201+
202+const listClientsCode = `
203+const result = await tools.executor.coreTools.oauth.clients.list({});
204+return JSON.stringify(result.ok ? result.data.clients.map((c) => c.slug) : { error: result.error });
205+`;
206+
207+scenario(
208+ "OAuth client · oauth.clients.create still pauses for human approval and a decline writes nothing",
209+ {},
210+ Effect.gen(function* () {
211+ const target = yield* Target;
212+ const mcp = yield* Mcp;
213+ const identity = yield* target.newIdentity();
214+ const session = mcp.session(identity);
215+ const slug = unique("gated-app");
216+
217+ yield* session.listTools();
218+
219+ // Nothing has registered a policy here — the only thing that can pause this
220+ // call is the tool's own `requiresApproval` annotation, which must have
221+ // survived dropping the client secret from the input.
222+ const paused = yield* session.call("execute", { code: createPublicClientCode(slug) });
223+ expect(paused.text, "oauth.clients.create pauses for approval (annotation guard)").toContain(
224+ "Execution paused",
225+ );
226+ expect(paused.text, "paused result carries the executionId").toContain("executionId:");
227+
228+ // Decline: the client must not be written.
229+ const match = /\bexecutionId:\s*(\S+)/.exec(paused.text);
230+ expect(match, "paused result carries an executionId to resume").not.toBeNull();
231+ yield* session.call("resume", { executionId: match![1], action: "decline" });
232+
233+ const slugs = JSON.parse((yield* session.call("execute", { code: listClientsCode })).text) as
234+ | ReadonlyArray<string>
235+ | { error: unknown };
236+ expect(
237+ Array.isArray(slugs) && slugs.includes(slug),
238+ "a declined oauth.clients.create never registered the client",
239+ ).toBe(false);
240+ }),
241+);
242+
243+// ---------------------------------------------------------------------------
244+// 3. Watch-it: the whole secret-free path, end to end, against the hosted
245+// Microsoft emulator. The agent hands off; the human types the secret in the
246+// browser; the agent connects.
247+// ---------------------------------------------------------------------------
248+
249+const EMULATOR_BASE = "https://microsoft.emulators.dev";
250+
251+const handoffForBrowserCode = (input: {
252+ readonly integration: string;
253+ readonly slug: string;
254+ readonly clientId: string;
255+ readonly authorizationUrl: string;
256+ readonly tokenUrl: string;
257+}) => `
258+const handoff = await tools.executor.coreTools.oauth.clients.createHandoff({
259+ integration: ${JSON.stringify(input.integration)},
260+ owner: "org",
261+ slug: ${JSON.stringify(input.slug)},
262+ grant: "client_credentials",
263+ clientId: ${JSON.stringify(input.clientId)},
264+ authorizationUrl: ${JSON.stringify(input.authorizationUrl)},
265+ tokenUrl: ${JSON.stringify(input.tokenUrl)},
266+ label: "Microsoft Graph (emulated)",
267+});
268+return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error };
269+`;
270+
271+const listClientSlugsCode = `
272+const result = await tools.executor.coreTools.oauth.clients.list({});
273+return result.ok ? { ok: true, slugs: result.data.clients.map((c) => c.slug) } : { ok: false, error: result.error };
274+`;
275+
276+const startConnectionCode = (input: {
277+ readonly slug: string;
278+ readonly integration: string;
279+ readonly connection: string;
280+ readonly template: string;
281+}) => `
282+const started = await tools.executor.coreTools.oauth.start({
283+ client: ${JSON.stringify(input.slug)},
284+ clientOwner: "org",
285+ owner: "org",
286+ name: ${JSON.stringify(input.connection)},
287+ integration: ${JSON.stringify(input.integration)},
288+ template: ${JSON.stringify(input.template)},
289+});
290+return started.ok ? { ok: true, status: started.data.status } : { ok: false, error: started.error };
291+`;
292+
293+const requireOAuthClientCredential = (credential: IssuedCredential) =>
294+ Effect.gen(function* () {
295+ if (
296+ credential.client_id &&
297+ credential.client_secret &&
298+ credential.authorization_url &&
299+ credential.token_url
300+ ) {
301+ return {
302+ clientId: credential.client_id,
303+ clientSecret: credential.client_secret,
304+ authorizationUrl: credential.authorization_url,
305+ tokenUrl: credential.token_url,
306+ };
307+ }
308+ return yield* Effect.die("Microsoft emulator returned incomplete OAuth client credentials.");
309+ });
310+
311+scenario(
312+ "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313+ { timeout: 240_000 },
314+ Effect.gen(function* () {
315+ const target = yield* Target;
316+ const { client: makeApiClient } = yield* Api;
317+ const mcp = yield* Mcp;
318+ const browser = yield* Browser;
319+ const identity = yield* target.newIdentity();
320+ const session = mcp.session(identity);
321+ const client = yield* makeApiClient(microsoftApi, identity);
322+
323+ const accountClient = yield* makeApiClient(AccountHttpApi, identity);
324+ const me = yield* accountClient.account.me();
325+ const orgSlug = me.organization?.slug;
326+ expect(orgSlug, "the bound organization advertises a URL slug").toBeTruthy();
327+
328+ const integration = unique("msgraph");
329+ const clientSlug = unique("msgraph_app");
330+ const connection = "machine";
331+ const template = MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG;
332+
333+ // The hosted emulator mints a real-shaped client-credentials app and records
334+ // every token exchange — the ledger is shared, so key everything off the
335+ // unique minted clientId.
336+ const emulator: EmulatorClient = yield* Effect.promise(() =>
337+ connectEmulator({ baseUrl: EMULATOR_BASE, service: "microsoft" }),
338+ );
339+ const minted = yield* Effect.promise(() =>
340+ emulator.credentials.mint({ type: "oauth-client-credentials", name: "Executor E2E Graph" }),
341+ );
342+ const oauth = yield* requireOAuthClientCredential(minted);
343+
344+ yield* Effect.ensuring(
345+ Effect.gen(function* () {
346+ // Register the Microsoft Graph integration so the console has an OAuth
347+ // method to register a client against.
348+ yield* client.microsoft.addGraph({
349+ payload: {
350+ presetIds: ["users"],
351+ customScopes: [],
352+ slug: integration,
353+ name: "Microsoft Graph Emulator",
354+ baseUrl: emulator.baseUrl,
355+ specUrl: emulator.openapiUrl,
356+ },
357+ });
358+
359+ // 1. The agent asks for a browser handoff URL — it has the client id and
360+ // endpoints (discovered/known), but never the secret.
361+ const handoff = yield* executeJson(
362+ session,
363+ handoffForBrowserCode({
364+ integration,
365+ slug: clientSlug,
366+ clientId: oauth.clientId,
367+ authorizationUrl: oauth.authorizationUrl,
368+ tokenUrl: oauth.tokenUrl,
369+ }),
370+ );
371+ expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true);
372+ const handoffUrl = String(handoff.url);
373+
374+ const parsed = new URL(handoffUrl);
375+ expect(parsed.origin, `handoff URL (${handoffUrl}) targets this deployment`).toBe(
376+ new URL(target.baseUrl).origin,
377+ );
378+ expect(parsed.pathname).toBe(`/${orgSlug}/integrations/${integration}`);
379+ // The agent's URL carries the client id but NOT the secret.
380+ expect(handoffUrl).toContain(oauth.clientId);
381+ expect(
382+ handoffUrl.includes(oauth.clientSecret),
383+ "the handoff URL never carries the client secret",
384+ ).toBe(false);
385+
386+ // 2. The human opens the URL: the Register-OAuth-app form is open and
387+ // pre-filled from the handoff. They type ONLY the secret.
388+ yield* browser.session(identity, async ({ page, step }) => {
389+ await step("Open the agent's handoff URL", async () => {
390+ await page.goto(handoffUrl, { waitUntil: "networkidle" });
391+ });
392+
393+ await step("The Register-OAuth-app form auto-opens, pre-filled", async () => {
394+ await page
395+ .getByRole("heading", { name: "Register OAuth app" })
396+ .waitFor({ timeout: 20_000 });
397+ // The agent's non-secret fields pre-filled — this is the whole point
398+ // of the handoff: the human verifies, they don't re-type.
399+ await expect
400+ .poll(() => page.locator("#oauth-client-id").inputValue())
401+ .toBe(oauth.clientId);
402+ await expect
403+ .poll(() => page.locator("#grant-client_credentials").isChecked())
404+ .toBe(true);
405+ });
406+
407+ await step("The human types the client secret (only the secret)", async () => {
408+ const secret = page.locator("#oauth-client-secret");
409+ await secret.waitFor({ timeout: 15_000 });
410+ await secret.fill(oauth.clientSecret);
411+ });
412+
413+ await step("Register the app", async () => {
414+ await page.getByRole("button", { name: "Register app" }).click();
415+ // onCreated returns to the Add-connection view — the register form closes.
416+ await page
417+ .getByRole("heading", { name: "Register OAuth app" })
418+ .waitFor({ state: "hidden", timeout: 20_000 });
419+ });
420+ });
421+
422+ // 3. The agent discovers the browser-registered client and completes the
423+ // connection — client credentials need no user consent.
424+ const listed = yield* executeJson(session, listClientSlugsCode);
425+ expect(
426+ (listed.slugs as ReadonlyArray<string> | undefined)?.includes(clientSlug),
427+ `the agent sees the human-registered client: ${JSON.stringify(listed)}`,
428+ ).toBe(true);
429+
430+ const started = yield* executeJson(
431+ session,
432+ startConnectionCode({
433+ slug: clientSlug,
434+ integration,
435+ connection,
436+ template: String(template),
437+ }),
438+ );
439+ expect(started.ok, `oauth.start succeeded: ${JSON.stringify(started)}`).toBe(true);
440+ expect(started.status, "client-credentials OAuth connected without browser consent").toBe(
441+ "connected",
442+ );
443+
444+ // 4. The emulator ledger proves Executor exchanged THIS app's credentials.
445+ const ledger = yield* Effect.promise(() => emulator.ledger.list());
446+ const tokenRequest = ledger.find(
447+ (entry) =>
448+ entry.path === "/oauth2/v2.0/token" &&
449+ JSON.stringify(entry.request.body ?? "").includes(oauth.clientId),
450+ );
451+ expect(
452+ tokenRequest?.response.status,
453+ "the emulator recorded a client-credentials token exchange for this app",
454+ ).toBe(200);
455+ expect(tokenRequest?.request.body).toMatchObject({ grant_type: "client_credentials" });
456+ }),
457+ // Best-effort teardown: selfhost shares one workspace, so remove everything.
458+ Effect.gen(function* () {
459+ yield* client.connections
460+ .remove({
461+ params: {
462+ owner: "org",
463+ integration: IntegrationSlug.make(integration),
464+ name: ConnectionName.make(connection),
465+ },
466+ })
467+ .pipe(Effect.ignore);
468+ yield* client.oauth
469+ .removeClient({
470+ params: { slug: OAuthClientSlug.make(clientSlug) },
471+ payload: { owner: "org" },
472+ })
473+ .pipe(Effect.ignore);
474+ yield* client.microsoft.removeGraph({ params: { slug: integration } }).pipe(Effect.ignore);
475+ }).pipe(Effect.ignore),
476+ );
477+ }),
478+);
e2e/scenarios/policy-tool-approval.test.ts+144−0
@@ -0,0 +1,144 @@
1+// Cross-target: the policy-mutation core tools gate themselves on human
2+// approval via their OWN `requiresApproval` annotation, with no policy in play.
3+//
4+// This is the regression guard for the approval-bypass finding: a prior rewrite
5+// of `coreTools` dropped the `requiresApproval` annotations from the policy
6+// tools, so prompt-injected sandbox code could call
7+// `tools.executor.coreTools.policies.create({ pattern: "*", action: "approve" })`
8+// and silently disable every other approval gate. Here the sandbox calls
9+// `policies.create` directly with NO matching policy present, so the only thing
10+// that can pause the execution is the tool's annotation. If the annotation is
11+// missing the call runs silently and the "Execution paused" assertion fails.
12+//
13+// Runs over MCP in model-managed elicitation mode (no browser): a gated call
14+// returns a paused result carrying an `executionId`; `resume` accepts or
15+// declines it. The created policy is a `block` rule on a unique, non-matching
16+// pattern, so even a leak cannot gate another scenario's tools; it is removed in
17+// an `ensuring` finalizer regardless.
18+import { randomUUID } from "node:crypto";
19+
20+import { expect } from "@effect/vitest";
21+import { Effect } from "effect";
22+import { composePluginApi } from "@executor-js/api/server";
23+
24+import { scenario } from "../src/scenario";
25+import { Api, Mcp, Target } from "../src/services";
26+
27+const coreApi = composePluginApi([] as const);
28+
29+/** Sandbox code that creates a policy through the gated core tool. The pattern
30+ * is unique-per-run and matches no real tool, so the rule is inert. */
31+const createPolicyCode = (pattern: string) => `
32+const result = await tools.executor.coreTools.policies.create({
33+ owner: "user",
34+ pattern: ${JSON.stringify(pattern)},
35+ action: "block",
36+});
37+return JSON.stringify(result);
38+`;
39+
40+scenario(
41+ "Policy tools · policies.create pauses for approval from its own annotation, then runs once approved",
42+ {},
43+ Effect.gen(function* () {
44+ const target = yield* Target;
45+ const apiSurface = yield* Api;
46+ const mcp = yield* Mcp;
47+ const identity = yield* target.newIdentity();
48+ const client = yield* apiSurface.client(coreApi, identity);
49+ const pattern = `policy-guard-approve-${randomUUID().slice(0, 8)}.*`;
50+
51+ // Best-effort removal of any policy left under `pattern` for this identity.
52+ const cleanup = client.policies.list().pipe(
53+ Effect.flatMap((list) =>
54+ Effect.forEach(
55+ list.filter((p) => p.pattern === pattern),
56+ (p) =>
57+ client.policies
58+ .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
59+ .pipe(Effect.ignore),
60+ ),
61+ ),
62+ Effect.ignore,
63+ );
64+
65+ yield* Effect.gen(function* () {
66+ const session = mcp.session(identity);
67+
68+ // Warm the OAuth handshake before the gated call.
69+ const tools = yield* session.listTools();
70+ expect(tools).toContain("execute");
71+
72+ // No policy exists for `policies.create` here — the only thing that can
73+ // pause this call is the tool's `requiresApproval` annotation.
74+ const paused = yield* session.call("execute", { code: createPolicyCode(pattern) });
75+ expect(
76+ paused.text,
77+ "policies.create paused for approval with no policy present (annotation guard)",
78+ ).toContain("Execution paused");
79+ expect(paused.text, "paused result carries the executionId").toContain("executionId:");
80+
81+ // The policy must not exist until the human approves.
82+ const beforeApproval = yield* client.policies.list();
83+ expect(
84+ beforeApproval.some((p) => p.pattern === pattern),
85+ "policy is not written while the approval is still pending",
86+ ).toBe(false);
87+
88+ const resumed = yield* session.approvePaused(paused.text);
89+ expect(resumed.ok, "resumed execution completed without error").toBe(true);
90+
91+ const afterApproval = yield* client.policies.list();
92+ expect(
93+ afterApproval.some((p) => p.pattern === pattern),
94+ "policy is written only after the human approves",
95+ ).toBe(true);
96+ }).pipe(Effect.ensuring(cleanup));
97+ }),
98+);
99+
100+scenario(
101+ "Policy tools · declining the approval blocks policies.create",
102+ {},
103+ Effect.gen(function* () {
104+ const target = yield* Target;
105+ const apiSurface = yield* Api;
106+ const mcp = yield* Mcp;
107+ const identity = yield* target.newIdentity();
108+ const client = yield* apiSurface.client(coreApi, identity);
109+ const pattern = `policy-guard-decline-${randomUUID().slice(0, 8)}.*`;
110+
111+ const cleanup = client.policies.list().pipe(
112+ Effect.flatMap((list) =>
113+ Effect.forEach(
114+ list.filter((p) => p.pattern === pattern),
115+ (p) =>
116+ client.policies
117+ .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
118+ .pipe(Effect.ignore),
119+ ),
120+ ),
121+ Effect.ignore,
122+ );
123+
124+ yield* Effect.gen(function* () {
125+ const session = mcp.session(identity);
126+ yield* session.listTools();
127+
128+ const paused = yield* session.call("execute", { code: createPolicyCode(pattern) });
129+ expect(paused.text, "policies.create paused for approval (annotation guard)").toContain(
130+ "Execution paused",
131+ );
132+
133+ const match = /\bexecutionId:\s*(\S+)/.exec(paused.text);
134+ expect(match, "paused result carries an executionId to resume").not.toBeNull();
135+ yield* session.call("resume", { executionId: match![1], action: "decline" });
136+
137+ const list = yield* client.policies.list();
138+ expect(
139+ list.some((p) => p.pattern === pattern),
140+ "declined policies.create never wrote the policy",
141+ ).toBe(false);
142+ }).pipe(Effect.ensuring(cleanup));
143+ }),
144+);
packages/core/sdk/src/client.ts+13−0
@@ -113,6 +113,19 @@ export interface IntegrationAccountHandoff { · 112 unmodified lines
113113 readonly template?: string;
114114 /** Non-secret connection label to prefill. */
115115 readonly label?: string;
116+ /** Present when the agent handed off a CONFIDENTIAL OAuth-app registration
117+ * (via `oauth.clients.createHandoff`): the accounts UI opens the
118+ * Register-OAuth-app form pre-filled with these NON-secret fields, and the
119+ * human types the client secret directly into the browser. */
120+ readonly oauthClient?: {
121+ /** Preselected client slug; when set the form's slug is fixed. */
122+ readonly slug?: string;
123+ readonly grant?: string;
124+ readonly clientId?: string;
125+ readonly authorizationUrl?: string;
126+ readonly tokenUrl?: string;
127+ readonly resource?: string;
128+ };
116129 }
117130
118131 /** Outcome of applying an edit-sheet section's staged change. `summary` is
packages/core/sdk/src/core-tools.ts+130−3
@@ -231,6 +231,10 @@ const OAuthClientOutput = Schema.Struct({ · 230 unmodified lines
231231 const OAuthClientsListOutput = Schema.Struct({
232232 clients: Schema.Array(OAuthClientOutput),
233233 });
234+// No `clientSecret`: a confidential client's secret must never cross the agent
235+// boundary (it would land in the LLM context window). This tool registers a
236+// PUBLIC client only; a secret-bearing app is registered by the human through
237+// `oauth.clients.createHandoff`, which deep-links them to the web form.
234238 const OAuthCreateClientInput = Schema.Struct({
235239 owner: OwnerSchema,
236240 slug: Schema.String,
@@ -238,9 +242,27 @@ const OAuthCreateClientInput = Schema.Struct({ · 1 unmodified line
238242 tokenUrl: Schema.String,
239243 grant: OAuthGrantSchema,
240244 clientId: Schema.String,
241− clientSecret: Schema.String,
242245 resource: Schema.optional(Schema.NullOr(Schema.String)),
243246 });
247+// Browser-handoff for a CONFIDENTIAL OAuth app: carries only the NON-secret
248+// fields the form pre-fills. The client secret is typed by the human in the web
249+// UI, exactly like a pasted connection credential, so it never reaches the
250+// agent. Mirrors `ConnectionCreateHandoffInput`.
251+const OAuthCreateClientHandoffInput = Schema.Struct({
252+ integration: Schema.String,
253+ owner: Schema.optional(OwnerSchema),
254+ slug: Schema.optional(Schema.String),
255+ grant: Schema.optional(OAuthGrantSchema),
256+ clientId: Schema.optional(Schema.String),
257+ authorizationUrl: Schema.optional(Schema.String),
258+ tokenUrl: Schema.optional(Schema.String),
259+ resource: Schema.optional(Schema.NullOr(Schema.String)),
260+ label: Schema.optional(Schema.String),
261+});
262+const OAuthCreateClientHandoffOutput = Schema.Struct({
263+ url: Schema.String,
264+ instructions: Schema.String,
265+});
244266 const OAuthClientOutputRef = Schema.Struct({
245267 client: Schema.String,
246268 });
@@ -321,6 +343,8 @@ const PolicyUpdateInputStd = schemaToStandard(PolicyUpdateInput); · 74 unmodified lines
321343 const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput);
322344 const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput);
323345 const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput);
346+const OAuthCreateClientHandoffInputStd = schemaToStandard(OAuthCreateClientHandoffInput);
347+const OAuthCreateClientHandoffOutputStd = schemaToStandard(OAuthCreateClientHandoffOutput);
324348 const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef);
325349 const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput);
326350 const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput);
@@ -445,6 +469,31 @@ const connectionCreateHandoffUrl = ( · 118 unmodified lines
445469 return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString();
446470 };
447471
472+const oauthClientCreateHandoffUrl = (
473+ webBaseUrl: string | undefined,
474+ orgSlug: string | undefined,
475+ input: typeof OAuthCreateClientHandoffInput.Type,
476+): string => {
477+ // `oauthClient=1` flips the integration's Add-account flow straight into the
478+ // Register-OAuth-app form; the rest pre-fill its NON-secret fields. The client
479+ // secret is deliberately absent: the human types it in the browser, so it is
480+ // never placed in this URL (nor in the agent's context). Same builder shape as
481+ // `connectionCreateHandoffUrl`.
482+ const search = new URLSearchParams({ addAccount: "1", oauthClient: "1" });
483+ if (input.owner !== undefined) search.set("owner", input.owner);
484+ if (input.slug !== undefined) search.set("clientSlug", input.slug);
485+ if (input.grant !== undefined) search.set("grant", input.grant);
486+ if (input.clientId !== undefined) search.set("clientId", input.clientId);
487+ if (input.authorizationUrl !== undefined) search.set("authorizationUrl", input.authorizationUrl);
488+ if (input.tokenUrl !== undefined) search.set("tokenUrl", input.tokenUrl);
489+ if (input.resource != null && input.resource.length > 0) search.set("resource", input.resource);
490+ if (input.label !== undefined) search.set("label", input.label);
491+ const orgPrefix = orgSlug !== undefined && orgSlug.length > 0 ? `/${orgSlug}` : "";
492+ const path = `${orgPrefix}/integrations/${encodeURIComponent(input.integration)}?${search.toString()}`;
493+ if (webBaseUrl === undefined || webBaseUrl.length === 0) return path;
494+ return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString();
495+};
496+
448497 export interface CoreToolsPluginOptions {
449498 readonly webBaseUrl?: string;
450499 /** The bound org's URL slug, prefixed onto browser-handoff URLs so they open
@@ -526,6 +575,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 75 unmodified lines
526575 'Low-level create or replace for a saved connection from provider item references. For a no-auth integration (public MCP server, public REST API), pass `template: "none"` with no `from`/`inputs` to wire it up directly. For normal API keys/tokens, use `connections.createHandoff` so the user enters the credential in the web UI. OAuth credentials should use `oauth.start`.',
527576 inputSchema: ConnectionCreateInputStd,
528577 outputSchema: ConnectionOutputStd,
578+ // Creating a connection binds a credential reference and roots a new
579+ // tool catalog: every tool that connection produces then becomes
580+ // callable. Even the no-auth (`template: "none"`) path pulls tools
581+ // from an arbitrary endpoint. Prompt-injected code could silently
582+ // wire an attacker-chosen integration or credential, so this is
583+ // approval-gated (the v1 `sources.configure` carried the same guard).
584+ annotations: { requiresApproval: true },
529585 execute: (input: typeof ConnectionCreateInput.Type, { ctx }) =>
530586 Effect.map(
531587 ctx.connections.create(createConnectionInputFromTool(input)),
@@ -553,6 +609,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines
553609 "Remove a saved connection and its produced tools by owner, integration, and connection name.",
554610 inputSchema: ConnectionRefInputStd,
555611 outputSchema: RemovedOutputStd,
612+ // Deleting a connection drops it and every tool it produced, which
613+ // prompt-injected code could use to disrupt an integration or force a
614+ // re-add flow. Approval-gated, matching v1 `sources.remove`.
615+ annotations: { requiresApproval: true },
556616 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>
557617 Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({
558618 removed: true,
@@ -564,6 +624,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 5 unmodified lines
564624 "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.",
565625 inputSchema: ConnectionRefInputStd,
566626 outputSchema: ConnectionsRefreshOutputStd,
627+ // Refresh replaces a connection's persisted tool set; for a mutable
628+ // upstream (an MCP server whose catalog can change) this can swap in
629+ // different tools without confirmation. Approval-gated, matching v1
630+ // `sources.refresh`.
631+ annotations: { requiresApproval: true },
567632 execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>
568633 Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({
569634 tools: tools.map(toolToOutput),
@@ -617,9 +682,20 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 47 unmodified lines
617682 tool({
618683 name: "oauth.clients.create",
619684 description:
620− "Register or replace an owner-scoped OAuth client from explicit client credentials. Use grant `client_credentials` for machine OAuth or `authorization_code` for browser consent flows.",
685+ "Register or replace an owner-scoped OAuth client WITHOUT a client secret: a PUBLIC client (PKCE / authorization_code) or a discovery-prefill placeholder. To register a CONFIDENTIAL client that has a secret, call `oauth.clients.createHandoff` instead so the human enters the secret in the web UI; never pass a client secret through this tool.",
621686 inputSchema: OAuthCreateClientInputStd,
622687 outputSchema: OAuthClientOutputRefStd,
688+ // This persists an OAuth client and REPLACES on slug collision. It
689+ // takes NO client secret: a secret would have to travel through the
690+ // agent's context window, so a confidential app is registered by the
691+ // human via `oauth.clients.createHandoff`. An empty secret registers a
692+ // PUBLIC client. The remaining risk is the write itself: prompt-injected
693+ // code could register a client with an attacker-controlled
694+ // authorizationUrl/tokenUrl, then drive `oauth.start` to mint a
695+ // connection and route the user's tokens to the attacker. The
696+ // highest-value gate here; matches v1 `sources.bindings.set`, which
697+ // guarded credential writes.
698+ annotations: { requiresApproval: true },
623699 execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) =>
624700 Effect.map(
625701 ctx.oauth.createClient({
@@ -629,18 +705,43 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 3 unmodified lines
629705 tokenUrl: input.tokenUrl,
630706 grant: input.grant,
631707 clientId: input.clientId,
632− clientSecret: input.clientSecret,
708+ // No secret crosses the agent boundary; an empty secret registers
709+ // a public client. Confidential clients go through
710+ // `oauth.clients.createHandoff`.
711+ clientSecret: "",
633712 resource: input.resource ?? null,
634713 }),
635714 (client) => ({ client: String(client) }),
636715 ),
637716 }),
717+ tool({
718+ name: "oauth.clients.createHandoff",
719+ description:
720+ "Return a browser URL that opens the Register-OAuth-app form for one integration, pre-filled with the non-secret fields (client id, endpoints, grant). Use this for any CONFIDENTIAL OAuth app: the user types the client secret directly in the web UI instead of sending it through the agent. After they register the app, call `oauth.clients.list` to discover its owner and slug, then `oauth.start`.",
721+ inputSchema: OAuthCreateClientHandoffInputStd,
722+ outputSchema: OAuthCreateClientHandoffOutputStd,
723+ // Pure URL builder: no DB write, no token, no secret. This is the SAFE
724+ // path (it routes the secret to the human in the browser), so it is
725+ // deliberately NOT approval-gated, mirroring `connections.createHandoff`.
726+ execute: (input: typeof OAuthCreateClientHandoffInput.Type) => {
727+ const url = oauthClientCreateHandoffUrl(options.webBaseUrl, options.orgSlug, input);
728+ return Effect.succeed({
729+ url,
730+ instructions:
731+ "Ask the user to open this URL and register the OAuth app in the Executor web UI, entering the client secret there. Do not ask them to paste the client secret into chat. After they finish, call oauth.clients.list to find the registered client (owner + slug), then oauth.start.",
732+ });
733+ },
734+ }),
638735 tool({
639736 name: "oauth.clients.registerDynamic",
640737 description:
641738 "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.",
642739 inputSchema: OAuthRegisterDynamicInputStd,
643740 outputSchema: OAuthClientOutputRefStd,
741+ // Same risk class as `oauth.clients.create`: registers a client at a
742+ // caller-supplied endpoint and persists the minted credentials for
743+ // later `oauth.start` abuse. Approval-gated. See `oauth.clients.create`.
744+ annotations: { requiresApproval: true },
644745 execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) =>
645746 Effect.map(
646747 ctx.oauth.registerDynamicClient({
@@ -668,6 +769,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines
668769 "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.",
669770 inputSchema: OAuthRemoveClientInputStd,
670771 outputSchema: RemovedOutputStd,
772+ // Removing a client breaks token refresh for every connection that
773+ // depends on it (a silent DoS) and can force re-auth through an
774+ // attacker-supplied replacement. Approval-gated, matching v1
775+ // `sources.bindings.remove`.
776+ annotations: { requiresApproval: true },
671777 execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) =>
672778 Effect.map(
673779 ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)),
@@ -696,6 +802,14 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 22 unmodified lines
696802 "Start OAuth through a registered client to mint a connection for an integration. `client_credentials` clients return `connected`; authorization-code clients return an authorization URL and state.",
697803 inputSchema: OAuthStartInputStd,
698804 outputSchema: OAuthStartOutputStd,
805+ // This is the materialization step that turns a registered client
806+ // into a live connection. For `client_credentials` it completes
807+ // synchronously (status `connected`) with no browser step, so a
808+ // prompt-injected call against an attacker-registered client mints a
809+ // credentialed connection with no human in the loop. The
810+ // authorization-code path already returns a URL the user must visit,
811+ // but one gate on the whole tool covers the silent path cleanly.
812+ annotations: { requiresApproval: true },
699813 execute: (input: typeof OAuthStartInput.Type, { ctx }) =>
700814 Effect.map(
701815 ctx.oauth.start({
@@ -752,6 +866,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 50 unmodified lines
752866 "Create a tool policy. `pattern` matches a tool address tail (`integration.connection.tool`, `integration.*`, `*`); `action` is approve/require_approval/block. `owner` is org (workspace guardrail) or user (personal).",
753867 inputSchema: PolicyCreateInputStd,
754868 outputSchema: PolicyOutputStd,
869+ // A policy decides which tools run without confirmation, so creating
870+ // one can silence every other approval gate (e.g. `approve *`). It
871+ // must itself require approval, otherwise prompt-injected code could
872+ // disable approvals by writing its own bypass policy.
873+ annotations: { requiresApproval: true },
755874 execute: (input: typeof PolicyCreateInput.Type, { ctx }) =>
756875 Effect.map(
757876 ctx.core.policies.create({
@@ -773,6 +892,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 15 unmodified lines
773892 description: "Update a tool policy's pattern and/or action by id + owner.",
774893 inputSchema: PolicyUpdateInputStd,
775894 outputSchema: PolicyOutputStd,
895+ // Editing a policy can broaden a pattern or flip an action to
896+ // `approve`, weakening an approval gate just as creation can, so it
897+ // requires approval too. See `policies.create`.
898+ annotations: { requiresApproval: true },
776899 execute: (input: typeof PolicyUpdateInput.Type, { ctx }) =>
777900 Effect.map(
778901 ctx.core.policies.update({
@@ -795,6 +918,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 16 unmodified lines
795918 description: "Remove a tool policy by id + owner.",
796919 inputSchema: PolicyRemoveInputStd,
797920 outputSchema: RemovedOutputStd,
921+ // Removing a policy can drop a `block` or `require_approval`
922+ // guardrail, so deletion is also approval-gated. See
923+ // `policies.create`.
924+ annotations: { requiresApproval: true },
798925 execute: (input: typeof PolicyRemoveInput.Type, { ctx }) =>
799926 Effect.map(
800927 ctx.core.policies.remove({
packages/react/src/components/add-account-modal.tsx+54−4
@@ -1,4 +1,4 @@
1−import { useEffect, useMemo, useState } from "react";
1+import { useEffect, useMemo, useRef, useState } from "react";
22 import { useAtomSet, useAtomValue } from "@effect/atom-react";
33 import * as Exit from "effect/Exit";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -48,7 +48,7 @@ import { · 43 unmodified lines
4848 } from "../plugins/use-effective-oauth-client";
4949 import { cn } from "../lib/utils";
5050 import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage";
51−import { OAuthClientForm } from "./oauth-client-form";
51+import { OAuthClientForm, type OAuthClientFormPrefill } from "./oauth-client-form";
5252 import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog";
5353 import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal";
5454 import { PlacementLine, type AuthMethod } from "../lib/auth-placements";
@@ -734,6 +734,43 @@ export function AddAccountModal(props: { · 679 unmodified lines
734734 setMethodId(initialMethod?.id ?? allMethods[0]!.id);
735735 }, [allMethods, initialState?.template, methodId]);
736736
737+ // Non-secret prefill carried by an `oauth.clients.createHandoff` deep link.
738+ // The agent fills in the endpoints/grant/client id it discovered; the client
739+ // secret is deliberately absent and is typed by the human in the form below.
740+ const oauthClientHandoff = initialState?.oauthClient;
741+ const oauthHandoffPrefill = useMemo<OAuthClientFormPrefill | undefined>(() => {
742+ if (!oauthClientHandoff) return undefined;
743+ const grant =
744+ oauthClientHandoff.grant === "authorization_code" ||
745+ oauthClientHandoff.grant === "client_credentials"
746+ ? oauthClientHandoff.grant
747+ : undefined;
748+ return {
749+ ...(oauthClientHandoff.authorizationUrl
750+ ? { authorizationUrl: oauthClientHandoff.authorizationUrl }
751+ : {}),
752+ ...(oauthClientHandoff.tokenUrl ? { tokenUrl: oauthClientHandoff.tokenUrl } : {}),
753+ ...(oauthClientHandoff.resource ? { resource: oauthClientHandoff.resource } : {}),
754+ ...(grant ? { grant } : {}),
755+ ...(oauthClientHandoff.clientId ? { clientId: oauthClientHandoff.clientId } : {}),
756+ };
757+ }, [oauthClientHandoff]);
758+
759+ // Jump straight into the Register-OAuth-app sub-view when the agent handed off
760+ // an OAuth-app registration. Fire once per handoff key (tracked by ref) so the
761+ // user can cancel back out without it springing open again; retries on later
762+ // renders only while the methods list is still empty.
763+ const oauthHandoffOpenedKey = useRef<string | null>(null);
764+ useEffect(() => {
765+ if (!initialState?.oauthClient) return;
766+ if (oauthHandoffOpenedKey.current === initialState.key) return;
767+ const oauthMethod = allMethods.find((m: AuthMethod) => m.kind === "oauth");
768+ if (!oauthMethod) return;
769+ oauthHandoffOpenedKey.current = initialState.key;
770+ setMethodId(oauthMethod.id);
771+ setRegisteringOAuthClient(true);
772+ }, [initialState, allMethods]);
773+
737774 const isOAuth = method?.kind === "oauth";
738775 const isNoAuth = method?.kind === "none";
739776 // The distinct credential inputs the selected method needs — one per variable
@@ -1206,11 +1243,24 @@ export function AddAccountModal(props: { · 466 unmodified lines
12061243 String(app.slug),
12071244 )}
12081245 prefill={{
1209− authorizationUrl: method.oauth?.authorizationUrl,
1210− tokenUrl: method.oauth?.tokenUrl,
1246+ authorizationUrl:
1247+ oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl,
1248+ tokenUrl: oauthHandoffPrefill?.tokenUrl ?? method.oauth?.tokenUrl,
12111249 scopes: method.oauth?.scopes,
12121250 registrationEndpoint: method.oauth?.registrationEndpoint,
1251+ ...(oauthHandoffPrefill?.grant ? { grant: oauthHandoffPrefill.grant } : {}),
1252+ ...(oauthHandoffPrefill?.clientId
1253+ ? { clientId: oauthHandoffPrefill.clientId }
1254+ : {}),
1255+ ...(oauthHandoffPrefill?.resource != null
1256+ ? { resource: oauthHandoffPrefill.resource }
1257+ : {}),
12131258 }}
1259+ fixedSlug={
1260+ oauthClientHandoff?.slug != null && oauthClientHandoff.slug.length > 0
1261+ ? OAuthClientSlug.make(oauthClientHandoff.slug)
1262+ : undefined
1263+ }
12141264 onCreated={(result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => {
12151265 setPickedApp(String(result.slug));
12161266 setRegisteringOAuthClient(false);
packages/react/src/pages/integration-detail.tsx+21−0
@@ -108,11 +108,32 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 107 unmodified lines
108108 const owner = search.get("owner");
109109 const template = search.get("template");
110110 const label = search.get("label");
111+ // `oauthClient=1` is the OAuth-app registration handoff
112+ // (`oauth.clients.createHandoff`): pull the NON-secret prefill fields. The
113+ // client secret is never in the URL; the human types it into the form.
114+ const oauthClient = ((): IntegrationAccountHandoff["oauthClient"] => {
115+ if (search.get("oauthClient") !== "1") return undefined;
116+ const slug = search.get("clientSlug");
117+ const grant = search.get("grant");
118+ const clientId = search.get("clientId");
119+ const authorizationUrl = search.get("authorizationUrl");
120+ const tokenUrl = search.get("tokenUrl");
121+ const resource = search.get("resource");
122+ return {
123+ ...(slug != null && slug.length > 0 ? { slug } : {}),
124+ ...(grant != null && grant.length > 0 ? { grant } : {}),
125+ ...(clientId != null && clientId.length > 0 ? { clientId } : {}),
126+ ...(authorizationUrl != null && authorizationUrl.length > 0 ? { authorizationUrl } : {}),
127+ ...(tokenUrl != null && tokenUrl.length > 0 ? { tokenUrl } : {}),
128+ ...(resource != null && resource.length > 0 ? { resource } : {}),
129+ };
130+ })();
111131 return {
112132 key: locationSearch,
113133 ...(owner === "org" || owner === "user" ? { owner } : {}),
114134 ...(template != null && template.length > 0 ? { template } : {}),
115135 ...(label != null && label.length > 0 ? { label } : {}),
136+ ...(oauthClient !== undefined ? { oauthClient } : {}),
116137 };
117138 }, [locationSearch]);
118139