* 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.
Unified · Split
| @@ -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 | ); | ||
| @@ -9,6 +9,10 @@ import { InternalError } from "@executor-js/sdk/shared"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | const ExecuteRequest = Schema.Struct({ | 10 | const 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 | ||
| 14 | const CompletedResult = Schema.Struct({ | 18 | const CompletedResult = Schema.Struct({ |
| @@ -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); |
| @@ -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). | ||
| 58 | const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" }); | ||
| 59 | |||
| 56 | // --------------------------------------------------------------------------- | 60 | // --------------------------------------------------------------------------- |
| 57 | // Result formatting | 61 | // 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, or | 390 | * 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 the | 468 | * 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 has | 489 | // 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>>(); |
| @@ -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), |
| @@ -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 must | 1369 | // 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( |
| @@ -2,11 +2,7 @@ import { useEffect, useMemo, useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 4 | import * as Exit from "effect/Exit"; |
| 5 | import { | 5 | import { IntegrationSlug, type Connection, type Owner } from "@executor-js/sdk/shared"; |
| 6 | IntegrationSlug, | ||
| 7 | type Connection, | ||
| 8 | type Owner, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 10 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | 6 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; |
| 11 | import { toast } from "sonner"; | 7 | import { 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 | <Badge | 83 | <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 access | 84 | 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 now | 95 | 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 | Reconnect | 122 | Reconnect |
| 131 | </DropdownMenuItem> | 123 | </DropdownMenuItem> |
| 132 | <DropdownMenuItem | 124 | <DropdownMenuItem variant="destructive" className="text-sm" onClick={props.onRemove}> |
| 133 | variant="destructive" | ||
| 134 | className="text-sm" | ||
| 135 | onClick={props.onRemove} | ||
| 136 | > | ||
| 137 | Remove | 125 | 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 | <AccountRow | 282 | <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. The | 339 | // 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.length | 353 | 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 | ), |
| @@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines | |||
| 215 | 215 | ||
| 216 | // Read-only invoke: pass the validated args through as JSON. Empty | 216 | // 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)) { |
| @@ -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 same | 28 | * connection are exactly the branded types `oauth.start` expects, so the same |
| 29 | * connection (owner/integration/name) is re-minted with a fresh refresh token | 29 | * 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. */ |
| 31 | export function oauthReconnectPayload( | 31 | export 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 | ||
| 64 | const canonicalScope = (scope: string): string => | 62 | const 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, drop | 64 | /** 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-delimited | 92 | /** 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. */ |
| 97 | export function connectionGrantedScopes( | 94 | export 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: the | 98 | /** 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 fully | 112 | /** The scopes that count as REQUIRED for a connection to be considered fully |