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.
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, Target } from "../src/services";
26+
27+const 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. */
31+const createPolicyCode = (pattern: string) => `
32+return await tools.executor.coreTools.policies.create({
33+ owner: "user",
34+ pattern: ${JSON.stringify(pattern)},
35+ action: "block",
36+});
37+`;
38+
39+scenario(
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
99
1010 const ExecuteRequest = Schema.Struct({
1111 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),
1216 });
1317
1418 const 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
3434 capture(
3535 Effect.gen(function* () {
3636 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+ );
3840
3941 if (outcome.status === "completed") {
4042 const formatted = formatExecuteResult(outcome.result);
packages/core/execution/src/engine.ts+27−2
@@ -53,6 +53,10 @@ export type ResumeResponse = { · 52 unmodified lines
5353 readonly content?: Record<string, unknown>;
5454 };
5555
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).
58+const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" });
59+
5660 // ---------------------------------------------------------------------------
5761 // Result formatting
5862 // ---------------------------------------------------------------------------
@@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> · 311 unmodified lines
370374 * Execute code, intercepting the first elicitation as a pause point.
371375 * Use this when the host doesn't support inline elicitation.
372376 * 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.
373383 */
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>;
375388
376389 /**
377390 * 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
455468 * The sandbox is forked as a daemon because paused executions can outlive the
456469 * caller scope that returned the first pause, such as an HTTP request handler.
457470 */
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+ ) {
459475 yield* Effect.annotateCurrentSpan({
460476 "mcp.execute.mode": "pausable",
461477 "mcp.execute.code_length": code.length,
462478 });
463479
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+
464489 // Queue preserves pauses that arrive before the previous approval has
465490 // returned to the caller, which can happen with concurrent tool calls.
466491 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
4141 code: string,
4242 options: { readonly onElicitation: ElicitationHandler },
4343 ) => Promise<ExecuteResult>;
44− readonly executeWithPause: (code: string) => Promise<ExecutionResult>;
44+ readonly executeWithPause: (
45+ code: string,
46+ options?: { readonly autoApprove?: boolean },
47+ ) => Promise<ExecutionResult>;
4548 readonly resume: (
4649 executionId: string,
4750 response: ResumeResponse,
@@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( · 101 unmodified lines
149152 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
150153 }),
151154 ),
152− executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),
155+ executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)),
153156 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
154157 getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),
155158 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
13461346 { timeout: 10000 },
13471347 );
13481348
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+
13491369 // live clock: the sandbox timeout is a real timer, so the test must
13501370 // actually wait for it rather than suspend on the virtual TestClock.
13511371 it.live(
packages/react/src/components/accounts-section.tsx+19−59
@@ -2,11 +2,7 @@ import { useEffect, useMemo, useState } from "react"; · 1 unmodified line
22 import { useAtomValue, useAtomSet } from "@effect/atom-react";
33 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
44 import * as Exit from "effect/Exit";
5−import {
6− IntegrationSlug,
7− type Connection,
8− type Owner,
9−} from "@executor-js/sdk/shared";
5+import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared";
106 import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
117 import { toast } from "sonner";
128
@@ -84,10 +80,7 @@ function AccountRow(props: { · 71 unmodified lines
8480 <CardStackEntryTitle className="flex min-w-0 items-center gap-2">
8581 <span className="truncate">{displayLabel}</span>
8682 {needsReconsent ? (
87− <Badge
88− variant="outline"
89− className="shrink-0 border-border text-muted-foreground"
90− >
83+ <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
9184 Reconnect to grant access
9285 </Badge>
9386 ) : null}
@@ -99,8 +92,7 @@ function AccountRow(props: { · 5 unmodified lines
9992 ) : null}
10093 {needsReconsent ? (
10194 <CardStackEntryDescription className="mt-1 text-xs text-muted-foreground">
102− This connection wasn't granted all the access this integration now
103− needs.
95+ This connection wasn't granted all the access this integration now needs.
10496 </CardStackEntryDescription>
10597 ) : null}
10698 </CardStackEntryContent>
@@ -129,11 +121,7 @@ function AccountRow(props: { · 22 unmodified lines
129121 <DropdownMenuItem className="text-sm" onClick={props.onReconnect}>
130122 Reconnect
131123 </DropdownMenuItem>
132− <DropdownMenuItem
133− variant="destructive"
134− className="text-sm"
135− onClick={props.onRemove}
136− >
124+ <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}>
137125 Remove
138126 </DropdownMenuItem>
139127 </DropdownMenuContent>
@@ -155,9 +143,7 @@ function OwnerAccounts(props: { · 15 unmodified lines
155143 readonly declaredScopes: readonly string[] | undefined;
156144 }) {
157145 const { integration, owner } = props;
158− const connections = useAtomValue(
159− connectionsForIntegrationAtom({ integration, owner }),
160− );
146+ const connections = useAtomValue(connectionsForIntegrationAtom({ integration, owner }));
161147 const doRemove = useAtomSet(removeConnectionOptimistic(owner), {
162148 mode: "promiseExit",
163149 });
@@ -174,9 +160,7 @@ function OwnerAccounts(props: { · 10 unmodified lines
174160 startErrorMessage: "Failed to reconnect",
175161 });
176162
177− const rows: readonly Connection[] = AsyncResult.isSuccess(connections)
178− ? connections.value
179− : [];
163+ const rows: readonly Connection[] = AsyncResult.isSuccess(connections) ? connections.value : [];
180164 if (rows.length === 0) return null;
181165
182166 const handleReconnect = async (connection: Connection) => {
@@ -185,8 +169,7 @@ function OwnerAccounts(props: { · 2 unmodified lines
185169 if (reconnectMode(connection) === "oauth") {
186170 const method = props.methods.find(
187171 (candidate: AuthMethod) =>
188− candidate.kind === "oauth" &&
189− String(candidate.template) === String(connection.template),
172+ candidate.kind === "oauth" && String(candidate.template) === String(connection.template),
190173 );
191174 if (
192175 method?.oauth?.supportsDynamicRegistration === true ||
@@ -293,18 +276,13 @@ function OwnerAccounts(props: { · 100 unmodified lines
293276
294277 return (
295278 <CardStack>
296− {props.showOwnerLabels ? (
297− <CardStackHeader>{ownerLabel(owner)}</CardStackHeader>
298− ) : null}
279+ {props.showOwnerLabels ? <CardStackHeader>{ownerLabel(owner)}</CardStackHeader> : null}
299280 <CardStackContent>
300281 {rows.map((connection: Connection) => (
301282 <AccountRow
302283 key={`${connection.owner}:${connection.integration}:${connection.name}`}
303284 connection={connection}
304− needsReconsent={connectionNeedsReconsent(
305− connection,
306− props.declaredScopes,
307− )}
285+ needsReconsent={connectionNeedsReconsent(connection, props.declaredScopes)}
308286 showOwnerLabel={props.showOwnerLabels}
309287 onEdit={() => props.onEdit(connection)}
310288 onReconnect={() => void handleReconnect(connection)}
@@ -335,14 +313,10 @@ export function AccountsSection(props: { · 24 unmodified lines
335313 removeCustomMethod,
336314 } = props;
337315 const [adding, setAdding] = useState(false);
338− const [editingConnection, setEditingConnection] = useState<Connection | null>(
339− null,
340− );
341− const [reconnectHandoff, setReconnectHandoff] =
342− useState<IntegrationAccountHandoff | null>(null);
316+ const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
317+ const [reconnectHandoff, setReconnectHandoff] = useState<IntegrationAccountHandoff | null>(null);
343318 const ownerDisplay = useOwnerDisplay();
344− const canAddConnection =
345− methods.length > 0 || createCustomMethod !== undefined;
319+ const canAddConnection = methods.length > 0 || createCustomMethod !== undefined;
346320
347321 useEffect(() => {
348322 if (accountHandoff) {
@@ -364,9 +338,7 @@ export function AccountsSection(props: { · 15 unmodified lines
364338
365339 // Read both owners to decide between the grouped view and the empty CTA. The
366340 // grouped sub-components re-read these (effect-atom dedupes) and self-hide.
367− const orgConnections = useAtomValue(
368− connectionsForIntegrationAtom({ integration, owner: "org" }),
369− );
341+ const orgConnections = useAtomValue(connectionsForIntegrationAtom({ integration, owner: "org" }));
370342 const userConnections = useAtomValue(
371343 connectionsForIntegrationAtom({ integration, owner: "user" }),
372344 );
@@ -377,18 +349,12 @@ export function AccountsSection(props: { · 4 unmodified lines
377349 useAtomSet(addConnectionOptimistic("user"));
378350
379351 const totalCount = useMemo(() => {
380− const orgRows = AsyncResult.isSuccess(orgConnections)
381− ? orgConnections.value.length
382− : 0;
383− const userRows = AsyncResult.isSuccess(userConnections)
384− ? userConnections.value.length
385− : 0;
352+ const orgRows = AsyncResult.isSuccess(orgConnections) ? orgConnections.value.length : 0;
353+ const userRows = AsyncResult.isSuccess(userConnections) ? userConnections.value.length : 0;
386354 return orgRows + userRows;
387355 }, [orgConnections, userConnections]);
388356
389− const loading =
390− !AsyncResult.isSuccess(orgConnections) &&
391− !AsyncResult.isSuccess(userConnections);
357+ const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections);
392358
393359 const modalState = reconnectHandoff ?? accountHandoff;
394360 const modal = (
@@ -420,9 +386,7 @@ export function AccountsSection(props: { · 25 unmodified lines
420386 onClick={() => {
421387 trackEvent("connection_add_opened", {
422388 integration_slug: String(integration),
423− has_oauth_method: methods.some(
424− (m: AuthMethod) => m.kind === "oauth",
425− ),
389+ has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"),
426390 has_api_key_method: methods.some(
427391 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",
428392 ),
@@ -442,9 +406,7 @@ export function AccountsSection(props: { · 13 unmodified lines
442406 </div>
443407 ) : totalCount === 0 ? (
444408 <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">
446− No connections yet
447− </p>
409+ <p className="text-sm font-medium text-foreground">No connections yet</p>
448410 <p className="mt-1 text-sm text-muted-foreground">
449411 Add a connection to make this integration's tools available.
450412 </p>
@@ -455,9 +417,7 @@ export function AccountsSection(props: { · 4 unmodified lines
455417 onClick={() => {
456418 trackEvent("connection_add_opened", {
457419 integration_slug: String(integration),
458− has_oauth_method: methods.some(
459− (m: AuthMethod) => m.kind === "oauth",
460− ),
420+ has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"),
461421 has_api_key_method: methods.some(
462422 (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none",
463423 ),
packages/react/src/components/tool-run-panel.tsx+3−1
@@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines
215215
216216 // Read-only invoke: pass the validated args through as JSON. Empty
217217 // `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.
218220 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: [] });
220222 setRunning(false);
221223
222224 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
2828 * connection are exactly the branded types `oauth.start` expects, so the same
2929 * connection (owner/integration/name) is re-minted with a fresh refresh token
3030 * and the widened scope union. Returns null for a non-OAuth connection. */
31−export function oauthReconnectPayload(
32− connection: Connection,
33−): OAuthStartPayload | null {
31+export function oauthReconnectPayload(connection: Connection): OAuthStartPayload | null {
3432 if (connection.oauthClient == null) return null;
3533 return {
3634 client: connection.oauthClient,
@@ -61,8 +59,7 @@ const OAUTH_SCOPE_ALIASES: Readonly<Record<string, string>> = { · 24 unmodified lines
6159 "https://www.googleapis.com/auth/userinfo.profile": "profile",
6260 };
6361
64−const canonicalScope = (scope: string): string =>
65− OAUTH_SCOPE_ALIASES[scope] ?? scope;
62+const canonicalScope = (scope: string): string => OAUTH_SCOPE_ALIASES[scope] ?? scope;
6663
6764 /** Normalize a scope list: trim, canonicalize known provider aliases, drop
6865 * empties, de-dupe (order-preserving). A scope set is compared as a SET —
@@ -94,12 +91,8 @@ export function missingScopes( · 25 unmodified lines
9491
9592 /** The scopes a connection was actually GRANTED, parsed from its space-delimited
9693 * `oauthScope` record. Empty for static creds / when the AS omitted scope. */
97−export function connectionGrantedScopes(
98− connection: Connection,
99−): readonly string[] {
100− return connection.oauthScope
101− ? connection.oauthScope.split(/\s+/).filter(Boolean)
102− : [];
94+export function connectionGrantedScopes(connection: Connection): readonly string[] {
95+ return connection.oauthScope ? connection.oauthScope.split(/\s+/).filter(Boolean) : [];
10396 }
10497
10598 /** Whether an OAuth connection must RECONNECT to grant newly-needed access: the
@@ -113,10 +106,7 @@ export function connectionNeedsReconsent( · 7 unmodified lines
113106 declaredScopes: readonly string[] | undefined,
114107 ): boolean {
115108 if (connection.oauthClient == null) return false;
116− return (
117− missingScopes(declaredScopes, connectionGrantedScopes(connection)).length >
118− 0
119− );
109+ return missingScopes(declaredScopes, connectionGrantedScopes(connection)).length > 0;
120110 }
121111
122112 /** The scopes that count as REQUIRED for a connection to be considered fully