From 370e9c695b6d61a2c7a02edac542890e1c31b102 Mon Sep 17 00:00:00 2001 From: CRIMX Date: Wed, 28 Oct 2026 14:44:03 +0800 Subject: [PATCH] feat(connector): support advisory action approval --- contrib/skills/shared/oo/SKILL.md | 13 +- .../oo/references/connector-execution.md | 46 ++- docs/commands.md | 18 +- docs/commands.zh-CN.md | 15 +- .../commands/connector/index.cli.test.ts | 280 +++++++++++++++++- src/application/commands/connector/run.ts | 62 +++- .../commands/connector/shared.test.ts | 32 ++ src/application/commands/connector/shared.ts | 24 +- .../commands/telemetry-decisions.test.ts | 2 +- src/i18n/catalog.ts | 10 + 10 files changed, 469 insertions(+), 33 deletions(-) diff --git a/contrib/skills/shared/oo/SKILL.md b/contrib/skills/shared/oo/SKILL.md index d3a1f365..6a329908 100644 --- a/contrib/skills/shared/oo/SKILL.md +++ b/contrib/skills/shared/oo/SKILL.md @@ -195,12 +195,17 @@ proves its output. [references/file-transfer.md](references/file-transfer.md). 6. Build payload Use only fields exposed by the selected contract. Prefer user-provided values - over defaults, samples, and placeholders. Ask one focused follow-up only when - a required value is missing, risky to infer, destructive, broadly shared, or - externally visible but ambiguous. + over defaults, samples, and placeholders. Ask one focused follow-up when a + required value is missing or risky to infer, a recipient, content or + destination choice is ambiguous, or authorization is insufficient under the + host's confirmation rules. 7. Execute Execute the selected connector path through `oo`, using only the canonical - shape documented in the reference read at step 5. + shape documented in the reference read at step 5. For advisory confirmation, + assess the operation, context and existing user instructions using the + [advisory confirmation rules](references/connector-execution.md#advisory-confirmation). + Ask only when authorization is insufficient. An `approval_required` error + does not itself authorize `--approval`. 8. Materialize Save outputs locally only when doing so helps the user and the selected path exposes an explicit artifact URL. diff --git a/contrib/skills/shared/oo/references/connector-execution.md b/contrib/skills/shared/oo/references/connector-execution.md index 2c256746..73cb29f2 100644 --- a/contrib/skills/shared/oo/references/connector-execution.md +++ b/contrib/skills/shared/oo/references/connector-execution.md @@ -79,9 +79,9 @@ unambiguous. Otherwise ask one focused question or confirmation. Do not use honor user requests to validate without executing. An explicit user instruction plus complete required payload values is sufficient -confidence for non-destructive send, post, create, or invite actions. Ask before -destructive actions, broad sharing, or ambiguous recipient, content, or -destination choices. +confidence for non-destructive send, post, create, or invite actions. For +destructive actions or broad sharing, follow the host's confirmation rules. Ask +when authorization, recipient, content, or destination choices remain unclear. ## Execute the connector path @@ -118,6 +118,46 @@ Facts: - The schema returned by `oo connector schema` is the public contract used to build payloads and inspect the action result shape. +## Advisory confirmation + +`errorCode: approval_required` means the server's effective policy requires +advisory confirmation for this action. The blocked action was not executed. +This requirement depends on the effective user, team and connection policy; +it is not part of the static action schema. Do not reconnect, refresh the schema, +or switch teams or connections to avoid it. + +Use your judgment to assess whether the user's authorization covers the concrete +operation and input values in the conversation context. Consider the user's +current request and any applicable standing instructions, including permission +to proceed without further questions. If that authorization is sufficient, +use `--approval` without asking again. If it is insufficient, ask the user to +confirm the operation and relevant parameters. Follow the host's existing +confirmation rules. + +When the confirmation requirement is already known and authorization is +sufficient, you may include `--approval` on the initial call. After an +`approval_required` response, apply the same judgment before retrying; the error +itself does not supply authorization. Keep the original team, connection and +operation parameters on retries. Never add the flag mechanically or put it in +default command templates. It is a CLI option, not a field in the provider +input. The CLI does not prompt or retry automatically. `--dry-run` only validates +input and does not establish authorization. + +For async execution: + +- With `--wait`, an explicit `--approval` applies to every poll of the selected + result action. +- With `--wait-result`, the flag applies to the submit action only. Its derived + result action does not inherit the declaration. +- If submission succeeded but a result query requires confirmation, stderr + includes recovery JSON with `service`, result `action`, result-query `input` + and `submitExecutionId`. The task already exists. Do not submit it again. + Assess authorization for the result query, then resume using the recovery + service, action and input with `--wait` and, once authorized, `--approval`. + Retain the original team selection (including environment variables) and + `--app-id` or `--connection-name`. `submitExecutionId` identifies the original + submission; it is not an input parameter or a CLI option. + ## Proxy a provider API request Use `oo connector proxy` only when the selected connector service supports diff --git a/docs/commands.md b/docs/commands.md index 8d1c1b4d..e609c5a2 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1164,6 +1164,9 @@ Validate input data and run one connector action. - Options: `-a, --action ` selects the action name and is required. - Options: `-d, --data ` accepts inline JSON or `@path` to a JSON file. `--input ` is an alias for `--data `. +- Options: `--approval` declares that the user has authorized this operation. + The caller assesses whether existing authorization covers the action and its + parameters in context. Ask the user only when authorization is insufficient. - Options: `--dry-run` validates the payload without executing the action. - Options: `--app-id ` selects the exact connection from search. It is mutually exclusive with `--connection-name`; empty IDs are rejected. @@ -1196,7 +1199,20 @@ Validate input data and run one connector action. - Errors: stderr prints the HTTP status and includes the server `message` and `errorCode` when the failure response provides them. When the response carries neither, the raw response body is included (trimmed and length - bounded) so the failure detail is not lost. + bounded) so the failure detail is not lost. For `errorCode: approval_required`, + stderr explains that the blocked action was not executed and that it can be + retried with `--approval` once user authorization is satisfied. The HTTP status + is retained and the exit code is `1`. The CLI does not retry automatically. +- Errors: if `--wait-result` successfully submits a task but a result query + returns `approval_required`, the command exits `1` and explains that the task + was submitted but that result query was not executed. Stderr includes recovery + JSON `{ service, action, input, submitExecutionId }`, where `action` and + `input` describe the result query. Stdout remains empty. After authorization, + resume that result query with `--wait --approval`, preserving the original + team and connection selection. Do not resubmit the task. +- Notes: `--wait` passes an explicit `--approval` declaration on every poll of + the selected action. With `--wait-result`, it applies only to submission; + the derived result action does not inherit it. - Notes: the command validates the input against the selected action contract before executing. - Notes: while waiting for an async result action in text mode, interactive diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index fadd3af8..67bc039b 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -972,7 +972,9 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 选项:`-a, --action ` 用于指定 action 名称,且为必填。 - 选项:`-d, --data ` 支持直接传入 JSON,或使用 `@路径` 读取 JSON 文件。 `--input ` 是 `--data ` 的 alias。 -- 选项:`--dry-run` 只做 payload 校验,不真正执行 action。 +- 选项:`--approval` 声明本次操作已有用户授权。调用方结合具体操作、参数、上下文 + 和用户已有指示,判断授权是否充分;不足时再询问用户。 +- 选项:`--dry-run` 只做 payload 校验,不执行 action。 - 选项:`--app-id ` 精确选择搜索返回的连接,与 `--connection-name` 互斥, 不接受空值。当前选择的团队必须拥有该连接,app ID 本身不会切换团队。 - 选项:`--connection-name ` 使用指定 connector app 连接名称 @@ -996,7 +998,16 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 输出:dry-run 的 JSON 输出返回 `{ dryRun, ok }`。 - 错误:stderr 会打印 HTTP 状态;如果失败响应包含服务端 `message` 或 `errorCode`,也会一并输出;两者都缺失时,会输出原始响应体(已去除首尾空白 - 并限制长度),以免丢失失败详情。 + 并限制长度),以免丢失失败详情。对于 `errorCode: approval_required`, + stderr 会说明被拦截的 action 尚未执行,满足用户授权后可用 `--approval` 重试。 + 保留 HTTP 状态,退出码为 `1`,不会自动重试。 +- 错误:`--wait-result` 成功提交任务后,如果结果查询返回 `approval_required`, + 命令以 `1` 退出,并说明任务已提交、本次结果查询未执行。stderr 附恢复 JSON + `{ service, action, input, submitExecutionId }`,其中 `action` 和 `input` + 描述结果查询,stdout 保持为空。满足授权后,保留原团队和连接选择,使用恢复参数 + 带 `--wait --approval` 继续查询,不要重新提交任务。 +- 说明:`--wait` 会将显式 `--approval` 声明传给所选 action 的每次轮询。 + 对于 `--wait-result`,声明只传给提交 action,派生结果 action 不会继承。 - 说明:命令会在执行前根据选中 action 的 contract 校验输入。 - 说明:text 模式下等待 async result action 时,交互式终端会在 stderr 显示进度。JSON 输出不会混入进度文本。 diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 087f2bc2..25e97b67 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -624,6 +624,22 @@ describe("connectorCommand CLI", () => { } }); + test.each([ + { lang: "en", description: "Declare that user authorization for this action is satisfied" }, + { lang: "zh", description: "声明用户对本次操作的授权已满足" }, + ])("renders the approval option in $lang help", async ({ lang, description }) => { + const sandbox = await createCliSandbox(); + try { + const result = await sandbox.run(["--lang", lang, "connector", "run", "--help"]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("--approval"); + expect(collapseWhitespace(result.stdout)).toContain(description); + } + finally { + await sandbox.cleanup(); + } + }); + test("renders connector proxy help with request and identity options", async () => { const sandbox = await createCliSandbox(); @@ -1913,6 +1929,266 @@ describe("connectorCommand CLI", () => { } }); + test.each([false, true])("sends approval only when explicitly requested and keeps provider input separate: %s", async (approval) => { + const sandbox = await createCliSandbox(); + try { + await writeAuthFile(sandbox); + await seedConnectorActionSchema(sandbox); + const requests: Request[] = []; + const inputData = { approval: "provider-value", to: "foo@bar.com" }; + const result = await sandbox.run([ + "connector", + "run", + "gmail", + "-a", + "send_mail", + "-d", + JSON.stringify(inputData), + "--json", + ...(approval ? ["--approval"] : []), + ], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + return new Response(JSON.stringify({ data: {}, meta: { executionId: "exec-1" } })); + }, + }); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + await expect(requests[0]!.json()).resolves.toEqual({ + input: inputData, + ...(approval ? { approval: true } : {}), + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test.each(["en", "zh"])("reports approval_required without retrying or evicting cached schema in %s", async (lang) => { + const sandbox = await createCliSandbox(); + try { + await writeAuthFile(sandbox); + await seedConnectorActionSchema(sandbox); + const requests: Request[] = []; + const argv = ["connector", "run", "gmail", "-a", "send_mail", "--json", "--lang", lang]; + for (const flags of [[], ["--approval"]]) { + const result = await sandbox.run([...argv, ...flags], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + return new Response(JSON.stringify({ + errorCode: "approval_required", + message: "Server requires advisory confirmation", + }), { status: 409 }); + }, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("409"); + expect(result.stderr).toContain("approval_required"); + expect(result.stderr).toContain("--approval"); + expect(result.stderr).toContain(lang === "en" ? "was not executed" : "尚未执行"); + expect(result.stderr).toContain(lang === "en" ? "user authorization" : "用户授权"); + } + expect(requests).toHaveLength(2); + expect(requests.every(request => request.method === "POST")).toBe(true); + const rows = readTelemetryRowsForTest(join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry")); + expect(rows).toHaveLength(2); + for (const row of rows) { + const payload = parseTelemetryRowPayload(row); + expect(payload).toMatchObject({ properties: { error_code: "approval_required", http_status: 409 } }); + expect(payload?.properties).not.toHaveProperty("approval"); + } + } + finally { + await sandbox.cleanup(); + } + }); + + test("does not execute or declare approval during dry-run", async () => { + const sandbox = await createCliSandbox(); + try { + await writeAuthFile(sandbox); + await seedConnectorActionSchema(sandbox); + let requestCount = 0; + const result = await sandbox.run([ + "connector", + "run", + "gmail", + "-a", + "send_mail", + "--dry-run", + "--approval", + "--json", + ], { + fetcher: async () => { + requestCount += 1; + return new Response(null, { status: 500 }); + }, + }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ dryRun: true, ok: true }); + expect(requestCount).toBe(0); + } + finally { + await sandbox.cleanup(); + } + }); + + test.each([false, true])("forwards explicit approval on every selected result poll: %s", async (approval) => { + const sandbox = await createCliSandbox(); + const sleepMock = createBunSleepMock(); + try { + await writeAuthFile(sandbox); + await seedConnectorActionSchema(sandbox, createAsyncResultActionSchema()); + const requests: Request[] = []; + const result = await sandbox.run([ + "connector", + "run", + "fusion-api", + "-a", + "openai_image_async_result", + "-d", + JSON.stringify({ sessionID: "session-1" }), + "--wait", + "--json", + ...(approval ? ["--approval"] : []), + ], { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + return new Response(JSON.stringify({ + data: requests.length === 1 ? { state: "processing" } : { state: "completed", data: { images: [] } }, + meta: { executionId: `poll-${requests.length}` }, + })); + }, + }); + expect(result.exitCode).toBe(0); + expect(requests).toHaveLength(2); + for (const request of requests) { + await expect(request.json()).resolves.toEqual({ + input: { sessionID: "session-1" }, + ...(approval ? { approval: true } : {}), + }); + } + } + finally { + sleepMock.restore(); + await sandbox.cleanup(); + } + }); + + test.each([ + { selector: "--app-id", runningPolls: 0 }, + { selector: "--app-id", runningPolls: 1 }, + { selector: "--connection-name", runningPolls: 0 }, + { selector: "--connection-name", runningPolls: 1 }, + ])("recovers a submitted task with $selector after $runningPolls running polls without resubmitting", async ({ selector, runningPolls }) => { + const sandbox = await createCliSandbox(); + const sleepMock = createBunSleepMock(); + try { + await writeAuthFile(sandbox); + await seedConnectorActionSchema(sandbox, createAsyncSubmitActionSchema()); + await seedConnectorActionSchema(sandbox, createAsyncResultActionSchema()); + sandbox.env.OO_TEAM_ID = "original-team-id"; + const requests: Request[] = []; + const bodies: unknown[] = []; + const result = await sandbox.run([ + "connector", + "run", + "fusion-api", + "-a", + "openai_image_async_submit", + "-d", + JSON.stringify({ prompt: "a cat" }), + selector, + "original-connection", + "--wait-result", + "--approval", + "--json", + ], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + if (request.url === "https://relation-control.oomol.com/v1/teams/original-team-id") { + return Response.json({ id: "original-team-id", name: "Original", role: "member" }); + } + requests.push(request); + bodies.push(await request.json()); + if (requests.length === 1) { + return new Response(JSON.stringify({ data: { sessionId: "private-session-1" }, meta: { executionId: "submit-exec" } })); + } + if (requests.length <= runningPolls + 1) { + return new Response(JSON.stringify({ data: { state: "processing" }, meta: { executionId: "poll-1" } })); + } + return new Response(JSON.stringify({ errorCode: "approval_required" }), { status: 409 }); + }, + }); + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Task submission succeeded"); + expect(result.stderr).toContain("was not executed"); + expect(result.stderr).toContain("409"); + expect(result.stderr).toContain("approval_required"); + expect(result.stderr).toContain("same team and connection"); + expect(result.stderr).toContain("Do not resubmit"); + const recovery = JSON.parse(result.stderr.split("Recovery JSON:\n")[1]!); + expect(recovery).toEqual({ + service: "fusion-api", + action: "openai_image_async_result", + input: { sessionID: "private-session-1" }, + submitExecutionId: "submit-exec", + }); + expect(result.stderr).not.toContain("secret-1"); + expect(requests).toHaveLength(runningPolls + 2); + expect(bodies).toEqual([ + { input: { prompt: "a cat" }, approval: true }, + ...Array.from({ length: runningPolls + 1 }).fill({ input: recovery.input }), + ]); + const payload = parseTelemetryRowPayload(readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!); + expect(payload).toMatchObject({ properties: { error_code: "approval_required", http_status: 409 } }); + for (const value of ["private-session-1", "submit-exec", "recovery", "\"approval\"", "a cat", "original-connection"]) { + expect(JSON.stringify(payload)).not.toContain(value); + } + const resumed = await sandbox.run([ + "connector", + "run", + recovery.service, + "-a", + recovery.action, + "-d", + JSON.stringify(recovery.input), + selector, + "original-connection", + "--wait", + "--approval", + "--json", + ], { + fetcher: async (input, init) => { + const request = toRequest(input, init); + if (request.url === "https://relation-control.oomol.com/v1/teams/original-team-id") { + return Response.json({ id: "original-team-id", name: "Original", role: "member" }); + } + requests.push(request); + bodies.push(await request.json()); + return new Response(JSON.stringify({ data: { state: "completed", data: { images: [] } }, meta: { executionId: "resumed-poll" } })); + }, + }); + expect(resumed.exitCode).toBe(0); + expect(requests).toHaveLength(runningPolls + 3); + expect(bodies.at(-1)).toEqual({ input: recovery.input, approval: true }); + for (const [index, request] of requests.entries()) { + expect(new URL(request.url).pathname).toBe(`/v1/actions/fusion-api.${index === 0 ? "openai_image_async_submit" : "openai_image_async_result"}`); + expect(request.headers.get("x-oo-team-id")).toBe("original-team-id"); + expect(selector === "--app-id" ? new URL(request.url).searchParams.get("appId") : request.headers.get("x-oo-connector-alias")).toBe("original-connection"); + } + } + finally { + sleepMock.restore(); + await sandbox.cleanup(); + } + }); + test("supports connector run with cached schema and json output", async () => { const sandbox = await createCliSandbox(); @@ -2432,7 +2708,7 @@ describe("connectorCommand CLI", () => { } }); - test("submits async actions and waits for result action completion when --wait-result is enabled", async () => { + test.each([false, true])("submits async actions without inheriting approval on derived result polls: %s", async (approval) => { const sandbox = await createCliSandbox(); const sleepMock = createBunSleepMock(); @@ -2481,6 +2757,7 @@ describe("connectorCommand CLI", () => { "-d", "{\"prompt\":\"a cat\"}", "--wait-result", + ...(approval ? ["--approval"] : []), "--json", ], { @@ -2530,6 +2807,7 @@ describe("connectorCommand CLI", () => { "https://connector.oomol.com/v1/actions/fusion-api.openai_image_async_result", ]); await expect(requests[0]?.json()).resolves.toEqual({ + ...(approval ? { approval: true } : {}), input: { prompt: "a cat", }, diff --git a/src/application/commands/connector/run.ts b/src/application/commands/connector/run.ts index 653e41a3..604887f2 100644 --- a/src/application/commands/connector/run.ts +++ b/src/application/commands/connector/run.ts @@ -55,6 +55,7 @@ const connectorAsyncLifecycleDefaultTimeoutMs = 6 * 3_600_000; interface ConnectorRunInput { appId?: string; action?: string; + approval?: boolean; connectionName?: string; data?: string; dryRun?: boolean; @@ -104,6 +105,11 @@ export const connectorRunCommand: CliCommandDefinition = { valueName: "connection-name", descriptionKey: "options.connectorRunConnectionName", }, + { + name: "approval", + longFlag: "--approval", + descriptionKey: "options.connectorRunApproval", + }, { name: "dryRun", longFlag: "--dry-run", @@ -125,6 +131,7 @@ export const connectorRunCommand: CliCommandDefinition = { inputSchema: z.object({ appId: z.string().optional(), action: z.string().optional(), + approval: z.boolean().optional(), connectionName: z.string().optional(), data: z.string().optional(), dryRun: z.boolean().optional(), @@ -263,6 +270,7 @@ export const connectorRunCommand: CliCommandDefinition = { response = await runConnectorActionWithDefaultMode( { actionName, + approval: input.approval, connectionSelector, identity, inputData, @@ -314,6 +322,7 @@ export const connectorRunCommand: CliCommandDefinition = { async function runConnectorActionWithDefaultMode( options: { actionName: string; + approval?: boolean; connectionSelector: ConnectorConnectionSelector | undefined; identity: TeamIdentity | undefined; inputData: unknown; @@ -330,6 +339,7 @@ async function runConnectorActionWithDefaultMode( return await runConnectorAsyncSubmitAndWaitForResult( { actionName: options.actionName, + approval: options.approval, connectionSelector: options.connectionSelector, identity: options.identity, inputData: options.inputData, @@ -348,6 +358,7 @@ async function runConnectorActionWithDefaultMode( return await waitForConnectorAsyncResult( { actionName: options.actionName, + approval: options.approval, connectionSelector: options.connectionSelector, identity: options.identity, inputData: options.inputData, @@ -369,6 +380,7 @@ async function runConnectorActionWithDefaultMode( return await runConnectorAction( { actionName: options.actionName, + approval: options.approval, connectionSelector: options.connectionSelector, identity: options.identity, inputData: options.inputData, @@ -382,6 +394,7 @@ async function runConnectorActionWithDefaultMode( async function runConnectorAsyncSubmitAndWaitForResult( options: { actionName: string; + approval?: boolean; connectionSelector: ConnectorConnectionSelector | undefined; identity: TeamIdentity | undefined; inputData: unknown; @@ -402,6 +415,7 @@ async function runConnectorAsyncSubmitAndWaitForResult( const submitResponse = await runConnectorAction( { actionName: options.actionName, + approval: options.approval, connectionSelector: options.connectionSelector, identity: options.identity, inputData: options.inputData, @@ -421,22 +435,38 @@ async function runConnectorAsyncSubmitAndWaitForResult( }); } - const resultResponse = await waitForConnectorAsyncResult( - { - actionName: options.submitLifecycle.resultAction, - connectionSelector: options.connectionSelector, - identity: options.identity, - inputData: { - [options.submitLifecycle.handle.inputField]: handle, + const resultInput = { [options.submitLifecycle.handle.inputField]: handle }; + let resultResponse: ConnectorActionRunResponse; + try { + resultResponse = await waitForConnectorAsyncResult( + { + actionName: options.submitLifecycle.resultAction, + connectionSelector: options.connectionSelector, + identity: options.identity, + inputData: resultInput, + lifecycle: options.resultLifecycle, + progressReporter: options.progressReporter, + serviceName: options.serviceName, + target: options.target, }, - lifecycle: options.resultLifecycle, - progressReporter: options.progressReporter, - serviceName: options.serviceName, - target: options.target, - }, - context, - setCurrentTarget, - ); + context, + setCurrentTarget, + ); + } + catch (error) { + if (error instanceof CliUserError && error.params?.errorCode === "approval_required") { + throw new CliUserError("errors.connectorRun.asyncApprovalRequired", 1, { + ...error.params, + recovery: JSON.stringify({ + service: options.serviceName, + action: options.submitLifecycle.resultAction, + input: resultInput, + submitExecutionId: submitResponse.meta.executionId, + }), + }); + } + throw error; + } return { data: resultResponse.data, @@ -451,6 +481,7 @@ async function runConnectorAsyncSubmitAndWaitForResult( async function waitForConnectorAsyncResult( options: { actionName: string; + approval?: boolean; connectionSelector: ConnectorConnectionSelector | undefined; identity: TeamIdentity | undefined; inputData: unknown; @@ -483,6 +514,7 @@ async function waitForConnectorAsyncResult( const pollResponse = await runConnectorAction( { actionName: options.actionName, + approval: options.approval, connectionSelector: options.connectionSelector, identity: options.identity, inputData: options.inputData, diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 37af5dba..316eacad 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -437,6 +437,38 @@ describe("connector shared requests", () => { expect(request.headers.get("x-oo-connector-alias")).toBeNull(); }); + test.each([undefined, false, true])("runConnectorAction serializes only a true approval declaration: %s", async (approval) => { + let body: unknown; + await runConnectorAction({ + actionName: "send_mail", + approval, + inputData: { approval: false }, + serviceName: "gmail", + target: createConnectorTargetFixture(), + }, createRequestContext({ + fetcher: async (input, init) => { + body = await toRequest(input, init).json(); + return new Response(JSON.stringify({ data: {}, meta: { executionId: "exec-1" } })); + }, + })); + expect(body).toEqual({ input: { approval: false }, ...(approval === true ? { approval: true } : {}) }); + }); + + test.each([undefined, insufficientCreditErrorCode])("recognizes approval_required by code before message heuristics: %s", async (message) => { + const error = await expectCliUserError(runConnectorAction({ + actionName: "send_mail", + inputData: {}, + serviceName: "gmail", + target: createConnectorTargetFixture(), + }, createRequestContext({ + fetcher: async () => Response.json({ errorCode: "approval_required", message }, { status: 409 }), + }))); + + expect(error.key).toBe("errors.connectorRun.approvalRequired"); + expect(error.exitCode).toBe(1); + expect(error.params).toEqual({ action: "send_mail", errorCode: "approval_required", status: 409 }); + }); + test("runConnectorAction sends the connection-name selector as a header without query params", async () => { const requests: Request[] = []; await runConnectorAction( diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index 3b5d3c7f..c9c3a36c 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -335,10 +335,9 @@ export async function getConnectorActionMetadata( return parsed.data; } -// The five requestFailed* keys of one connector POST namespace. The two -// namespaces differ only here and in the subject param, so one ladder serves -// both. +// Connector POST namespaces share error mapping and provide their own messages. interface ConnectorFailureKeys { + approvalRequired?: string; requestFailed: string; requestFailedWithBody: string; requestFailedWithCode: string; @@ -347,6 +346,7 @@ interface ConnectorFailureKeys { } const connectorRunFailureKeys: ConnectorFailureKeys = { + approvalRequired: "errors.connectorRun.approvalRequired", requestFailed: "errors.connectorRun.requestFailed", requestFailedWithBody: "errors.connectorRun.requestFailedWithBody", requestFailedWithCode: "errors.connectorRun.requestFailedWithCode", @@ -365,6 +365,7 @@ const connectorProxyFailureKeys: ConnectorFailureKeys = { export async function runConnectorAction( options: { actionName: string; + approval?: boolean; connectionSelector?: ConnectorConnectionSelector; identity?: TeamIdentity | undefined; inputData: unknown; @@ -382,7 +383,10 @@ export async function runConnectorAction( ...teamIdentityHeaders(options.identity), }, host: { baseUrl: options.target.baseUrl }, - jsonBody: { input: options.inputData }, + jsonBody: { + input: options.inputData, + ...(options.approval === true ? { approval: true } : {}), + }, label: "Connector action run", logFields: { common: { @@ -690,8 +694,8 @@ function connectorFailureLogFields(failure: OoRequestFailure): Record