Route confidential OAuth-app secrets through a browser handoff instead of the agent context, and approval-gate connection/oauth/policy writes.
Unified · Split
| @@ -89,13 +89,15 @@ const sent = await t({ · 88 unmodified lines | |||
| 89 | return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error }; | 89 | return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error }; |
| 90 | `; | 90 | `; |
| 91 | 91 | ||
| 92 | /** Run `execute`, auto-approving a paused execution (policy elicitation) once, | 92 | /** Run `execute`, auto-approving any paused executions (approval-gated tools |
| 93 | * and parse the sandbox's JSON return value. */ | 93 | * pause once per gated call) and parse the sandbox's JSON return value. */ |
| 94 | const executeJson = (session: McpSession, code: string) => | 94 | const executeJson = (session: McpSession, code: string) => |
| 95 | Effect.gen(function* () { | 95 | Effect.gen(function* () { |
| 96 | let result = yield* session.call("execute", { code }); | 96 | 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) { | ||
| 98 | result = yield* session.approvePaused(result.text); | 99 | result = yield* session.approvePaused(result.text); |
| 100 | guard += 1; | ||
| 99 | } | 101 | } |
| 100 | expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); | 102 | expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); |
| 101 | return JSON.parse(result.text) as Record<string, unknown>; | 103 | return JSON.parse(result.text) as Record<string, unknown>; |
| @@ -148,10 +150,12 @@ scenario( · 46 unmodified lines | |||
| 148 | orgSlug: orgSlug!, | 150 | orgSlug: orgSlug!, |
| 149 | }).pipe( | 151 | }).pipe( |
| 150 | // Best-effort cleanup even on failure: drop the created connection(s) | 152 | // 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. | ||
| 152 | Effect.ensuring( | 156 | Effect.ensuring( |
| 153 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 154 | yield* session.call("execute", { code: removeConnectionsCode(integration) }); | 158 | yield* executeJson(session, removeConnectionsCode(integration)); |
| 155 | yield* client.openapi.removeSpec({ params: { slug: integration } }); | 159 | yield* client.openapi.removeSpec({ params: { slug: integration } }); |
| 156 | }).pipe(Effect.ignore), | 160 | }).pipe(Effect.ignore), |
| 157 | ), | 161 | ), |
| @@ -192,9 +192,12 @@ scenario( · 191 unmodified lines | |||
| 192 | }).pipe( | 192 | }).pipe( |
| 193 | // Selfhost shares one workspace identity — leaked connections fail other | 193 | // Selfhost shares one workspace identity — leaked connections fail other |
| 194 | // scenarios' zero-state assertions, so drop everything this run made. | 194 | // 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. | ||
| 195 | Effect.ensuring( | 198 | Effect.ensuring( |
| 196 | Effect.gen(function* () { | 199 | Effect.gen(function* () { |
| 197 | yield* session.call("execute", { code: removeConnectionsCode(integration) }); | 200 | yield* executeJson(session, removeConnectionsCode(integration)); |
| 198 | yield* client.openapi.removeSpec({ params: { slug: integration } }); | 201 | yield* client.openapi.removeSpec({ params: { slug: integration } }); |
| 199 | }).pipe(Effect.ignore), | 202 | }).pipe(Effect.ignore), |
| 200 | ), | 203 | ), |
| @@ -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 | ); | ||
| @@ -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 | ); | ||
| @@ -113,6 +113,19 @@ export interface IntegrationAccountHandoff { · 112 unmodified lines | |||
| 113 | readonly template?: string; | 113 | readonly template?: string; |
| 114 | /** Non-secret connection label to prefill. */ | 114 | /** Non-secret connection label to prefill. */ |
| 115 | readonly label?: string; | 115 | 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 | }; | ||
| 116 | } | 129 | } |
| 117 | 130 | ||
| 118 | /** Outcome of applying an edit-sheet section's staged change. `summary` is | 131 | /** Outcome of applying an edit-sheet section's staged change. `summary` is |
| @@ -231,6 +231,10 @@ const OAuthClientOutput = Schema.Struct({ · 230 unmodified lines | |||
| 231 | const OAuthClientsListOutput = Schema.Struct({ | 231 | const OAuthClientsListOutput = Schema.Struct({ |
| 232 | clients: Schema.Array(OAuthClientOutput), | 232 | clients: Schema.Array(OAuthClientOutput), |
| 233 | }); | 233 | }); |
| 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. | ||
| 234 | const OAuthCreateClientInput = Schema.Struct({ | 238 | const OAuthCreateClientInput = Schema.Struct({ |
| 235 | owner: OwnerSchema, | 239 | owner: OwnerSchema, |
| 236 | slug: Schema.String, | 240 | slug: Schema.String, |
| @@ -238,9 +242,27 @@ const OAuthCreateClientInput = Schema.Struct({ · 1 unmodified line | |||
| 238 | tokenUrl: Schema.String, | 242 | tokenUrl: Schema.String, |
| 239 | grant: OAuthGrantSchema, | 243 | grant: OAuthGrantSchema, |
| 240 | clientId: Schema.String, | 244 | clientId: Schema.String, |
| 241 | clientSecret: Schema.String, | ||
| 242 | resource: Schema.optional(Schema.NullOr(Schema.String)), | 245 | resource: Schema.optional(Schema.NullOr(Schema.String)), |
| 243 | }); | 246 | }); |
| 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 | }); | ||
| 244 | const OAuthClientOutputRef = Schema.Struct({ | 266 | const OAuthClientOutputRef = Schema.Struct({ |
| 245 | client: Schema.String, | 267 | client: Schema.String, |
| 246 | }); | 268 | }); |
| @@ -321,6 +343,8 @@ const PolicyUpdateInputStd = schemaToStandard(PolicyUpdateInput); · 74 unmodified lines | |||
| 321 | const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput); | 343 | const PolicyRemoveInputStd = schemaToStandard(PolicyRemoveInput); |
| 322 | const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput); | 344 | const OAuthClientsListOutputStd = schemaToStandard(OAuthClientsListOutput); |
| 323 | const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput); | 345 | const OAuthCreateClientInputStd = schemaToStandard(OAuthCreateClientInput); |
| 346 | const OAuthCreateClientHandoffInputStd = schemaToStandard(OAuthCreateClientHandoffInput); | ||
| 347 | const OAuthCreateClientHandoffOutputStd = schemaToStandard(OAuthCreateClientHandoffOutput); | ||
| 324 | const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef); | 348 | const OAuthClientOutputRefStd = schemaToStandard(OAuthClientOutputRef); |
| 325 | const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput); | 349 | const OAuthRegisterDynamicInputStd = schemaToStandard(OAuthRegisterDynamicInput); |
| 326 | const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput); | 350 | const OAuthRemoveClientInputStd = schemaToStandard(OAuthRemoveClientInput); |
| @@ -445,6 +469,31 @@ const connectionCreateHandoffUrl = ( · 118 unmodified lines | |||
| 445 | return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString(); | 469 | return new URL(path, webBaseUrl.endsWith("/") ? webBaseUrl : `${webBaseUrl}/`).toString(); |
| 446 | }; | 470 | }; |
| 447 | 471 | ||
| 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 | |||
| 448 | export interface CoreToolsPluginOptions { | 497 | export interface CoreToolsPluginOptions { |
| 449 | readonly webBaseUrl?: string; | 498 | readonly webBaseUrl?: string; |
| 450 | /** The bound org's URL slug, prefixed onto browser-handoff URLs so they open | 499 | /** 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 | |||
| 526 | '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`.', | 575 | '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`.', |
| 527 | inputSchema: ConnectionCreateInputStd, | 576 | inputSchema: ConnectionCreateInputStd, |
| 528 | outputSchema: ConnectionOutputStd, | 577 | 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 }, | ||
| 529 | execute: (input: typeof ConnectionCreateInput.Type, { ctx }) => | 585 | execute: (input: typeof ConnectionCreateInput.Type, { ctx }) => |
| 530 | Effect.map( | 586 | Effect.map( |
| 531 | ctx.connections.create(createConnectionInputFromTool(input)), | 587 | ctx.connections.create(createConnectionInputFromTool(input)), |
| @@ -553,6 +609,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines | |||
| 553 | "Remove a saved connection and its produced tools by owner, integration, and connection name.", | 609 | "Remove a saved connection and its produced tools by owner, integration, and connection name.", |
| 554 | inputSchema: ConnectionRefInputStd, | 610 | inputSchema: ConnectionRefInputStd, |
| 555 | outputSchema: RemovedOutputStd, | 611 | 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 }, | ||
| 556 | execute: (input: typeof ConnectionRefInput.Type, { ctx }) => | 616 | execute: (input: typeof ConnectionRefInput.Type, { ctx }) => |
| 557 | Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({ | 617 | Effect.map(ctx.connections.remove(connectionRefFromInput(input)), () => ({ |
| 558 | removed: true, | 618 | removed: true, |
| @@ -564,6 +624,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 5 unmodified lines | |||
| 564 | "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.", | 624 | "Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.", |
| 565 | inputSchema: ConnectionRefInputStd, | 625 | inputSchema: ConnectionRefInputStd, |
| 566 | outputSchema: ConnectionsRefreshOutputStd, | 626 | 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 }, | ||
| 567 | execute: (input: typeof ConnectionRefInput.Type, { ctx }) => | 632 | execute: (input: typeof ConnectionRefInput.Type, { ctx }) => |
| 568 | Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({ | 633 | Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({ |
| 569 | tools: tools.map(toolToOutput), | 634 | tools: tools.map(toolToOutput), |
| @@ -617,9 +682,20 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 47 unmodified lines | |||
| 617 | tool({ | 682 | tool({ |
| 618 | name: "oauth.clients.create", | 683 | name: "oauth.clients.create", |
| 619 | description: | 684 | 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.", |
| 621 | inputSchema: OAuthCreateClientInputStd, | 686 | inputSchema: OAuthCreateClientInputStd, |
| 622 | outputSchema: OAuthClientOutputRefStd, | 687 | 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 }, | ||
| 623 | execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) => | 699 | execute: (input: typeof OAuthCreateClientInput.Type, { ctx }) => |
| 624 | Effect.map( | 700 | Effect.map( |
| 625 | ctx.oauth.createClient({ | 701 | ctx.oauth.createClient({ |
| @@ -629,18 +705,43 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 3 unmodified lines | |||
| 629 | tokenUrl: input.tokenUrl, | 705 | tokenUrl: input.tokenUrl, |
| 630 | grant: input.grant, | 706 | grant: input.grant, |
| 631 | clientId: input.clientId, | 707 | 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: "", | ||
| 633 | resource: input.resource ?? null, | 712 | resource: input.resource ?? null, |
| 634 | }), | 713 | }), |
| 635 | (client) => ({ client: String(client) }), | 714 | (client) => ({ client: String(client) }), |
| 636 | ), | 715 | ), |
| 637 | }), | 716 | }), |
| 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 | }), | ||
| 638 | tool({ | 735 | tool({ |
| 639 | name: "oauth.clients.registerDynamic", | 736 | name: "oauth.clients.registerDynamic", |
| 640 | description: | 737 | description: |
| 641 | "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.", | 738 | "Register an OAuth client through RFC 7591 Dynamic Client Registration and save the minted client for later `oauth.start` calls.", |
| 642 | inputSchema: OAuthRegisterDynamicInputStd, | 739 | inputSchema: OAuthRegisterDynamicInputStd, |
| 643 | outputSchema: OAuthClientOutputRefStd, | 740 | 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 }, | ||
| 644 | execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) => | 745 | execute: (input: typeof OAuthRegisterDynamicInput.Type, { ctx }) => |
| 645 | Effect.map( | 746 | Effect.map( |
| 646 | ctx.oauth.registerDynamicClient({ | 747 | ctx.oauth.registerDynamicClient({ |
| @@ -668,6 +769,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 21 unmodified lines | |||
| 668 | "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.", | 769 | "Remove an owner-scoped OAuth client by owner and slug. Existing connections are not cascaded.", |
| 669 | inputSchema: OAuthRemoveClientInputStd, | 770 | inputSchema: OAuthRemoveClientInputStd, |
| 670 | outputSchema: RemovedOutputStd, | 771 | 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 }, | ||
| 671 | execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) => | 777 | execute: (input: typeof OAuthRemoveClientInput.Type, { ctx }) => |
| 672 | Effect.map( | 778 | Effect.map( |
| 673 | ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)), | 779 | ctx.oauth.removeClient(input.owner as Owner, OAuthClientSlug.make(input.slug)), |
| @@ -696,6 +802,14 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 22 unmodified lines | |||
| 696 | "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.", | 802 | "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.", |
| 697 | inputSchema: OAuthStartInputStd, | 803 | inputSchema: OAuthStartInputStd, |
| 698 | outputSchema: OAuthStartOutputStd, | 804 | 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 }, | ||
| 699 | execute: (input: typeof OAuthStartInput.Type, { ctx }) => | 813 | execute: (input: typeof OAuthStartInput.Type, { ctx }) => |
| 700 | Effect.map( | 814 | Effect.map( |
| 701 | ctx.oauth.start({ | 815 | ctx.oauth.start({ |
| @@ -752,6 +866,11 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 50 unmodified lines | |||
| 752 | "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).", | 866 | "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).", |
| 753 | inputSchema: PolicyCreateInputStd, | 867 | inputSchema: PolicyCreateInputStd, |
| 754 | outputSchema: PolicyOutputStd, | 868 | 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 }, | ||
| 755 | execute: (input: typeof PolicyCreateInput.Type, { ctx }) => | 874 | execute: (input: typeof PolicyCreateInput.Type, { ctx }) => |
| 756 | Effect.map( | 875 | Effect.map( |
| 757 | ctx.core.policies.create({ | 876 | ctx.core.policies.create({ |
| @@ -773,6 +892,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 15 unmodified lines | |||
| 773 | description: "Update a tool policy's pattern and/or action by id + owner.", | 892 | description: "Update a tool policy's pattern and/or action by id + owner.", |
| 774 | inputSchema: PolicyUpdateInputStd, | 893 | inputSchema: PolicyUpdateInputStd, |
| 775 | outputSchema: PolicyOutputStd, | 894 | 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 }, | ||
| 776 | execute: (input: typeof PolicyUpdateInput.Type, { ctx }) => | 899 | execute: (input: typeof PolicyUpdateInput.Type, { ctx }) => |
| 777 | Effect.map( | 900 | Effect.map( |
| 778 | ctx.core.policies.update({ | 901 | ctx.core.policies.update({ |
| @@ -795,6 +918,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { · 16 unmodified lines | |||
| 795 | description: "Remove a tool policy by id + owner.", | 918 | description: "Remove a tool policy by id + owner.", |
| 796 | inputSchema: PolicyRemoveInputStd, | 919 | inputSchema: PolicyRemoveInputStd, |
| 797 | outputSchema: RemovedOutputStd, | 920 | 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 }, | ||
| 798 | execute: (input: typeof PolicyRemoveInput.Type, { ctx }) => | 925 | execute: (input: typeof PolicyRemoveInput.Type, { ctx }) => |
| 799 | Effect.map( | 926 | Effect.map( |
| 800 | ctx.core.policies.remove({ | 927 | ctx.core.policies.remove({ |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { useEffect, useMemo, useState } from "react"; | 1 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -48,7 +48,7 @@ import { · 43 unmodified lines | |||
| 48 | } from "../plugins/use-effective-oauth-client"; | 48 | } from "../plugins/use-effective-oauth-client"; |
| 49 | import { cn } from "../lib/utils"; | 49 | import { cn } from "../lib/utils"; |
| 50 | import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage"; | 50 | import { buildUsageMap, connectionsUsingClient } from "../lib/oauth-client-usage"; |
| 51 | import { OAuthClientForm } from "./oauth-client-form"; | 51 | import { OAuthClientForm, type OAuthClientFormPrefill } from "./oauth-client-form"; |
| 52 | import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; | 52 | import { RemoveOAuthAppDialog } from "./remove-oauth-app-dialog"; |
| 53 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; | 53 | import { AddCustomMethodForm, type CreateCustomMethod } from "./add-custom-method-modal"; |
| 54 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; | 54 | import { PlacementLine, type AuthMethod } from "../lib/auth-placements"; |
| @@ -734,6 +734,43 @@ export function AddAccountModal(props: { · 679 unmodified lines | |||
| 734 | setMethodId(initialMethod?.id ?? allMethods[0]!.id); | 734 | setMethodId(initialMethod?.id ?? allMethods[0]!.id); |
| 735 | }, [allMethods, initialState?.template, methodId]); | 735 | }, [allMethods, initialState?.template, methodId]); |
| 736 | 736 | ||
| 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 | |||
| 737 | const isOAuth = method?.kind === "oauth"; | 774 | const isOAuth = method?.kind === "oauth"; |
| 738 | const isNoAuth = method?.kind === "none"; | 775 | const isNoAuth = method?.kind === "none"; |
| 739 | // The distinct credential inputs the selected method needs — one per variable | 776 | // The distinct credential inputs the selected method needs — one per variable |
| @@ -1206,11 +1243,24 @@ export function AddAccountModal(props: { · 466 unmodified lines | |||
| 1206 | String(app.slug), | 1243 | String(app.slug), |
| 1207 | )} | 1244 | )} |
| 1208 | prefill={{ | 1245 | prefill={{ |
| 1209 | authorizationUrl: method.oauth?.authorizationUrl, | 1246 | authorizationUrl: |
| 1210 | tokenUrl: method.oauth?.tokenUrl, | 1247 | oauthHandoffPrefill?.authorizationUrl ?? method.oauth?.authorizationUrl, |
| 1248 | tokenUrl: oauthHandoffPrefill?.tokenUrl ?? method.oauth?.tokenUrl, | ||
| 1211 | scopes: method.oauth?.scopes, | 1249 | scopes: method.oauth?.scopes, |
| 1212 | registrationEndpoint: method.oauth?.registrationEndpoint, | 1250 | 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 | : {}), | ||
| 1213 | }} | 1258 | }} |
| 1259 | fixedSlug={ | ||
| 1260 | oauthClientHandoff?.slug != null && oauthClientHandoff.slug.length > 0 | ||
| 1261 | ? OAuthClientSlug.make(oauthClientHandoff.slug) | ||
| 1262 | : undefined | ||
| 1263 | } | ||
| 1214 | onCreated={(result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => { | 1264 | onCreated={(result: { readonly owner: Owner; readonly slug: OAuthClientSlug }) => { |
| 1215 | setPickedApp(String(result.slug)); | 1265 | setPickedApp(String(result.slug)); |
| 1216 | setRegisteringOAuthClient(false); | 1266 | setRegisteringOAuthClient(false); |
| @@ -108,11 +108,32 @@ export function IntegrationDetailPage(props: { namespace: string }) { · 107 unmodified lines | |||
| 108 | const owner = search.get("owner"); | 108 | const owner = search.get("owner"); |
| 109 | const template = search.get("template"); | 109 | const template = search.get("template"); |
| 110 | const label = search.get("label"); | 110 | 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 | })(); | ||
| 111 | return { | 131 | return { |
| 112 | key: locationSearch, | 132 | key: locationSearch, |
| 113 | ...(owner === "org" || owner === "user" ? { owner } : {}), | 133 | ...(owner === "org" || owner === "user" ? { owner } : {}), |
| 114 | ...(template != null && template.length > 0 ? { template } : {}), | 134 | ...(template != null && template.length > 0 ? { template } : {}), |
| 115 | ...(label != null && label.length > 0 ? { label } : {}), | 135 | ...(label != null && label.length > 0 ? { label } : {}), |
| 136 | ...(oauthClient !== undefined ? { oauthClient } : {}), | ||
| 116 | }; | 137 | }; |
| 117 | }, [locationSearch]); | 138 | }, [locationSearch]); |
| 118 | 139 | ||