History

Run panel: auto-approve operator-invoked tools (#1183)

* Run panel: auto-approve operator-invoked tools Invoking a tool through the Run/Test panel paused on approval-gated tools and dead-ended on a "This tool requires approval" message, even though the operator clicking Run is itself the approval. Thread an optional autoApprove through the execute path: the execution engine runs the inline accept-all handler instead of intercepting the first elicitation as a pause, so an approval-gated tool runs to completion. The HTTP /executions endpoint takes autoApprove and the Run panel sends it. block policies still fail before any elicitation, so this never bypasses a hard block; the MCP host path is unchanged and still pauses for the model. * Format files flagged by oxfmt These two files were already unformatted on main (identical to origin/main); oxfmt --check flags them repo-wide. Formatting-only, no behavior change.

Rhys Sullivan committed 4 months ago

Commit a150db91 parent 5422ec39 files · +187 −80Browse files

Unified · Split

9 files
  1. e2e/scenarios/run-panel-auto-approve.test.ts +101 −0
  2. packages/core/api/src/executions/api.ts +4 −0
  3. packages/core/api/src/handlers/executions.ts +3 −1
  4. packages/core/execution/src/engine.ts +27 −2
  5. packages/core/execution/src/promise.ts +5 −2
  6. packages/core/execution/src/tool-invoker.test.ts +20 −0
  7. packages/react/src/components/accounts-section.tsx +19 −59
  8. packages/react/src/components/tool-run-panel.tsx +3 −1
  9. packages/react/src/plugins/oauth-reconnect.ts +5 −15
e2e/scenarios/run-panel-auto-approve.test.ts+101−0
@@ -0,0 +1,101 @@
1// Cross-target: the Run/Test panel's backend (`POST /executions`) auto-approves
2// approval-gated tools when the operator invokes them, because clicking Run in
3// the panel IS the human approval.
4//
5// The panel sends `autoApprove: true`. Here we drive the same HTTP endpoint the
6// panel uses and prove both halves of the contract against ONE tool that gates
7// itself: the `policies.create` core tool carries a `requiresApproval`
8// annotation, so with no matching policy in play the annotation is the only
9// thing that can pause the call.
10//
11// - without `autoApprove`: the call pauses (the panel would have dead-ended
12// on "This tool requires approval"), and the policy is not written.
13// - with `autoApprove`: the call runs to completion and the policy is written.
14//
15// The created policy is a `block` rule on a unique, non-matching pattern, so a
16// leak cannot gate another scenario's tools; it is removed in an `ensuring`
17// finalizer regardless.
18import { randomUUID } from "node:crypto";
19
20import { expect } from "@effect/vitest";
21import { Effect } from "effect";
22import { composePluginApi } from "@executor-js/api/server";
23
24import { scenario } from "../src/scenario";
25import { Api, Target } from "../src/services";
26
27const coreApi = composePluginApi([] as const);
28
29/** Sandbox code that creates a policy through the approval-gated core tool. The
30 * pattern is unique-per-run and matches no real tool, so the rule is inert. */
31const createPolicyCode = (pattern: string) => `
32return await tools.executor.coreTools.policies.create({
33 owner: "user",
34 pattern: ${JSON.stringify(pattern)},
35 action: "block",
36});
37`;
38
39scenario(
40 "Run panel · autoApprove runs an approval-gated tool that otherwise pauses",
41 {},
42 Effect.gen(function* () {
43 const target = yield* Target;
44 const apiSurface = yield* Api;
45 const identity = yield* target.newIdentity();
46 const client = yield* apiSurface.client(coreApi, identity);
47 const pattern = `run-auto-approve-${randomUUID().slice(0, 8)}.*`;
48 const code = createPolicyCode(pattern);
49
50 const cleanup = client.policies.list().pipe(
51 Effect.flatMap((list) =>
52 Effect.forEach(
53 list.filter((p) => p.pattern === pattern),
54 (p) =>
55 client.policies
56 .remove({ params: { policyId: p.id }, payload: { owner: "user" } })
57 .pipe(Effect.ignore),
58 ),
59 ),
60 Effect.ignore,
61 );
62
63 yield* Effect.gen(function* () {
64 // Baseline: without autoApprove the gated tool pauses (the panel's old
65 // dead-end), and the side effect must not have happened.
66 const gated = yield* client.executions.execute({ payload: { code } });
67 expect(gated.status, "a gated tool pauses without autoApprove").toBe("paused");
68
69 const beforeApproval = yield* client.policies.list();
70 expect(
71 beforeApproval.some((p) => p.pattern === pattern),
72 "the policy is not written while the call is paused for approval",
73 ).toBe(false);
74
75 // Release the paused fiber so it does not linger waiting on a response.
76 if (gated.status === "paused") {
77 const executionId = (gated.structured as { readonly executionId?: string }).executionId;
78 if (executionId) {
79 yield* client.executions
80 .resume({ params: { executionId }, payload: { action: "cancel" } })
81 .pipe(Effect.ignore);
82 }
83 }
84
85 // With autoApprove the operator IS the approver: the same call runs to
86 // completion and the side effect lands.
87 const approved = yield* client.executions.execute({
88 payload: { code, autoApprove: true },
89 });
90 expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed");
91 if (approved.status !== "completed") return; // narrowing only
92 expect(approved.isError, "the auto-approved run is not an error").toBe(false);
93
94 const afterApproval = yield* client.policies.list();
95 expect(
96 afterApproval.some((p) => p.pattern === pattern),
97 "the policy is written once autoApprove runs the gated tool",
98 ).toBe(true);
99 }).pipe(Effect.ensuring(cleanup));
100 }),
101);
packages/core/api/src/executions/api.ts+4−0
@@ -9,6 +9,10 @@ import { InternalError } from "@executor-js/sdk/shared"; · 8 unmodified lines
9 9
10const ExecuteRequest = Schema.Struct({10const ExecuteRequest = Schema.Struct({
11 code: Schema.String,11 code: Schema.String,
12 // When true the caller is the human approver: approval-gated tools run to
13 // completion instead of pausing. Set by the operator-facing Run/Test panel,
14 // where clicking Run is itself the approval. `block` policies still apply.
15 autoApprove: Schema.optional(Schema.Boolean),
12});16});
13 17
14const CompletedResult = Schema.Struct({18const CompletedResult = Schema.Struct({
packages/core/api/src/handlers/executions.ts+3−1
@@ -34,7 +34,9 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 33 unmodified lines
34 capture(34 capture(
35 Effect.gen(function* () {35 Effect.gen(function* () {
36 const engine = yield* ExecutionEngineService;36 const engine = yield* ExecutionEngineService;
37 const outcome = yield* captureEngineError(engine.executeWithPause(payload.code));37 const outcome = yield* captureEngineError(
38 engine.executeWithPause(payload.code, { autoApprove: payload.autoApprove }),
39 );
38 40
39 if (outcome.status === "completed") {41 if (outcome.status === "completed") {
40 const formatted = formatExecuteResult(outcome.result);42 const formatted = formatExecuteResult(outcome.result);
packages/core/execution/src/engine.ts+27−2
@@ -53,6 +53,10 @@ export type ResumeResponse = { · 52 unmodified lines
53 readonly content?: Record<string, unknown>;53 readonly content?: Record<string, unknown>;
54};54};
55 55
56// Auto-accept every elicitation. Used by the `autoApprove` path where the
57// caller is itself the human approver (the operator-facing Run/Test panel).
58const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" });
59
56// ---------------------------------------------------------------------------60// ---------------------------------------------------------------------------
57// Result formatting61// Result formatting
58// ---------------------------------------------------------------------------62// ---------------------------------------------------------------------------
@@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> · 311 unmodified lines
370 * Execute code, intercepting the first elicitation as a pause point.374 * Execute code, intercepting the first elicitation as a pause point.
371 * Use this when the host doesn't support inline elicitation.375 * Use this when the host doesn't support inline elicitation.
372 * Returns either a completed result or a paused execution that can be resumed.376 * Returns either a completed result or a paused execution that can be resumed.
377 *
378 * `options.autoApprove` treats the caller as the human in the loop: every
379 * elicitation is accepted inline, so an approval-gated tool runs to
380 * completion instead of pausing. The operator-facing Run/Test panel sets
381 * this because clicking Run IS the approval. `block` policies still fail
382 * before any elicitation, so this never bypasses a hard block.
373 */383 */
374 readonly executeWithPause: (code: string) => Effect.Effect<ExecutionResult, E>;384 readonly executeWithPause: (
385 code: string,
386 options?: { readonly autoApprove?: boolean },
387 ) => Effect.Effect<ExecutionResult, E>;
375 388
376 /**389 /**
377 * Resume a paused execution. Returns a completed result, a new pause, or390 * Resume a paused execution. Returns a completed result, a new pause, or
@@ -455,12 +468,24 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 77 unmodified lines
455 * The sandbox is forked as a daemon because paused executions can outlive the468 * The sandbox is forked as a daemon because paused executions can outlive the
456 * caller scope that returned the first pause, such as an HTTP request handler.469 * caller scope that returned the first pause, such as an HTTP request handler.
457 */470 */
458 const startPausableExecution = Effect.fn("mcp.execute")(function* (code: string) {471 const startPausableExecution = Effect.fn("mcp.execute")(function* (
472 code: string,
473 options?: { readonly autoApprove?: boolean },
474 ) {
459 yield* Effect.annotateCurrentSpan({475 yield* Effect.annotateCurrentSpan({
460 "mcp.execute.mode": "pausable",476 "mcp.execute.mode": "pausable",
461 "mcp.execute.code_length": code.length,477 "mcp.execute.code_length": code.length,
462 });478 });
463 479
480 // Operator-approved invoke: run through the inline path with an accept-all
481 // handler so an approval gate resolves itself instead of pausing. Never
482 // pauses, so the caller always gets a completed result.
483 if (options?.autoApprove) {
484 yield* Effect.annotateCurrentSpan({ "mcp.execute.auto_approve": true });
485 const result = yield* runInlineExecution(code, { onElicitation: acceptAllHandler });
486 return { status: "completed", result } satisfies ExecutionResult;
487 }
488
464 // Queue preserves pauses that arrive before the previous approval has489 // Queue preserves pauses that arrive before the previous approval has
465 // returned to the caller, which can happen with concurrent tool calls.490 // returned to the caller, which can happen with concurrent tool calls.
466 const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>();491 const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>();
packages/core/execution/src/promise.ts+5−2
@@ -41,7 +41,10 @@ export type ExecutionEngine = { · 40 unmodified lines
41 code: string,41 code: string,
42 options: { readonly onElicitation: ElicitationHandler },42 options: { readonly onElicitation: ElicitationHandler },
43 ) => Promise<ExecuteResult>;43 ) => Promise<ExecuteResult>;
44 readonly executeWithPause: (code: string) => Promise<ExecutionResult>;44 readonly executeWithPause: (
45 code: string,
46 options?: { readonly autoApprove?: boolean },
47 ) => Promise<ExecutionResult>;
45 readonly resume: (48 readonly resume: (
46 executionId: string,49 executionId: string,
47 response: ResumeResponse,50 response: ResumeResponse,
@@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( · 101 unmodified lines
149 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),152 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
150 }),153 }),
151 ),154 ),
152 executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),155 executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)),
153 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),156 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
154 getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),157 getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),
155 getDescription: () => Effect.runPromise(engine.getDescription),158 getDescription: () => Effect.runPromise(engine.getDescription),
packages/core/execution/src/tool-invoker.test.ts+20−0
@@ -1346,6 +1346,26 @@ describe("pause/resume with multiple elicitations", () => { · 1345 unmodified lines
1346 { timeout: 10000 },1346 { timeout: 10000 },
1347 );1347 );
1348 1348
1349 it.effect(
1350 "autoApprove runs an eliciting tool to completion instead of pausing",
1351 () =>
1352 Effect.gen(function* () {
1353 const executor = yield* makeElicitingExecutor();
1354 const engine = createExecutionEngine({ executor, codeExecutor });
1355 const code = "return await tools.api.org.main.singleApproval({});";
1356
1357 // Same tool that pauses without autoApprove (see the tests above) runs
1358 // straight through when the caller is the approver: no pause, no
1359 // executionId to resume, just the side effect's result.
1360 const outcome = yield* engine.executeWithPause(code, { autoApprove: true });
1361 expect(outcome.status, "autoApprove never pauses").toBe("completed");
1362 if (outcome.status !== "completed") return;
1363 expect(outcome.result.error).toBeUndefined();
1364 expect(outcome.result.result).toMatchObject({ ok: true });
1365 }),
1366 { timeout: 10000 },
1367 );
1368
1349 // live clock: the sandbox timeout is a real timer, so the test must1369 // live clock: the sandbox timeout is a real timer, so the test must
1350 // actually wait for it rather than suspend on the virtual TestClock.1370 // actually wait for it rather than suspend on the virtual TestClock.
1351 it.live(1371 it.live(
packages/react/src/components/accounts-section.tsx+19−59
@@ -2,11 +2,7 @@ import { useEffect, useMemo, useState } from "react"; · 1 unmodified line
2import { useAtomValue, useAtomSet } from "@effect/atom-react";2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import * as Exit from "effect/Exit";4import * as Exit from "effect/Exit";
5import {5import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";
6 IntegrationSlug,
7 type Connection,
8 type Owner,
9} from "@executor-js/sdk/shared";
10import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";6import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
11import { toast } from "sonner";7import { toast } from "sonner";
12 8
@@ -84,10 +80,7 @@ function AccountRow(props: { · 71 unmodified lines
84 <CardStackEntryTitle className="flex min-w-0 items-center gap-2">80 <CardStackEntryTitle className="flex min-w-0 items-center gap-2">
85 <span className="truncate">{displayLabel}</span>81 <span className="truncate">{displayLabel}</span>
86 {needsReconsent ? (82 {needsReconsent ? (
87 <Badge83 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
88 variant="outline"
89 className="shrink-0 border-border text-muted-foreground"
90 >
91 Reconnect to grant access84 Reconnect to grant access
92 </Badge>85 </Badge>
93 ) : null}86 ) : null}
@@ -99,8 +92,7 @@ function AccountRow(props: { · 5 unmodified lines
99 ) : null}92 ) : null}
100 {needsReconsent ? (93 {needsReconsent ? (
101 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">94 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
102 This connection wasn't granted all the access this integration now95 This connection wasn't granted all the access this integration now needs.
103 needs.
104 </CardStackEntryDescription>96 </CardStackEntryDescription>
105 ) : null}97 ) : null}
106 </CardStackEntryContent>98 </CardStackEntryContent>
@@ -129,11 +121,7 @@ function AccountRow(props: { · 22 unmodified lines
129 <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>121 <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>
130 Reconnect122 Reconnect
131 </DropdownMenuItem>123 </DropdownMenuItem>
132 <DropdownMenuItem124 <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}>
133 variant="destructive"
134 className="text-sm"
135 onClick={props.onRemove}
136 >
137 Remove125 Remove
138 </DropdownMenuItem>126 </DropdownMenuItem>
139 </DropdownMenuContent>127 </DropdownMenuContent>
@@ -155,9 +143,7 @@ function OwnerAccounts(props: { · 15 unmodified lines
155 readonly declaredScopes: readonly string[] | undefined;143 readonly declaredScopes: readonly string[] | undefined;
156}) {144}) {
157 const { integration, owner } = props;145 const { integration, owner } = props;
158 const connections = useAtomValue(146 const connections = useAtomValue(connectionsForIntegrationAtom({ integration, owner }));
159 connectionsForIntegrationAtom({ integration, owner }),
160 );
161 const doRemove = useAtomSet(removeConnectionOptimistic(owner), {147 const doRemove = useAtomSet(removeConnectionOptimistic(owner), {
162 mode: "promiseExit",148 mode: "promiseExit",
163 });149 });
@@ -174,9 +160,7 @@ function OwnerAccounts(props: { · 10 unmodified lines
174 startErrorMessage: "Failed to reconnect",160 startErrorMessage: "Failed to reconnect",
175 });161 });
176 162
177 const rows: readonly Connection[] = AsyncResult.isSuccess(connections)163 const rows: readonly Connection[] = AsyncResult.isSuccess(connections) ? connections.value : [];
178 ? connections.value
179 : [];
180 if (rows.length === 0) return null;164 if (rows.length === 0) return null;
181 165
182 const handleReconnect = async (connection: Connection) => {166 const handleReconnect = async (connection: Connection) => {
@@ -185,8 +169,7 @@ function OwnerAccounts(props: { · 2 unmodified lines
185 if (reconnectMode(connection) === "oauth") {169 if (reconnectMode(connection) === "oauth") {
186 const method = props.methods.find(170 const method = props.methods.find(
187 (candidate: AuthMethod) =>171 (candidate: AuthMethod) =>
188 candidate.kind === "oauth" &&172 candidate.kind === "oauth" && String(candidate.template) === String(connection.template),
189 String(candidate.template) === String(connection.template),
190 );173 );
191 if (174 if (
192 method?.oauth?.supportsDynamicRegistration === true ||175 method?.oauth?.supportsDynamicRegistration === true ||
@@ -293,18 +276,13 @@ function OwnerAccounts(props: { · 100 unmodified lines
293 276
294 return (277 return (
295 <CardStack>278 <CardStack>
296 {props.showOwnerLabels ? (279 {props.showOwnerLabels ? <CardStackHeader>{ownerLabel(owner)}</CardStackHeader> : null}
297 <CardStackHeader>{ownerLabel(owner)}</CardStackHeader>
298 ) : null}
299 <CardStackContent>280 <CardStackContent>
300 {rows.map((connection: Connection) => (281 {rows.map((connection: Connection) => (
301 <AccountRow282 <AccountRow
302 key={`${connection.owner}:${connection.integration}:${connection.name}`}283 key={`${connection.owner}:${connection.integration}:${connection.name}`}
303 connection={connection}284 connection={connection}
304 needsReconsent={connectionNeedsReconsent(285 needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)}
305 connection,
306 props.declaredScopes,
307 )}
308 showOwnerLabel={props.showOwnerLabels}286 showOwnerLabel={props.showOwnerLabels}
309 onEdit={() => props.onEdit(connection)}287 onEdit={() => props.onEdit(connection)}
310 onReconnect={() => void handleReconnect(connection)}288 onReconnect={() => void handleReconnect(connection)}
@@ -335,14 +313,10 @@ export function AccountsSection(props: { · 24 unmodified lines
335 removeCustomMethod,313 removeCustomMethod,
336 } = props;314 } = props;
337 const [adding, setAdding] = useState(false);315 const [adding, setAdding] = useState(false);
338 const [editingConnection, setEditingConnection] = useState<Connection | null>(316 const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
339 null,317 const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null);
340 );
341 const [reconnectHandoff, setReconnectHandoff] =
342 useState<IntegrationAccountHandoff | null>(null);
343 const ownerDisplay = useOwnerDisplay();318 const ownerDisplay = useOwnerDisplay();
344 const canAddConnection =319 const canAddConnection = methods.length > 0 || createCustomMethod !== undefined;
345 methods.length > 0 || createCustomMethod !== undefined;
346 320
347 useEffect(() => {321 useEffect(() => {
348 if (accountHandoff) {322 if (accountHandoff) {
@@ -364,9 +338,7 @@ export function AccountsSection(props: { · 15 unmodified lines
364 338
365 // Read both owners to decide between the grouped view and the empty CTA. The339 // Read both owners to decide between the grouped view and the empty CTA. The
366 // grouped sub-components re-read these (effect-atom dedupes) and self-hide.340 // grouped sub-components re-read these (effect-atom dedupes) and self-hide.
367 const orgConnections = useAtomValue(341 const orgConnections = useAtomValue(connectionsForIntegrationAtom({ integration, owner: "org" }));
368 connectionsForIntegrationAtom({ integration, owner: "org" }),
369 );
370 const userConnections = useAtomValue(342 const userConnections = useAtomValue(
371 connectionsForIntegrationAtom({ integration, owner: "user" }),343 connectionsForIntegrationAtom({ integration, owner: "user" }),
372 );344 );
@@ -377,18 +349,12 @@ export function AccountsSection(props: { · 4 unmodified lines
377 useAtomSet(addConnectionOptimistic("user"));349 useAtomSet(addConnectionOptimistic("user"));
378 350
379 const totalCount = useMemo(() => {351 const totalCount = useMemo(() => {
380 const orgRows = AsyncResult.isSuccess(orgConnections)352 const orgRows = AsyncResult.isSuccess(orgConnections) ? orgConnections.value.length : 0;
381 ? orgConnections.value.length353 const userRows = AsyncResult.isSuccess(userConnections) ? userConnections.value.length : 0;
382 : 0;
383 const userRows = AsyncResult.isSuccess(userConnections)
384 ? userConnections.value.length
385 : 0;
386 return orgRows + userRows;354 return orgRows + userRows;
387 }, [orgConnections, userConnections]);355 }, [orgConnections, userConnections]);
388 356
389 const loading =357 const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections);
390 !AsyncResult.isSuccess(orgConnections) &&
391 !AsyncResult.isSuccess(userConnections);
392 358
393 const modalState = reconnectHandoff ?? accountHandoff;359 const modalState = reconnectHandoff ?? accountHandoff;
394 const modal = (360 const modal = (
@@ -420,9 +386,7 @@ export function AccountsSection(props: { · 25 unmodified lines
420 onClick={() => {386 onClick={() => {
421 trackEvent("connection_add_opened", {387 trackEvent("connection_add_opened", {
422 integration_slug: String(integration),388 integration_slug: String(integration),
423 has_oauth_method: methods.some(389 has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"),
424 (m: AuthMethod) => m.kind === "oauth",
425 ),
426 has_api_key_method: methods.some(390 has_api_key_method: methods.some(
427 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",391 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",
428 ),392 ),
@@ -442,9 +406,7 @@ export function AccountsSection(props: { · 13 unmodified lines
442 </div>406 </div>
443 ) : totalCount === 0 ? (407 ) : totalCount === 0 ? (
444 <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">408 <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">
445 <p className="text-sm font-medium text-foreground">409 <p className="text-sm font-medium text-foreground">No connections yet</p>
446 No connections yet
447 </p>
448 <p className="mt-1 text-sm text-muted-foreground">410 <p className="mt-1 text-sm text-muted-foreground">
449 Add a connection to make this integration's tools available.411 Add a connection to make this integration's tools available.
450 </p>412 </p>
@@ -455,9 +417,7 @@ export function AccountsSection(props: { · 4 unmodified lines
455 onClick={() => {417 onClick={() => {
456 trackEvent("connection_add_opened", {418 trackEvent("connection_add_opened", {
457 integration_slug: String(integration),419 integration_slug: String(integration),
458 has_oauth_method: methods.some(420 has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"),
459 (m: AuthMethod) => m.kind === "oauth",
460 ),
461 has_api_key_method: methods.some(421 has_api_key_method: methods.some(
462 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",422 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",
463 ),423 ),
packages/react/src/components/tool-run-panel.tsx+3−1
@@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines
215 215
216 // Read-only invoke: pass the validated args through as JSON. Empty216 // Read-only invoke: pass the validated args through as JSON. Empty
217 // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`.217 // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`.
218 // `autoApprove` because the operator clicked Run: that IS the approval, so
219 // an approval-gated tool should run here instead of dead-ending on a pause.
218 const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`;220 const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`;
219 const exit = await doExecute({ payload: { code }, reactivityKeys: [] });221 const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] });
220 setRunning(false);222 setRunning(false);
221 223
222 if (Exit.isFailure(exit)) {224 if (Exit.isFailure(exit)) {
packages/react/src/plugins/oauth-reconnect.ts+5−15
@@ -28,9 +28,7 @@ export function reconnectMode(connection: Connection): ReconnectMode { · 27 unmodified lines
28 * connection are exactly the branded types `oauth.start` expects, so the same28 * connection are exactly the branded types `oauth.start` expects, so the same
29 * connection (owner/integration/name) is re-minted with a fresh refresh token29 * connection (owner/integration/name) is re-minted with a fresh refresh token
30 * and the widened scope union. Returns null for a non-OAuth connection. */30 * and the widened scope union. Returns null for a non-OAuth connection. */
31export function oauthReconnectPayload(31export function oauthReconnectPayload(connection: Connection): OAuthStartPayload | null {
32 connection: Connection,
33): OAuthStartPayload | null {
34 if (connection.oauthClient == null) return null;32 if (connection.oauthClient == null) return null;
35 return {33 return {
36 client: connection.oauthClient,34 client: connection.oauthClient,
@@ -61,8 +59,7 @@ const OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = { · 24 unmodified lines
61 "https://www.googleapis.com/auth/userinfo.profile": "profile",59 "https://www.googleapis.com/auth/userinfo.profile": "profile",
62};60};
63 61
64const canonicalScope = (scope: string): string =>62const canonicalScope = (scope: string): string => OAUTH_SCOPE_ALIASES[scope] ?? scope;
65 OAUTH_SCOPE_ALIASES[scope] ?? scope;
66 63
67/** Normalize a scope list: trim, canonicalize known provider aliases, drop64/** Normalize a scope list: trim, canonicalize known provider aliases, drop
68 * empties, de-dupe (order-preserving). A scope set is compared as a SET —65 * empties, de-dupe (order-preserving). A scope set is compared as a SET —
@@ -94,12 +91,8 @@ export function missingScopes( · 25 unmodified lines
94 91
95/** The scopes a connection was actually GRANTED, parsed from its space-delimited92/** The scopes a connection was actually GRANTED, parsed from its space-delimited
96 * `oauthScope` record. Empty for static creds / when the AS omitted scope. */93 * `oauthScope` record. Empty for static creds / when the AS omitted scope. */
97export function connectionGrantedScopes(94export function connectionGrantedScopes(connection: Connection): readonly string[] {
98 connection: Connection,95 return connection.oauthScope ? connection.oauthScope.split(/\s+/).filter(Boolean) : [];
99): readonly string[] {
100 return connection.oauthScope
101 ? connection.oauthScope.split(/\s+/).filter(Boolean)
102 : [];
103}96}
104 97
105/** Whether an OAuth connection must RECONNECT to grant newly-needed access: the98/** Whether an OAuth connection must RECONNECT to grant newly-needed access: the
@@ -113,10 +106,7 @@ export function connectionNeedsReconsent( · 7 unmodified lines
113 declaredScopes: readonly string[] | undefined,106 declaredScopes: readonly string[] | undefined,
114): boolean {107): boolean {
115 if (connection.oauthClient == null) return false;108 if (connection.oauthClient == null) return false;
116 return (109 return missingScopes(declaredScopes, connectionGrantedScopes(connection)).length > 0;
117 missingScopes(declaredScopes, connectionGrantedScopes(connection)).length >
118 0
119 );
120}110}
121 111
122/** The scopes that count as REQUIRED for a connection to be considered fully112/** The scopes that count as REQUIRED for a connection to be considered fully