From aeaa4f217b0d9d4cb155a3e238de8f2f3c57bdd1 Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 10 Jun 2026 12:17:18 +0800 Subject: [PATCH 1/5] feat(connector): add proxy subcommand --- .../oo/references/connector-execution.md | 67 +++- docs/commands.md | 45 ++- docs/commands.zh-CN.md | 37 +- .../commands/connector/identity.test.ts | 23 -- .../commands/connector/identity.ts | 17 +- .../commands/connector/index.cli.test.ts | 145 +++++++- src/application/commands/connector/index.ts | 2 + src/application/commands/connector/proxy.ts | 328 ++++++++++++++++++ .../commands/connector/shared.test.ts | 80 ++++- src/application/commands/connector/shared.ts | 220 +++++++++++- .../commands/telemetry-decisions.test.ts | 13 + src/i18n/catalog.ts | 117 +++++++ 12 files changed, 1039 insertions(+), 55 deletions(-) create mode 100644 src/application/commands/connector/proxy.ts diff --git a/contrib/skills/shared/oo/references/connector-execution.md b/contrib/skills/shared/oo/references/connector-execution.md index a63ded53..80286c35 100644 --- a/contrib/skills/shared/oo/references/connector-execution.md +++ b/contrib/skills/shared/oo/references/connector-execution.md @@ -95,9 +95,64 @@ Facts: - The schema returned by `oo connector schema` is the public contract used to build payloads and inspect the action result shape. +## Proxy a provider API request + +Use `oo connector proxy` only when the selected connector service supports +proxy execution and no purpose-built connector action is available for the +user's requested provider endpoint. Do not use proxy as a first choice when a +matching connector action exists. + +Canonical forms: + +```bash +oo connector proxy "" \ + --endpoint "" \ + --method GET \ + --query '' \ + --json +``` + +```bash +oo connector proxy "" \ + --data '{"endpoint":"/path","method":"POST","body":{}}' \ + --json +``` + +Facts: + +- `serviceName` is the only positional argument. +- `--data` accepts the full proxy request object: + `{ "endpoint": string, "method": "GET" | "POST" | "PUT" | "PATCH" | "DELETE", "query"?: object, "headers"?: object, "body"?: unknown }`. +- Without `--data`, build the same object with `--endpoint`, `--method`, and + optional `--query`, `--headers`, and `--body`. +- `--query`, `--headers`, and `--body` are parsed as JSON. To send text in + `body`, pass a JSON string such as `"hello"`. +- Do not pass provider credentials, `Authorization`, cookies, or API keys in + proxy headers. The connector service injects authentication from the + connected app. +- Use `--app-id ""` or `--alias ""` only when the user identified a + specific connected app. They cannot be combined. +- JSON output has this stable shape: + +```json +{ + "data": { + "status": 200, + "headers": {}, + "data": {} + }, + "meta": { + "executionId": "execution-id", + "service": "serviceName", + "appId": "optional-app-id" + } +} +``` + ## Choose the run identity -A connector action runs under one identity. Pick it from what the user said: +A connector action or proxy request runs under one identity. Pick it from what +the user said: - If the user does not mention any organization, run under their personal identity: add nothing extra. This is the default. @@ -113,6 +168,16 @@ oo connector run "" \ --json ``` +For proxy requests, use the same identity option: + +```bash +oo connector proxy "" \ + --endpoint "" \ + --method GET \ + --organization "" \ + --json +``` + - If the user has a configured default organization but explicitly asks for this one run to be personal, add `--personal`. diff --git a/docs/commands.md b/docs/commands.md index 7cc433e9..a87564e8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -239,8 +239,9 @@ Persist one configuration value. pending telemetry events immediately and the current `config set` invocation is not recorded as telemetry. - Value rules: for `identity.organization`, use any non-empty organization name. - It sets the default organization identity used by `oo connector run` when - neither `--organization` nor `--personal` is passed. + It sets the default organization identity used by `oo connector run` and + `oo connector proxy` when neither `--organization` nor `--personal` is + passed. ### `oo config unset ` @@ -608,6 +609,46 @@ Validate input data and run one connector action. - Notes: while waiting for an async result action in text mode, interactive terminals show progress on stderr. JSON output does not include progress text. +### `oo connector proxy ` + +Proxy a provider API request through a connected connector app. + +- Arguments: `` is the service name. +- Options: `-d, --data ` accepts a complete proxy request JSON object or + `@path` to a JSON file. The object shape is + `{ endpoint, method, query?, headers?, body? }`. +- Options: without `--data`, use `--endpoint ` and + `--method ` plus optional `--query `, `--headers `, and + `--body ` to build the same request object. +- Options: `--endpoint` is a provider endpoint path relative to the provider + proxy base URL, or an allowed absolute HTTPS URL. +- Options: `--method` must be one of `GET`, `POST`, `PUT`, `PATCH`, or + `DELETE`. +- Options: `--query` must be a JSON object whose values are strings, numbers, + booleans, or `null`. +- Options: `--headers` must be a JSON object with string values. + Authentication headers are injected by the connector service from the + connected app; callers should not pass provider credentials through CLI + options. +- Options: `--body` is parsed as JSON. To send a text body, pass a JSON string + such as `"hello"`. +- Options: `--app-id ` or `--alias ` selects a specific connected + connector app. They cannot be combined. +- Options: `--organization ` runs the proxy request under the given + organization identity instead of your personal identity. `--org ` is an + alias for `--organization `. When omitted, the request runs under the + `identity.organization` config default if set, otherwise your personal + identity. +- Options: `--personal` runs the proxy request under your personal identity and + ignores any configured default organization. It cannot be combined with + `--organization`. +- Options: `--format=json` and `--json` print a JSON object. +- Output: JSON output keeps the stable shape + `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`. +- Notes: `oo connector proxy` does not use connector action schemas or schema + cache. Use it when the selected connector supports proxy execution and no + purpose-built connector action is available. + ## Search ### `oo search ` diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 9dbc4d91..505d8ebf 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -214,7 +214,8 @@ CLI 还会立即尝试清空待发送 telemetry 事件,并且本次 `config set` 调用自身不会被记录为 telemetry。 - 取值规则:当 `` 为 `identity.organization` 时,支持任意非空的组织名称。 - 它设置 `oo connector run` 在未传 `--organization` 或 `--personal` 时使用的默认组织身份。 + 它设置 `oo connector run` 和 `oo connector proxy` 在未传 `--organization` 或 + `--personal` 时使用的默认组织身份。 ### `oo config unset ` @@ -522,6 +523,40 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 说明:text 模式下等待 async result action 时,交互式终端会在 stderr 显示进度。JSON 输出不会混入进度文本。 +### `oo connector proxy ` + +通过已连接的 connector app 代理 provider API 请求。 + +- 参数:`` 为服务名。 +- 选项:`-d, --data ` 接收完整 proxy request JSON object,或使用 + `@路径` 读取 JSON 文件。对象形状为 + `{ endpoint, method, query?, headers?, body? }`。 +- 选项:未传 `--data` 时,使用 `--endpoint ` 和 + `--method `,以及可选的 `--query `、`--headers `、 + `--body ` 组装同样的 request object。 +- 选项:`--endpoint` 是相对于 provider proxy base URL 的 provider endpoint + path,或允许的绝对 HTTPS URL。 +- 选项:`--method` 必须是 `GET`、`POST`、`PUT`、`PATCH` 或 `DELETE`。 +- 选项:`--query` 必须是 JSON object,值只能是 string、number、boolean 或 + `null`。 +- 选项:`--headers` 必须是 string 值的 JSON object。认证 header 会由 + connector service 根据已连接 app 注入;调用方不应通过 CLI 选项传 provider + credential。 +- 选项:`--body` 会按 JSON 解析。如需发送文本 body,请传 JSON string,例如 + `"hello"`。 +- 选项:`--app-id ` 或 `--alias ` 用于选择特定已连接的 + connector app。两者不能同时使用。 +- 选项:`--organization ` 以指定组织身份运行该 proxy 请求,而非个人身份。 + `--org ` 是 `--organization ` 的 alias。省略时,若配置了 + `identity.organization` 默认值则使用该组织,否则使用个人身份。 +- 选项:`--personal` 以个人身份运行该 proxy 请求,并忽略已配置的默认组织。 + 不能与 `--organization` 同时使用。 +- 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 +- 输出:JSON 输出保持稳定结构 + `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`。 +- 说明:`oo connector proxy` 不使用 connector action schema 或 schema cache。 + 当选中的 connector 支持 proxy execution 且没有专用 connector action 时使用。 + ## Search ### `oo search ` diff --git a/src/application/commands/connector/identity.test.ts b/src/application/commands/connector/identity.test.ts index 09f51f9f..dfbcb587 100644 --- a/src/application/commands/connector/identity.test.ts +++ b/src/application/commands/connector/identity.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test"; import { - applyConnectorIdentityToUrl, connectorIdentityHeaders, resolveConnectorIdentity, } from "./identity.ts"; @@ -63,28 +62,6 @@ describe("resolveConnectorIdentity", () => { }); }); -describe("applyConnectorIdentityToUrl", () => { - test("adds the organization query parameter for an organization identity", () => { - const url = new URL("https://connector.oomol.com/v1/actions/gmail.send_mail"); - - applyConnectorIdentityToUrl(url, { organization: "acme" }); - - expect(url.toString()).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail?organization=acme", - ); - }); - - test("leaves the URL untouched for the personal identity", () => { - const url = new URL("https://connector.oomol.com/v1/actions/gmail.send_mail"); - - applyConnectorIdentityToUrl(url, {}); - - expect(url.toString()).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail", - ); - }); -}); - describe("connectorIdentityHeaders", () => { test("returns the organization header for an organization identity", () => { expect(connectorIdentityHeaders({ organization: "acme" })).toEqual({ diff --git a/src/application/commands/connector/identity.ts b/src/application/commands/connector/identity.ts index 76c61f95..a0f6e15b 100644 --- a/src/application/commands/connector/identity.ts +++ b/src/application/commands/connector/identity.ts @@ -4,9 +4,9 @@ // personal => {} (no organization) // org => { organization } // -// The identity is resolved once per `connector run` invocation and then applied -// only to the action run requests (POST /v1/actions/...). The action schema / -// metadata layer is identity-independent and is intentionally left untouched. +// The identity is resolved once per connector invocation and then applied only +// to execution requests. The action schema / metadata layer is +// identity-independent and is intentionally left untouched. // // This struct is the extension point for additional identity dimensions: a new // field here plus a branch in the request helpers below is all a future @@ -53,17 +53,6 @@ export function resolveConnectorIdentity(input: { return { identity: {}, source: "personal" }; } -// Adds the identity query parameters to a connector action request URL. This is -// the single place that maps identity fields to query parameters. -export function applyConnectorIdentityToUrl( - url: URL, - identity: ConnectorIdentity | undefined, -): void { - if (identity?.organization !== undefined) { - url.searchParams.set("organization", identity.organization); - } -} - // Builds the identity request headers (`x-oo-organization`). Returns an empty // object for the personal identity so callers can spread it unconditionally. export function connectorIdentityHeaders( diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index d076b554..1687f936 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -501,6 +501,145 @@ describe("connectorCommand CLI", () => { } }); + test("renders connector proxy help with request and identity options", async () => { + const sandbox = await createCliSandbox(); + + try { + const result = await sandbox.run(["connector", "proxy", "--help"]); + const help = collapseWhitespace(result.stdout); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("--endpoint"); + expect(result.stdout).toContain("--method"); + expect(result.stdout).toContain("--query"); + expect(result.stdout).toContain("--headers"); + expect(result.stdout).toContain("--body"); + expect(result.stdout).toContain("--app-id"); + expect(result.stdout).toContain("--alias"); + expect(result.stdout).toContain("--organization"); + expect(result.stdout).toContain("--personal"); + expect(help).toContain( + "Proxy a provider API request through a connected connector app", + ); + } + finally { + await sandbox.cleanup(); + } + }); + + test("supports connector proxy with split request options and organization identity", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const requests: Request[] = []; + const result = await sandbox.run( + [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "POST", + "--query", + "{\"limit\":1}", + "--headers", + "{\"accept\":\"application/json\"}", + "--body", + "{\"query\":\"hello\"}", + "--alias", + "primary", + "--organization", + "acme", + "--json", + ], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: { + data: { + answer: "world", + }, + headers: { + "content-type": "application/json", + }, + status: 200, + }, + meta: { + appId: "app-1", + executionId: "exec-1", + service: "tavily", + }, + })); + }, + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + data: { + data: { + answer: "world", + }, + headers: { + "content-type": "application/json", + }, + status: 200, + }, + meta: { + appId: "app-1", + executionId: "exec-1", + service: "tavily", + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + expect(requests[0]?.url).toBe("https://connector.oomol.com/v1/proxy/tavily"); + expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); + expect(requests[0]?.headers.get("X-Oomol-Connector-Alias")).toBe("primary"); + expect(requests[0]?.headers.get("X-Oomol-Connector-App-Id")).toBeNull(); + await expect(requests[0]?.json()).resolves.toEqual({ + body: { + query: "hello", + }, + endpoint: "/search", + headers: { + accept: "application/json", + }, + method: "POST", + query: { + limit: 1, + }, + }); + expect(telemetryPayload).toMatchObject({ + properties: { + command_full: "connector.proxy", + has_alias: true, + has_app_id: false, + has_body: true, + identity_source: "flag", + method: "POST", + service: "tavily", + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("organization"); + } + finally { + await sandbox.cleanup(); + } + }); + test("supports connector run with cached schema and json output", async () => { const sandbox = await createCliSandbox(); @@ -2756,7 +2895,7 @@ describe("connectorCommand CLI", () => { expect(requests).toHaveLength(1); expect(requests[0]?.method).toBe("POST"); expect(requests[0]?.url).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail?organization=acme", + "https://connector.oomol.com/v1/actions/gmail.send_mail", ); expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); expect(telemetryPayload).toMatchObject({ @@ -2846,7 +2985,7 @@ describe("connectorCommand CLI", () => { // The run POST carries the organization identity. expect(requests[1]?.method).toBe("POST"); expect(requests[1]?.url).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail?organization=acme", + "https://connector.oomol.com/v1/actions/gmail.send_mail", ); expect(requests[1]?.headers.get("x-oo-organization")).toBe("acme"); } @@ -2899,7 +3038,7 @@ describe("connectorCommand CLI", () => { expect(result.exitCode).toBe(0); expect(requests[0]?.url).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail?organization=acme", + "https://connector.oomol.com/v1/actions/gmail.send_mail", ); expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); expect(runTelemetryPayload).toMatchObject({ diff --git a/src/application/commands/connector/index.ts b/src/application/commands/connector/index.ts index f2c1d217..bef4a62a 100644 --- a/src/application/commands/connector/index.ts +++ b/src/application/commands/connector/index.ts @@ -1,5 +1,6 @@ import type { CliCommandDefinition } from "../../contracts/cli.ts"; +import { connectorProxyCommand } from "./proxy.ts"; import { connectorRunCommand } from "./run.ts"; import { connectorSchemaCommand } from "./schema.ts"; import { connectorSearchCommand } from "./search.ts"; @@ -12,5 +13,6 @@ export const connectorCommand: CliCommandDefinition = { connectorSearchCommand, connectorSchemaCommand, connectorRunCommand, + connectorProxyCommand, ], }; diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts new file mode 100644 index 00000000..ca0f2796 --- /dev/null +++ b/src/application/commands/connector/proxy.ts @@ -0,0 +1,328 @@ +import type { CliCommandDefinition, CliExecutionContext } from "../../contracts/cli.ts"; +import type { ConnectorProxyResponse } from "./shared.ts"; + +import { Buffer } from "node:buffer"; +import { z } from "zod"; +import { CliUserError } from "../../contracts/cli.ts"; +import { getConfiguredIdentityOrganization } from "../../schemas/settings.ts"; +import { bucketTelemetryBytes } from "../../telemetry/buckets.ts"; +import { jsonOutputOptions, writeJsonOutput } from "../json-output.ts"; +import { requireCurrentAccount } from "../shared/auth-utils.ts"; +import { createFormatInputError } from "../shared/input-parsing.ts"; +import { readJsonInputValue } from "../shared/json-input.ts"; +import { resolveConnectorIdentity } from "./identity.ts"; +import { + connectorFormatValues, + runConnectorProxy, +} from "./shared.ts"; + +const connectorProxyDataErrorKeys = { + dataFilePathRequired: "errors.connectorProxy.dataFilePathRequired", + dataReadFailed: "errors.connectorProxy.dataReadFailed", + invalidDataJson: "errors.connectorProxy.invalidDataJson", +} as const; + +const connectorProxyMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; + +const proxyQueryValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); + +const proxyRequestSchema = z.object({ + body: z.unknown().optional(), + endpoint: z.string().trim().min(1), + headers: z.record(z.string(), z.string()).optional(), + method: z.enum(connectorProxyMethods), + query: z.record(z.string(), proxyQueryValueSchema).optional(), +}).strict(); + +interface ConnectorProxyInput { + alias?: string; + appId?: string; + body?: string; + data?: string; + endpoint?: string; + format?: (typeof connectorFormatValues)[number]; + headers?: string; + method?: (typeof connectorProxyMethods)[number]; + organization?: string; + personal?: boolean; + query?: string; + serviceName: string; + showSchemaVersion?: boolean; +} + +export const connectorProxyCommand: CliCommandDefinition = { + name: "proxy", + summaryKey: "commands.connector.proxy.summary", + descriptionKey: "commands.connector.proxy.description", + missingArgumentBehavior: "showHelp", + arguments: [ + { + name: "serviceName", + descriptionKey: "arguments.serviceName", + required: true, + }, + ], + options: [ + { + name: "data", + longFlag: "--data", + shortFlag: "-d", + aliasFlags: ["--input"], + valueName: "data", + descriptionKey: "options.connectorProxyData", + }, + { + name: "endpoint", + longFlag: "--endpoint", + valueName: "endpoint", + descriptionKey: "options.connectorProxyEndpoint", + }, + { + name: "method", + longFlag: "--method", + valueName: "method", + descriptionKey: "options.connectorProxyMethod", + }, + { + name: "query", + longFlag: "--query", + valueName: "query", + descriptionKey: "options.connectorProxyQuery", + }, + { + name: "headers", + longFlag: "--headers", + valueName: "headers", + descriptionKey: "options.connectorProxyHeaders", + }, + { + name: "body", + longFlag: "--body", + valueName: "body", + descriptionKey: "options.connectorProxyBody", + }, + { + name: "appId", + longFlag: "--app-id", + valueName: "appId", + descriptionKey: "options.connectorProxyAppId", + }, + { + name: "alias", + longFlag: "--alias", + valueName: "alias", + descriptionKey: "options.connectorProxyAlias", + }, + { + name: "organization", + longFlag: "--organization", + aliasFlags: ["--org"], + valueName: "organization", + descriptionKey: "options.connectorRunOrganization", + }, + { + name: "personal", + longFlag: "--personal", + descriptionKey: "options.connectorRunPersonal", + }, + ...jsonOutputOptions, + ], + inputSchema: z.object({ + alias: z.string().optional(), + appId: z.string().optional(), + body: z.string().optional(), + data: z.string().optional(), + endpoint: z.string().optional(), + format: z.enum(connectorFormatValues).optional(), + headers: z.string().optional(), + method: z.enum(connectorProxyMethods).optional(), + organization: z.string().optional(), + personal: z.boolean().optional(), + query: z.string().optional(), + serviceName: z.string(), + showSchemaVersion: z.boolean().optional(), + }), + mapInputError: (_, rawInput) => createFormatInputError(rawInput), + handler: async (input, context) => { + if (input.personal === true && input.organization !== undefined) { + throw new CliUserError("errors.connectorRun.identityConflict", 2); + } + + if (input.appId !== undefined && input.alias !== undefined) { + throw new CliUserError("errors.connectorProxy.selectorConflict", 2); + } + + const organizationFlag = input.organization?.trim(); + if (input.organization !== undefined && organizationFlag === "") { + throw new CliUserError("errors.connectorRun.organizationEmpty", 2); + } + + const appId = trimOptionalSelector(input.appId, "errors.connectorProxy.appIdEmpty"); + const alias = trimOptionalSelector(input.alias, "errors.connectorProxy.aliasEmpty"); + const account = await requireCurrentAccount(context); + const settings = await context.settingsStore.read(); + const { identity, source: identitySource } = resolveConnectorIdentity({ + configOrganization: getConfiguredIdentityOrganization(settings), + organizationFlag, + personalFlag: input.personal === true, + }); + const proxyRequest = await buildConnectorProxyRequest(input, context); + + context.telemetry?.recordProperties({ + data_size_bucket: bucketTelemetryBytes( + Buffer.byteLength(JSON.stringify(proxyRequest)), + ), + has_alias: alias !== undefined, + has_app_id: appId !== undefined, + has_body: hasProxyBody(proxyRequest), + identity_source: identitySource, + method: readProxyMethod(proxyRequest), + service: input.serviceName, + }); + + const response = await runConnectorProxy( + { + alias, + apiKey: account.apiKey, + appId, + endpoint: account.endpoint, + identity, + proxyRequest, + serviceName: input.serviceName, + }, + context, + ); + + if (input.format === "json") { + writeJsonOutput(context.stdout, response, { + showSchemaVersion: input.showSchemaVersion, + }); + return; + } + + context.stdout.write(`${formatConnectorProxyResponseAsText(response, context)}\n`); + }, +}; + +async function buildConnectorProxyRequest( + input: ConnectorProxyInput, + context: Pick, +): Promise { + if (input.data !== undefined && hasSplitProxyRequestInput(input)) { + throw new CliUserError("errors.connectorProxy.dataConflict", 2); + } + + if (input.data !== undefined) { + return parseProxyRequest( + await readJsonInputValue(input.data, context, connectorProxyDataErrorKeys, {}), + ); + } + + if (input.endpoint === undefined || input.endpoint.trim() === "") { + throw new CliUserError("errors.connectorProxy.endpointRequired", 2); + } + + if (input.method === undefined) { + throw new CliUserError("errors.connectorProxy.methodRequired", 2); + } + + return parseProxyRequest({ + endpoint: input.endpoint, + method: input.method, + ...(input.query !== undefined + ? { query: parseJsonOption(input.query, "errors.connectorProxy.invalidQueryJson") } + : {}), + ...(input.headers !== undefined + ? { + headers: parseJsonOption( + input.headers, + "errors.connectorProxy.invalidHeadersJson", + ), + } + : {}), + ...(input.body !== undefined + ? { body: parseJsonOption(input.body, "errors.connectorProxy.invalidBodyJson") } + : {}), + }); +} + +function hasSplitProxyRequestInput(input: ConnectorProxyInput): boolean { + return input.endpoint !== undefined + || input.method !== undefined + || input.query !== undefined + || input.headers !== undefined + || input.body !== undefined; +} + +function parseProxyRequest(value: unknown): unknown { + const parsed = proxyRequestSchema.safeParse(value); + + if (!parsed.success) { + throw new CliUserError("errors.connectorProxy.invalidPayload", 2, { + message: parsed.error.issues[0]?.message ?? "Invalid proxy request.", + }); + } + + return parsed.data; +} + +function parseJsonOption(value: string, errorKey: string): unknown { + try { + return JSON.parse(value) as unknown; + } + catch (error) { + throw new CliUserError(errorKey, 2, { + message: error instanceof Error ? error.message : String(error), + }); + } +} + +function trimOptionalSelector(value: string | undefined, errorKey: string): string | undefined { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + + if (trimmed === "") { + throw new CliUserError(errorKey, 2); + } + + return trimmed; +} + +function hasProxyBody(proxyRequest: unknown): boolean { + return typeof proxyRequest === "object" + && proxyRequest !== null + && "body" in proxyRequest; +} + +function readProxyMethod(proxyRequest: unknown): string { + if ( + typeof proxyRequest === "object" + && proxyRequest !== null + && "method" in proxyRequest + && typeof proxyRequest.method === "string" + ) { + return proxyRequest.method; + } + + return "unknown"; +} + +function formatConnectorProxyResponseAsText( + response: ConnectorProxyResponse, + context: Pick, +): string { + return [ + `${context.translator.t("connector.proxy.text.status")}: ${response.data.status}`, + `${context.translator.t("connector.run.text.executionId")}: ${response.meta.executionId}`, + `${context.translator.t("connector.run.text.resultData")}:`, + JSON.stringify(response.data.data, null, 2), + ].join("\n"); +} diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 481e4687..0c34680f 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -17,6 +17,7 @@ import { getConnectorActionMetadata, listAuthenticatedConnectorServices, runConnectorAction, + runConnectorProxy, searchConnectorActions, } from "./shared.ts"; @@ -187,7 +188,7 @@ describe("connector shared requests", () => { }); }); - test("runConnectorAction sends the organization query and header for an organization identity", async () => { + test("runConnectorAction sends the organization header for an organization identity", async () => { const requests: Request[] = []; await runConnectorAction( { @@ -220,7 +221,7 @@ describe("connector shared requests", () => { expect(requests).toHaveLength(1); expect(requests[0]?.url).toBe( - "https://connector.oomol.com/v1/actions/gmail.send_mail?organization=acme", + "https://connector.oomol.com/v1/actions/gmail.send_mail", ); expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); }); @@ -295,6 +296,81 @@ describe("connector shared requests", () => { expect(requests[0]?.headers.get("x-oo-organization")).toBeNull(); }); + test("runConnectorProxy sends proxy requests with identity and selector headers", async () => { + const requests: Request[] = []; + const response = await runConnectorProxy( + { + alias: "primary", + apiKey: "secret-1", + endpoint: "oomol.com", + identity: { + organization: "acme", + }, + proxyRequest: { + endpoint: "/search", + method: "GET", + query: { + q: "hello", + }, + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: { + data: { + answer: "world", + }, + headers: { + "content-type": "application/json", + }, + status: 200, + }, + message: "OK", + meta: { + appId: "app-1", + executionId: "exec-1", + service: "tavily", + }, + success: true, + })); + }, + }), + ); + + expect(response).toEqual({ + data: { + data: { + answer: "world", + }, + headers: { + "content-type": "application/json", + }, + status: 200, + }, + meta: { + appId: "app-1", + executionId: "exec-1", + service: "tavily", + }, + }); + expect(requests).toHaveLength(1); + expect(requests[0]?.url).toBe("https://connector.oomol.com/v1/proxy/tavily"); + expect(requests[0]?.headers.get("Authorization")).toBe("secret-1"); + expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); + expect(requests[0]?.headers.get("X-Oomol-Connector-Alias")).toBe("primary"); + await expect(requests[0]?.json()).resolves.toEqual({ + endpoint: "/search", + method: "GET", + query: { + q: "hello", + }, + }); + }); + test("runConnectorAction surfaces errorCode when the failure response omits a message", async () => { const error = await expectCliUserError(runConnectorAction( { diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index 594411c4..a203806d 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -10,10 +10,7 @@ import { isInsufficientCreditFailure, } from "../shared/billing.ts"; import { getUnexpectedRequestErrorMessage, requestText } from "../shared/request.ts"; -import { - applyConnectorIdentityToUrl, - connectorIdentityHeaders, -} from "./identity.ts"; +import { connectorIdentityHeaders } from "./identity.ts"; export const connectorActionDefinitionSchema = z.object({ description: z.string().optional().default(""), @@ -86,6 +83,23 @@ const connectorActionRunResponseSchema = z.object({ ...response }) => response); +const connectorProxyResponseSchema = z.object({ + data: z.object({ + data: z.unknown(), + headers: z.record(z.string(), z.string()), + status: z.number().int(), + }), + meta: z.object({ + appId: z.string().optional(), + executionId: z.string().min(1), + service: z.string().min(1), + }).passthrough(), +}).passthrough().transform(({ + message: _message, + success: _success, + ...response +}) => response); + const connectorActionFailureResponseSchema = z.object({ errorCode: z.string().optional(), message: z.string().optional(), @@ -111,6 +125,7 @@ export type ConnectorActionDefinition = z.output; export type ConnectorActionMetadata = z.output; export type ConnectorActionRunResponse = z.output; +export type ConnectorProxyResponse = z.output; type ConnectorActionFailureResponse = z.output; export async function searchConnectorActions( @@ -305,7 +320,6 @@ export async function runConnectorAction( options.endpoint, options.serviceName, options.actionName, - options.identity, ); const requestBody = JSON.stringify({ input: options.inputData, @@ -416,22 +430,163 @@ export async function runConnectorAction( } } +export async function runConnectorProxy( + options: { + alias?: string; + apiKey: string; + appId?: string; + endpoint: string; + identity?: ConnectorIdentity; + proxyRequest: unknown; + serviceName: string; + }, + context: Pick, +): Promise { + const requestUrl = createConnectorProxyRequestUrl( + options.endpoint, + options.serviceName, + ); + const requestBody = JSON.stringify(options.proxyRequest); + const requestStartedAt = Date.now(); + + context.logger.debug( + { + ...withRequestTarget(requestUrl.host, requestUrl.pathname), + bodyLength: requestBody.length, + method: "POST", + serviceName: options.serviceName, + }, + "Connector proxy request started.", + ); + + let rawResponse: string; + + try { + const response = await context.fetcher(requestUrl, { + body: requestBody, + headers: { + "Authorization": options.apiKey, + "Content-Type": "application/json", + ...connectorIdentityHeaders(options.identity), + ...connectorProxySelectorHeaders(options), + }, + method: "POST", + }); + const durationMs = Date.now() - requestStartedAt; + + rawResponse = await response.text(); + + if (!response.ok) { + const failureResponse = parseConnectorFailureResponse(rawResponse); + const responseDiagnostics = collectSafeConnectorFailureDiagnostics( + response, + rawResponse, + failureResponse, + ); + + context.logger.warn( + { + ...withRequestTarget(requestUrl.host, requestUrl.pathname), + ...responseDiagnostics, + durationMs, + errorCode: failureResponse?.errorCode, + executionId: failureResponse?.meta?.executionId, + method: "POST", + responseMessage: sanitizeConnectorFailureMessage( + failureResponse?.message, + ), + serviceName: options.serviceName, + status: response.status, + }, + "Connector proxy request returned a non-success status.", + ); + + throw createConnectorProxyRequestFailedError({ + failureResponse, + serviceName: options.serviceName, + status: response.status, + }); + } + + context.logger.debug( + { + ...withRequestTarget(requestUrl.host, requestUrl.pathname), + durationMs, + method: "POST", + serviceName: options.serviceName, + status: response.status, + }, + "Connector proxy request completed.", + ); + } + catch (error) { + if (error instanceof CliUserError) { + throw error; + } + + context.logger.warn( + { + ...withRequestTarget(requestUrl.host, requestUrl.pathname), + durationMs: Date.now() - requestStartedAt, + err: error, + method: "POST", + serviceName: options.serviceName, + }, + "Connector proxy request failed unexpectedly.", + ); + + throw new CliUserError("errors.connectorProxy.requestError", 1, { + message: getUnexpectedRequestErrorMessage(error, context.translator), + }); + } + + try { + return connectorProxyResponseSchema.parse( + JSON.parse(rawResponse) as unknown, + ); + } + catch { + throw new CliUserError("errors.connectorProxy.invalidResponse", 1); + } +} + function createConnectorActionRequestUrl( endpoint: string, serviceName: string, actionName: string, - identity?: ConnectorIdentity, ): URL { const qualifiedActionName = `${encodeURIComponent(serviceName)}.${encodeURIComponent(actionName)}`; - const requestUrl = new URL( + return new URL( `https://connector.${endpoint}/v1/actions/${qualifiedActionName}`, ); +} - applyConnectorIdentityToUrl(requestUrl, identity); +function createConnectorProxyRequestUrl( + endpoint: string, + serviceName: string, +): URL { + return new URL( + `https://connector.${endpoint}/v1/proxy/${encodeURIComponent(serviceName)}`, + ); +} + +function connectorProxySelectorHeaders(options: { + appId?: string; + alias?: string; +}): Record { + const headers: Record = {}; + + if (options.appId !== undefined) { + headers["X-Oomol-Connector-App-Id"] = options.appId; + } + + if (options.alias !== undefined) { + headers["X-Oomol-Connector-Alias"] = options.alias; + } - return requestUrl; + return headers; } function parseConnectorFailureResponse( @@ -560,6 +715,53 @@ function sanitizeConnectorFailureMessage( return `${singleLineMessage.slice(0, maxLength)}...`; } +function createConnectorProxyRequestFailedError(input: { + failureResponse: ConnectorActionFailureResponse | undefined; + serviceName: string; + status: number; +}): CliUserError { + const responseMessage = input.failureResponse?.message; + const errorCode = input.failureResponse?.errorCode; + + if (isInsufficientCreditFailure({ + errorCode, + message: responseMessage, + status: input.status, + })) { + return createInsufficientCreditError(); + } + + if (responseMessage !== undefined && responseMessage !== "") { + if (errorCode !== undefined && errorCode !== "") { + return new CliUserError("errors.connectorProxy.requestFailedWithMessageAndCode", 1, { + errorCode, + message: responseMessage, + service: input.serviceName, + status: input.status, + }); + } + + return new CliUserError("errors.connectorProxy.requestFailedWithMessage", 1, { + message: responseMessage, + service: input.serviceName, + status: input.status, + }); + } + + if (errorCode !== undefined && errorCode !== "") { + return new CliUserError("errors.connectorProxy.requestFailedWithCode", 1, { + errorCode, + service: input.serviceName, + status: input.status, + }); + } + + return new CliUserError("errors.connectorProxy.requestFailed", 1, { + service: input.serviceName, + status: input.status, + }); +} + function createConnectorRunRequestFailedError(options: { actionName: string; failureResponse: ConnectorActionFailureResponse | undefined; diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 2444e499..7a8fee70 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -146,6 +146,19 @@ const commandTelemetryDecisions = { ], reason: "Records connector product dimensions, bucketed payload size, async wait modes, stable error code, and identity source (personal/flag/config) without the organization name.", }, + "connector.proxy": { + kind: "properties", + properties: [ + "data_size_bucket", + "has_alias", + "has_app_id", + "has_body", + "identity_source", + "method", + "service", + ], + reason: "Records connector proxy product dimensions, bucketed payload size, selector presence, method enum, and identity source without endpoint, headers, body, organization name, app id, or alias.", + }, "connector.search": { kind: "properties", properties: [ diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 7fa57681..4aad3b67 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -51,6 +51,9 @@ export const enMessages = { "commands.connector.run.description": "Validate input data and run one connector action.", "commands.connector.run.summary": "Run a connector action", + "commands.connector.proxy.description": + "Proxy a provider API request through a connected connector app.", + "commands.connector.proxy.summary": "Proxy a connector API request", "commands.completion.description": "Output a shell completion script for a supported shell.", "commands.completion.summary": "Generate shell completion scripts", @@ -332,6 +335,44 @@ export const enMessages = { "The result action {action} configured for --wait-result must declare an async result lifecycle.", "errors.connectorRun.waitResultUnsupported": "The --wait-result option is only supported for connector actions with an async submit lifecycle.", + "errors.connectorProxy.aliasEmpty": + "The --alias value cannot be empty.", + "errors.connectorProxy.appIdEmpty": + "The --app-id value cannot be empty.", + "errors.connectorProxy.dataConflict": + "Use either --data or the split proxy request options, not both.", + "errors.connectorProxy.dataFilePathRequired": + "The @data file path cannot be empty.", + "errors.connectorProxy.dataReadFailed": + "Failed to read proxy request data from {path}: {message}", + "errors.connectorProxy.endpointRequired": + "The --endpoint option is required when --data is omitted.", + "errors.connectorProxy.invalidBodyJson": + "The --body value is not valid JSON: {message}", + "errors.connectorProxy.invalidDataJson": + "The --data value is not valid JSON: {message}", + "errors.connectorProxy.invalidHeadersJson": + "The --headers value is not valid JSON: {message}", + "errors.connectorProxy.invalidPayload": + "The connector proxy request payload is invalid: {message}", + "errors.connectorProxy.invalidQueryJson": + "The --query value is not valid JSON: {message}", + "errors.connectorProxy.invalidResponse": + "The connector proxy response body is unsupported.", + "errors.connectorProxy.methodRequired": + "The --method option is required when --data is omitted.", + "errors.connectorProxy.requestError": + "The connector proxy request failed: {message}", + "errors.connectorProxy.requestFailed": + "Connector proxy service {service} returned HTTP {status}.", + "errors.connectorProxy.requestFailedWithCode": + "Connector proxy service {service} returned HTTP {status} (errorCode: {errorCode}).", + "errors.connectorProxy.requestFailedWithMessage": + "Connector proxy service {service} returned HTTP {status}: {message}", + "errors.connectorProxy.requestFailedWithMessageAndCode": + "Connector proxy service {service} returned HTTP {status} (errorCode: {errorCode}): {message}", + "errors.connectorProxy.selectorConflict": + "Use either --app-id or --alias, not both.", "errors.connectorSchema.readFailed": "Failed to read the connector action schema cache at {path}: {message}", "errors.connectorSchema.writeFailed": @@ -779,6 +820,22 @@ export const enMessages = { "Run the action under the given organization identity (alias: --org)", "options.connectorRunPersonal": "Run the action under your personal identity, ignoring any configured default organization", + "options.connectorProxyAlias": + "Run the proxy request with the connector app alias", + "options.connectorProxyAppId": + "Run the proxy request with the connector app id", + "options.connectorProxyBody": + "Specify the upstream request body as JSON", + "options.connectorProxyData": + "Provide the full proxy request as JSON or @path to a JSON file", + "options.connectorProxyEndpoint": + "Specify the upstream endpoint path or allowed absolute HTTPS URL", + "options.connectorProxyHeaders": + "Specify non-authentication upstream headers as a JSON object", + "options.connectorProxyMethod": + "Specify the upstream HTTP method", + "options.connectorProxyQuery": + "Specify upstream query parameters as a JSON object", "options.debug": "Print the current log file path when the CLI exits", "options.description": "Set the required generated skill description", "options.days": @@ -1039,6 +1096,7 @@ export const enMessages = { "connector.run.text.dryRunPassed": "Validation passed.", "connector.run.text.executionId": "Execution ID", "connector.run.text.resultData": "Result data", + "connector.proxy.text.status": "Status", "connector.run.progress.completed": "Completed {action} (polls: {pollCount})", "connector.run.progress.polling": @@ -1144,6 +1202,10 @@ export const zhMessages = { "校验输入数据,并运行一个 connector action。", "commands.connector.run.summary": "运行 connector action", + "commands.connector.proxy.description": + "通过已连接的 connector app 代理 provider API 请求。", + "commands.connector.proxy.summary": + "代理 connector API 请求", "commands.completion.description": "输出受支持 shell 的补全脚本。", "commands.completion.summary": "生成 shell 补全脚本", "commands.config.description": "读取并更新持久化的用户配置。", @@ -1398,6 +1460,44 @@ export const zhMessages = { "--wait-result 配置的结果 action {action} 必须声明异步结果 lifecycle。", "errors.connectorRun.waitResultUnsupported": "--wait-result 选项仅支持带有异步 submit lifecycle 的 connector action。", + "errors.connectorProxy.aliasEmpty": + "--alias 的值不能为空。", + "errors.connectorProxy.appIdEmpty": + "--app-id 的值不能为空。", + "errors.connectorProxy.dataConflict": + "--data 和拆分的 proxy request 选项只能使用其中一种。", + "errors.connectorProxy.dataFilePathRequired": + "@data 文件路径不能为空。", + "errors.connectorProxy.dataReadFailed": + "读取 proxy request 数据 {path} 失败:{message}", + "errors.connectorProxy.endpointRequired": + "省略 --data 时必须传入 --endpoint。", + "errors.connectorProxy.invalidBodyJson": + "--body 的值不是有效 JSON:{message}", + "errors.connectorProxy.invalidDataJson": + "--data 的值不是有效 JSON:{message}", + "errors.connectorProxy.invalidHeadersJson": + "--headers 的值不是有效 JSON:{message}", + "errors.connectorProxy.invalidPayload": + "Connector proxy request payload 无效:{message}", + "errors.connectorProxy.invalidQueryJson": + "--query 的值不是有效 JSON:{message}", + "errors.connectorProxy.invalidResponse": + "Connector proxy 响应内容不受支持。", + "errors.connectorProxy.methodRequired": + "省略 --data 时必须传入 --method。", + "errors.connectorProxy.requestError": + "Connector proxy 请求失败:{message}", + "errors.connectorProxy.requestFailed": + "Connector proxy service {service} 返回了 HTTP {status}。", + "errors.connectorProxy.requestFailedWithCode": + "Connector proxy service {service} 返回了 HTTP {status}(errorCode: {errorCode})。", + "errors.connectorProxy.requestFailedWithMessage": + "Connector proxy service {service} 返回了 HTTP {status}:{message}", + "errors.connectorProxy.requestFailedWithMessageAndCode": + "Connector proxy service {service} 返回了 HTTP {status}(errorCode: {errorCode}):{message}", + "errors.connectorProxy.selectorConflict": + "--app-id 和 --alias 只能使用其中一个。", "errors.connectorSchema.readFailed": "读取 {path} 的 connector action schema cache 失败:{message}", "errors.connectorSchema.writeFailed": @@ -1840,6 +1940,22 @@ export const zhMessages = { "以指定组织身份运行该 action(别名:--org)", "options.connectorRunPersonal": "以个人身份运行该 action,忽略已配置的默认组织", + "options.connectorProxyAlias": + "使用 connector app alias 运行 proxy 请求", + "options.connectorProxyAppId": + "使用 connector app id 运行 proxy 请求", + "options.connectorProxyBody": + "以 JSON 指定上游请求体", + "options.connectorProxyData": + "提供完整 proxy request JSON,或使用 @路径读取 JSON 文件", + "options.connectorProxyEndpoint": + "指定上游 endpoint path 或允许的绝对 HTTPS URL", + "options.connectorProxyHeaders": + "以 JSON object 指定非认证上游请求头", + "options.connectorProxyMethod": + "指定上游 HTTP method", + "options.connectorProxyQuery": + "以 JSON object 指定上游 query 参数", "options.debug": "在 CLI 退出时打印当前日志文件路径", "options.description": "设置必填的生成 skill 描述", "options.days": "设置私有包临时分享天数(默认 7,最长 7)", @@ -2096,6 +2212,7 @@ export const zhMessages = { "connector.run.text.dryRunPassed": "校验通过。", "connector.run.text.executionId": "执行 ID", "connector.run.text.resultData": "结果数据", + "connector.proxy.text.status": "状态", "connector.run.progress.completed": "{action} 已完成(轮询次数:{pollCount})", "connector.run.progress.polling": From 866d91b86862101ec859ddd31f43e500def07f73 Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 10 Jun 2026 12:54:27 +0800 Subject: [PATCH 2/5] fix(connector): address proxy review comments --- .../commands/connector/index.cli.test.ts | 1 - src/application/commands/connector/proxy.ts | 1 - .../commands/connector/shared.test.ts | 117 ++++++++++++++++++ .../commands/telemetry-decisions.test.ts | 3 +- 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 1687f936..9f3ec483 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -630,7 +630,6 @@ describe("connectorCommand CLI", () => { has_body: true, identity_source: "flag", method: "POST", - service: "tavily", }, }); expect(telemetryPayload?.properties).not.toHaveProperty("organization"); diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts index ca0f2796..09535a9d 100644 --- a/src/application/commands/connector/proxy.ts +++ b/src/application/commands/connector/proxy.ts @@ -182,7 +182,6 @@ export const connectorProxyCommand: CliCommandDefinition = has_body: hasProxyBody(proxyRequest), identity_source: identitySource, method: readProxyMethod(proxyRequest), - service: input.serviceName, }); const response = await runConnectorProxy( diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 0c34680f..418e74b7 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -371,6 +371,123 @@ describe("connector shared requests", () => { }); }); + test("runConnectorProxy maps insufficient credit responses to the billing error", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + errorCode: insufficientCreditErrorCode, + message: "insufficient credit", + success: false, + }), { + status: 402, + }), + }), + )); + + expect(error.key).toBe("errors.billing.insufficientCredit"); + expect(error.params).toEqual({ + url: billingTokenRechargeUrl, + }); + }); + + test("runConnectorProxy surfaces message and errorCode on failed proxy responses", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + errorCode: "invalid_input", + message: "bad query", + success: false, + }), { + status: 400, + }), + }), + )); + + expect(error.key).toBe("errors.connectorProxy.requestFailedWithMessageAndCode"); + expect(error.params).toEqual({ + errorCode: "invalid_input", + message: "bad query", + service: "tavily", + status: 400, + }); + }); + + test("runConnectorProxy surfaces message when the failure response omits an errorCode", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + message: "proxy disabled", + success: false, + }), { + status: 403, + }), + }), + )); + + expect(error.key).toBe("errors.connectorProxy.requestFailedWithMessage"); + expect(error.params).toEqual({ + message: "proxy disabled", + service: "tavily", + status: 403, + }); + }); + + test("runConnectorProxy surfaces errorCode when the failure response omits a message", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + errorCode: "invalid_input", + success: false, + }), { + status: 400, + }), + }), + )); + + expect(error.key).toBe("errors.connectorProxy.requestFailedWithCode"); + expect(error.params).toEqual({ + errorCode: "invalid_input", + service: "tavily", + status: 400, + }); + }); + test("runConnectorAction surfaces errorCode when the failure response omits a message", async () => { const error = await expectCliUserError(runConnectorAction( { diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 7a8fee70..c57f3bd0 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -155,9 +155,8 @@ const commandTelemetryDecisions = { "has_body", "identity_source", "method", - "service", ], - reason: "Records connector proxy product dimensions, bucketed payload size, selector presence, method enum, and identity source without endpoint, headers, body, organization name, app id, or alias.", + reason: "Records connector proxy bucketed payload size, selector presence, method enum, and identity source without service name, endpoint, headers, body, organization name, app id, or alias.", }, "connector.search": { kind: "properties", From 94295a13f489871c3863e9c590d833bee966e1a6 Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 10 Jun 2026 13:48:58 +0800 Subject: [PATCH 3/5] fix(connector): address proxy review followups --- docs/commands.md | 6 +- docs/commands.zh-CN.md | 6 +- .../commands/connector/index.cli.test.ts | 258 ++++++++++++++++++ src/application/commands/connector/proxy.ts | 57 ++-- src/application/commands/connector/run.ts | 28 +- .../commands/connector/shared.test.ts | 116 ++++++++ src/application/commands/connector/shared.ts | 4 +- .../commands/connector/telemetry.ts | 28 ++ .../commands/telemetry-decisions.test.ts | 4 +- src/i18n/catalog.ts | 8 + 10 files changed, 465 insertions(+), 50 deletions(-) create mode 100644 src/application/commands/connector/telemetry.ts diff --git a/docs/commands.md b/docs/commands.md index a87564e8..49e34a80 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -617,9 +617,11 @@ Proxy a provider API request through a connected connector app. - Options: `-d, --data ` accepts a complete proxy request JSON object or `@path` to a JSON file. The object shape is `{ endpoint, method, query?, headers?, body? }`. +- Options: `--input ` is an alias for `--data `. - Options: without `--data`, use `--endpoint ` and `--method ` plus optional `--query `, `--headers `, and - `--body ` to build the same request object. + `--body ` to build the same request object. The `--data` form cannot + be combined with these split request options. - Options: `--endpoint` is a provider endpoint path relative to the provider proxy base URL, or an allowed absolute HTTPS URL. - Options: `--method` must be one of `GET`, `POST`, `PUT`, `PATCH`, or @@ -645,6 +647,8 @@ Proxy a provider API request through a connected connector app. - Options: `--format=json` and `--json` print a JSON object. - Output: JSON output keeps the stable shape `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`. +- Errors: stderr prints the connector proxy HTTP status and includes the server + `message` and `errorCode` when the failure response provides them. - Notes: `oo connector proxy` does not use connector action schemas or schema cache. Use it when the selected connector supports proxy execution and no purpose-built connector action is available. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 505d8ebf..42dfeedc 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -531,9 +531,11 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 选项:`-d, --data ` 接收完整 proxy request JSON object,或使用 `@路径` 读取 JSON 文件。对象形状为 `{ endpoint, method, query?, headers?, body? }`。 +- 选项:`--input ` 是 `--data ` 的 alias。 - 选项:未传 `--data` 时,使用 `--endpoint ` 和 `--method `,以及可选的 `--query `、`--headers `、 - `--body ` 组装同样的 request object。 + `--body ` 组装同样的 request object。`--data` 形式不能与这些拆分 + request 选项同时使用。 - 选项:`--endpoint` 是相对于 provider proxy base URL 的 provider endpoint path,或允许的绝对 HTTPS URL。 - 选项:`--method` 必须是 `GET`、`POST`、`PUT`、`PATCH` 或 `DELETE`。 @@ -554,6 +556,8 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- - 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 - 输出:JSON 输出保持稳定结构 `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`。 +- 错误:stderr 会打印 connector proxy HTTP 状态;如果失败响应提供了 + `message` 和 `errorCode`,也会一并包含。 - 说明:`oo connector proxy` 不使用 connector action schema 或 schema cache。 当选中的 connector 支持 proxy execution 且没有专用 connector action 时使用。 diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 9f3ec483..512c93ff 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -522,6 +522,8 @@ describe("connectorCommand CLI", () => { expect(help).toContain( "Proxy a provider API request through a connected connector app", ); + expect(help).toContain("Run the proxy request under"); + expect(help).not.toContain("Run the action under"); } finally { await sandbox.cleanup(); @@ -625,6 +627,7 @@ describe("connectorCommand CLI", () => { expect(telemetryPayload).toMatchObject({ properties: { command_full: "connector.proxy", + data_size_bucket: "<1KB", has_alias: true, has_app_id: false, has_body: true, @@ -632,7 +635,262 @@ describe("connectorCommand CLI", () => { method: "POST", }, }); + expect(telemetryPayload?.properties).not.toHaveProperty("alias"); + expect(telemetryPayload?.properties).not.toHaveProperty("app_id"); + expect(telemetryPayload?.properties).not.toHaveProperty("body"); + expect(telemetryPayload?.properties).not.toHaveProperty("endpoint"); + expect(telemetryPayload?.properties).not.toHaveProperty("headers"); expect(telemetryPayload?.properties).not.toHaveProperty("organization"); + expect(telemetryPayload?.properties).not.toHaveProperty("service"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("supports connector proxy with data file input and text output", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + await Bun.write( + join(sandbox.cwd, "proxy-request.json"), + JSON.stringify({ + endpoint: "/empty", + method: "GET", + }), + ); + + const requests: Request[] = []; + const result = await sandbox.run( + [ + "connector", + "proxy", + "tavily", + "--data", + "@proxy-request.json", + ], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: { + status: 204, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + })); + }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Status: 204"); + expect(result.stdout).toContain("Execution ID: exec-1"); + expect(result.stdout).toContain("Result data:"); + expect(result.stdout).toContain("null"); + expect(requests).toHaveLength(1); + await expect(requests[0]?.json()).resolves.toEqual({ + endpoint: "/empty", + method: "GET", + }); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects invalid connector proxy method values before login", async () => { + const sandbox = await createCliSandbox(); + + try { + const result = await sandbox.run([ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "get", + ]); + + expect(result.exitCode).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "The connector proxy request payload is invalid:", + ); + expect(result.stderr).toContain("method:"); + expect(result.stderr).toContain("expected one of"); + expect(result.stderr).not.toContain("You must log in"); + expect(result.stderr).not.toContain("Invalid format"); + } + finally { + await sandbox.cleanup(); + } + }); + + test("rejects connector proxy request usage errors before login", async () => { + const sandbox = await createCliSandbox(); + + try { + const cases = [ + { + argv: ["connector", "proxy", "tavily"], + message: "The --endpoint option is required when --data is omitted.", + }, + { + argv: ["connector", "proxy", "tavily", "--endpoint", "/search"], + message: "The --method option is required when --data is omitted.", + }, + { + argv: [ + "connector", + "proxy", + "tavily", + "--data", + "{}", + "--endpoint", + "/search", + "--method", + "GET", + ], + message: + "Use either --data or the split proxy request options, not both.", + }, + { + argv: [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "GET", + "--query", + "{", + ], + message: "The --query value is not valid JSON:", + }, + { + argv: [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "GET", + "--headers", + "{", + ], + message: "The --headers value is not valid JSON:", + }, + { + argv: [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "GET", + "--body", + "{", + ], + message: "The --body value is not valid JSON:", + }, + { + argv: ["connector", "proxy", "tavily", "--data", "{"], + message: "The --data value is not valid JSON:", + }, + { + argv: ["connector", "proxy", "tavily", "--data", "@"], + message: "The @data file path cannot be empty.", + }, + { + argv: ["connector", "proxy", "tavily", "--data", "@missing.json"], + exitCode: 1, + message: "Failed to read proxy request data from", + }, + { + argv: ["connector", "proxy", "tavily", "--data", ""], + message: "endpoint:", + }, + ]; + + for (const testCase of cases) { + const result = await sandbox.run(testCase.argv); + + expect(result.exitCode).toBe(testCase.exitCode ?? 2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(testCase.message); + expect(result.stderr).not.toContain("You must log in"); + } + } + finally { + await sandbox.cleanup(); + } + }); + + test("records connector proxy failure telemetry without raw request values", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const result = await sandbox.run( + [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "GET", + "--alias", + "primary", + "--json", + ], + { + fetcher: async () => new Response(JSON.stringify({ + errorCode: "invalid_input", + message: "bad query", + success: false, + }), { + status: 400, + }), + }, + ); + const telemetryPayload = parseTelemetryRowPayload( + readTelemetryRowsForTest( + join(sandbox.env.XDG_CONFIG_HOME!, APP_NAME, "telemetry"), + )[0]!, + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + "Connector proxy service tavily returned HTTP 400", + ); + expect(telemetryPayload).toMatchObject({ + properties: { + command_full: "connector.proxy", + data_size_bucket: "<1KB", + error_code: "invalid_input", + has_alias: true, + has_app_id: false, + has_body: false, + http_status: 400, + identity_source: "personal", + method: "GET", + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("alias"); + expect(telemetryPayload?.properties).not.toHaveProperty("endpoint"); + expect(telemetryPayload?.properties).not.toHaveProperty("service"); } finally { await sandbox.cleanup(); diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts index 09535a9d..9129a851 100644 --- a/src/application/commands/connector/proxy.ts +++ b/src/application/commands/connector/proxy.ts @@ -15,6 +15,7 @@ import { connectorFormatValues, runConnectorProxy, } from "./shared.ts"; +import { recordConnectorFailureTelemetry } from "./telemetry.ts"; const connectorProxyDataErrorKeys = { dataFilePathRequired: "errors.connectorProxy.dataFilePathRequired", @@ -47,7 +48,7 @@ interface ConnectorProxyInput { endpoint?: string; format?: (typeof connectorFormatValues)[number]; headers?: string; - method?: (typeof connectorProxyMethods)[number]; + method?: string; organization?: string; personal?: boolean; query?: string; @@ -123,12 +124,12 @@ export const connectorProxyCommand: CliCommandDefinition = longFlag: "--organization", aliasFlags: ["--org"], valueName: "organization", - descriptionKey: "options.connectorRunOrganization", + descriptionKey: "options.connectorProxyOrganization", }, { name: "personal", longFlag: "--personal", - descriptionKey: "options.connectorRunPersonal", + descriptionKey: "options.connectorProxyPersonal", }, ...jsonOutputOptions, ], @@ -140,7 +141,7 @@ export const connectorProxyCommand: CliCommandDefinition = endpoint: z.string().optional(), format: z.enum(connectorFormatValues).optional(), headers: z.string().optional(), - method: z.enum(connectorProxyMethods).optional(), + method: z.string().optional(), organization: z.string().optional(), personal: z.boolean().optional(), query: z.string().optional(), @@ -164,6 +165,7 @@ export const connectorProxyCommand: CliCommandDefinition = const appId = trimOptionalSelector(input.appId, "errors.connectorProxy.appIdEmpty"); const alias = trimOptionalSelector(input.alias, "errors.connectorProxy.aliasEmpty"); + const proxyRequest = await buildConnectorProxyRequest(input, context); const account = await requireCurrentAccount(context); const settings = await context.settingsStore.read(); const { identity, source: identitySource } = resolveConnectorIdentity({ @@ -171,7 +173,6 @@ export const connectorProxyCommand: CliCommandDefinition = organizationFlag, personalFlag: input.personal === true, }); - const proxyRequest = await buildConnectorProxyRequest(input, context); context.telemetry?.recordProperties({ data_size_bucket: bucketTelemetryBytes( @@ -184,18 +185,25 @@ export const connectorProxyCommand: CliCommandDefinition = method: readProxyMethod(proxyRequest), }); - const response = await runConnectorProxy( - { - alias, - apiKey: account.apiKey, - appId, - endpoint: account.endpoint, - identity, - proxyRequest, - serviceName: input.serviceName, - }, - context, - ); + let response: ConnectorProxyResponse; + try { + response = await runConnectorProxy( + { + alias, + apiKey: account.apiKey, + appId, + endpoint: account.endpoint, + identity, + proxyRequest, + serviceName: input.serviceName, + }, + context, + ); + } + catch (error) { + recordConnectorFailureTelemetry(error, context.telemetry); + throw error; + } if (input.format === "json") { writeJsonOutput(context.stdout, response, { @@ -262,14 +270,25 @@ function parseProxyRequest(value: unknown): unknown { const parsed = proxyRequestSchema.safeParse(value); if (!parsed.success) { + const issue = parsed.error.issues[0]; throw new CliUserError("errors.connectorProxy.invalidPayload", 2, { - message: parsed.error.issues[0]?.message ?? "Invalid proxy request.", + message: issue !== undefined + ? formatProxyRequestIssue(issue) + : "Invalid proxy request.", }); } return parsed.data; } +function formatProxyRequestIssue(issue: z.core.$ZodIssue): string { + if (issue.path.length === 0) { + return issue.message; + } + + return `${issue.path.map(String).join(".")}: ${issue.message}`; +} + function parseJsonOption(value: string, errorKey: string): unknown { try { return JSON.parse(value) as unknown; @@ -322,6 +341,6 @@ function formatConnectorProxyResponseAsText( `${context.translator.t("connector.proxy.text.status")}: ${response.data.status}`, `${context.translator.t("connector.run.text.executionId")}: ${response.meta.executionId}`, `${context.translator.t("connector.run.text.resultData")}:`, - JSON.stringify(response.data.data, null, 2), + JSON.stringify(response.data.data, null, 2) ?? "null", ].join("\n"); } diff --git a/src/application/commands/connector/run.ts b/src/application/commands/connector/run.ts index b568d52f..7c48081d 100644 --- a/src/application/commands/connector/run.ts +++ b/src/application/commands/connector/run.ts @@ -27,6 +27,7 @@ import { requireConnectorActionName, runConnectorAction, } from "./shared.ts"; +import { recordConnectorFailureTelemetry } from "./telemetry.ts"; import { validateConnectorActionInput } from "./validation.ts"; const connectorRunExecutionIdColor = "#59F78D"; @@ -273,7 +274,7 @@ export const connectorRunCommand: CliCommandDefinition = { } catch (error) { progressReporter?.abort(); - recordConnectorRunFailureTelemetry(error, context.telemetry); + recordConnectorFailureTelemetry(error, context.telemetry); if (isConnectorActionSchemaNotFoundError(error)) { deleteConnectorActionSchemaCache( { @@ -662,28 +663,3 @@ function formatConnectorRunResultData( ): string { return colors.cyan(JSON.stringify(value, null, 2) ?? "null"); } - -function recordConnectorRunFailureTelemetry( - error: unknown, - telemetry: CliExecutionContext["telemetry"], -): void { - if (!(error instanceof CliUserError)) { - return; - } - - const status = error.params?.status; - const errorCode = error.params?.errorCode; - const properties: { error_code?: string; http_status?: number } = {}; - - if (typeof status === "number") { - properties.http_status = status; - } - - if (typeof errorCode === "string" && errorCode !== "") { - properties.error_code = errorCode; - } - - if (Object.keys(properties).length > 0) { - telemetry?.recordProperties(properties); - } -} diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 418e74b7..48f1d091 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -371,6 +371,43 @@ describe("connector shared requests", () => { }); }); + test("runConnectorProxy accepts proxy responses without headers or data fields", async () => { + const response = await runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/empty", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + data: { + status: 204, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + })), + }), + ); + + expect(response).toEqual({ + data: { + data: null, + headers: {}, + status: 204, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + }); + }); + test("runConnectorProxy maps insufficient credit responses to the billing error", async () => { const error = await expectCliUserError(runConnectorProxy( { @@ -488,6 +525,85 @@ describe("connector shared requests", () => { }); }); + test("runConnectorProxy surfaces status when the failure response has no message or errorCode", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + success: false, + }), { + status: 500, + }), + }), + )); + + expect(error.key).toBe("errors.connectorProxy.requestFailed"); + expect(error.params).toEqual({ + service: "tavily", + status: 500, + }); + }); + + test("runConnectorProxy rejects unsupported success response envelopes", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => new Response(JSON.stringify({ + data: { + headers: {}, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + })), + }), + )); + + expect(error.key).toBe("errors.connectorProxy.invalidResponse"); + }); + + test("runConnectorProxy appends the sandbox hint when the fetcher cannot open a socket", async () => { + const error = await expectCliUserError(runConnectorProxy( + { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + }, + createRequestContext({ + fetcher: async () => { + throw createFailedToOpenSocketError("network down"); + }, + }), + )); + + expect(error.key).toBe("errors.connectorProxy.requestError"); + expect(error.params).toEqual({ + message: + "network down\nCurrent environment may be running in a network-restricted sandbox. Try requesting elevated permissions.", + }); + }); + test("runConnectorAction surfaces errorCode when the failure response omits a message", async () => { const error = await expectCliUserError(runConnectorAction( { diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index a203806d..c91babf0 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -85,8 +85,8 @@ const connectorActionRunResponseSchema = z.object({ const connectorProxyResponseSchema = z.object({ data: z.object({ - data: z.unknown(), - headers: z.record(z.string(), z.string()), + data: z.unknown().optional().default(null), + headers: z.record(z.string(), z.unknown()).optional().default({}), status: z.number().int(), }), meta: z.object({ diff --git a/src/application/commands/connector/telemetry.ts b/src/application/commands/connector/telemetry.ts new file mode 100644 index 00000000..b3a09bd7 --- /dev/null +++ b/src/application/commands/connector/telemetry.ts @@ -0,0 +1,28 @@ +import type { CliExecutionContext } from "../../contracts/cli.ts"; + +import { CliUserError } from "../../contracts/cli.ts"; + +export function recordConnectorFailureTelemetry( + error: unknown, + telemetry: CliExecutionContext["telemetry"], +): void { + if (!(error instanceof CliUserError)) { + return; + } + + const status = error.params?.status; + const errorCode = error.params?.errorCode; + const properties: { error_code?: string; http_status?: number } = {}; + + if (typeof status === "number") { + properties.http_status = status; + } + + if (typeof errorCode === "string" && errorCode !== "") { + properties.error_code = errorCode; + } + + if (Object.keys(properties).length > 0) { + telemetry?.recordProperties(properties); + } +} diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index c57f3bd0..7d617161 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -150,13 +150,15 @@ const commandTelemetryDecisions = { kind: "properties", properties: [ "data_size_bucket", + "error_code", "has_alias", "has_app_id", "has_body", + "http_status", "identity_source", "method", ], - reason: "Records connector proxy bucketed payload size, selector presence, method enum, and identity source without service name, endpoint, headers, body, organization name, app id, or alias.", + reason: "Records connector proxy bucketed payload size, selector presence, method enum, identity source, stable error code, and HTTP status without service name, endpoint, headers, body, organization name, app id, or alias.", }, "connector.search": { kind: "properties", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 4aad3b67..62e58232 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -834,6 +834,10 @@ export const enMessages = { "Specify non-authentication upstream headers as a JSON object", "options.connectorProxyMethod": "Specify the upstream HTTP method", + "options.connectorProxyOrganization": + "Run the proxy request under the given organization identity (alias: --org)", + "options.connectorProxyPersonal": + "Run the proxy request under your personal identity, ignoring any configured default organization", "options.connectorProxyQuery": "Specify upstream query parameters as a JSON object", "options.debug": "Print the current log file path when the CLI exits", @@ -1954,6 +1958,10 @@ export const zhMessages = { "以 JSON object 指定非认证上游请求头", "options.connectorProxyMethod": "指定上游 HTTP method", + "options.connectorProxyOrganization": + "以指定组织身份运行该 proxy 请求(别名:--org)", + "options.connectorProxyPersonal": + "以个人身份运行该 proxy 请求,忽略已配置的默认组织", "options.connectorProxyQuery": "以 JSON object 指定上游 query 参数", "options.debug": "在 CLI 退出时打印当前日志文件路径", From 1172a099765e0cd680746ed840dce3ee7e81e21a Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 10 Jun 2026 14:00:12 +0800 Subject: [PATCH 4/5] fix(connector): handle proxy method review --- docs/commands.md | 2 +- docs/commands.zh-CN.md | 2 +- .../commands/connector/index.cli.test.ts | 52 ++++- src/application/commands/connector/proxy.ts | 7 +- .../commands/connector/shared.test.ts | 210 +++++++----------- 5 files changed, 136 insertions(+), 137 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 49e34a80..149e96c4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -625,7 +625,7 @@ Proxy a provider API request through a connected connector app. - Options: `--endpoint` is a provider endpoint path relative to the provider proxy base URL, or an allowed absolute HTTPS URL. - Options: `--method` must be one of `GET`, `POST`, `PUT`, `PATCH`, or - `DELETE`. + `DELETE`. Values are case-insensitive. - Options: `--query` must be a JSON object whose values are strings, numbers, booleans, or `null`. - Options: `--headers` must be a JSON object with string values. diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 42dfeedc..81d3fbef 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -538,7 +538,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- request 选项同时使用。 - 选项:`--endpoint` 是相对于 provider proxy base URL 的 provider endpoint path,或允许的绝对 HTTPS URL。 -- 选项:`--method` 必须是 `GET`、`POST`、`PUT`、`PATCH` 或 `DELETE`。 +- 选项:`--method` 必须是 `GET`、`POST`、`PUT`、`PATCH` 或 `DELETE`,大小写不敏感。 - 选项:`--query` 必须是 JSON object,值只能是 string、number、boolean 或 `null`。 - 选项:`--headers` 必须是 string 值的 JSON object。认证 header 会由 diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 512c93ff..8fc04eb1 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -704,6 +704,56 @@ describe("connectorCommand CLI", () => { } }); + test("normalizes connector proxy method values case-insensitively", async () => { + const sandbox = await createCliSandbox(); + + try { + await writeAuthFile(sandbox); + + const requests: Request[] = []; + const result = await sandbox.run( + [ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "get", + "--json", + ], + { + fetcher: async (input, init) => { + requests.push(toRequest(input, init)); + + return new Response(JSON.stringify({ + data: { + data: null, + headers: {}, + status: 200, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + })); + }, + }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(requests).toHaveLength(1); + await expect(requests[0]?.json()).resolves.toEqual({ + endpoint: "/search", + method: "GET", + }); + } + finally { + await sandbox.cleanup(); + } + }); + test("rejects invalid connector proxy method values before login", async () => { const sandbox = await createCliSandbox(); @@ -715,7 +765,7 @@ describe("connectorCommand CLI", () => { "--endpoint", "/search", "--method", - "get", + "TRACE", ]); expect(result.exitCode).toBe(2); diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts index 9129a851..d77ebb15 100644 --- a/src/application/commands/connector/proxy.ts +++ b/src/application/commands/connector/proxy.ts @@ -25,6 +25,11 @@ const connectorProxyDataErrorKeys = { const connectorProxyMethods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const; +const connectorProxyMethodSchema = z.string() + .trim() + .transform(value => value.toUpperCase()) + .pipe(z.enum(connectorProxyMethods)); + const proxyQueryValueSchema = z.union([ z.string(), z.number(), @@ -36,7 +41,7 @@ const proxyRequestSchema = z.object({ body: z.unknown().optional(), endpoint: z.string().trim().min(1), headers: z.record(z.string(), z.string()).optional(), - method: z.enum(connectorProxyMethods), + method: connectorProxyMethodSchema, query: z.record(z.string(), proxyQueryValueSchema).optional(), }).strict(); diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 48f1d091..5b95b43f 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -373,26 +373,21 @@ describe("connector shared requests", () => { test("runConnectorProxy accepts proxy responses without headers or data fields", async () => { const response = await runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", + createProxyRunInput({ proxyRequest: { endpoint: "/empty", method: "GET", }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - data: { - status: 204, - }, - meta: { - executionId: "exec-1", - service: "tavily", - }, - })), }), + createProxyRequestContext(async () => new Response(JSON.stringify({ + data: { + status: 204, + }, + meta: { + executionId: "exec-1", + service: "tavily", + }, + }))), ); expect(response).toEqual({ @@ -410,24 +405,14 @@ describe("connector shared requests", () => { test("runConnectorProxy maps insufficient credit responses to the billing error", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - errorCode: insufficientCreditErrorCode, - message: "insufficient credit", - success: false, - }), { - status: 402, - }), - }), + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + errorCode: insufficientCreditErrorCode, + message: "insufficient credit", + success: false, + }), { + status: 402, + })), )); expect(error.key).toBe("errors.billing.insufficientCredit"); @@ -438,24 +423,14 @@ describe("connector shared requests", () => { test("runConnectorProxy surfaces message and errorCode on failed proxy responses", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - errorCode: "invalid_input", - message: "bad query", - success: false, - }), { - status: 400, - }), - }), + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + errorCode: "invalid_input", + message: "bad query", + success: false, + }), { + status: 400, + })), )); expect(error.key).toBe("errors.connectorProxy.requestFailedWithMessageAndCode"); @@ -469,23 +444,13 @@ describe("connector shared requests", () => { test("runConnectorProxy surfaces message when the failure response omits an errorCode", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - message: "proxy disabled", - success: false, - }), { - status: 403, - }), - }), + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + message: "proxy disabled", + success: false, + }), { + status: 403, + })), )); expect(error.key).toBe("errors.connectorProxy.requestFailedWithMessage"); @@ -498,23 +463,13 @@ describe("connector shared requests", () => { test("runConnectorProxy surfaces errorCode when the failure response omits a message", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - errorCode: "invalid_input", - success: false, - }), { - status: 400, - }), - }), + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + errorCode: "invalid_input", + success: false, + }), { + status: 400, + })), )); expect(error.key).toBe("errors.connectorProxy.requestFailedWithCode"); @@ -527,22 +482,12 @@ describe("connector shared requests", () => { test("runConnectorProxy surfaces status when the failure response has no message or errorCode", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - success: false, - }), { - status: 500, - }), - }), + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + success: false, + }), { + status: 500, + })), )); expect(error.key).toBe("errors.connectorProxy.requestFailed"); @@ -554,26 +499,16 @@ describe("connector shared requests", () => { test("runConnectorProxy rejects unsupported success response envelopes", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", + createProxyRunInput(), + createProxyRequestContext(async () => new Response(JSON.stringify({ + data: { + headers: {}, }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => new Response(JSON.stringify({ - data: { - headers: {}, - }, - meta: { - executionId: "exec-1", - service: "tavily", - }, - })), - }), + meta: { + executionId: "exec-1", + service: "tavily", + }, + }))), )); expect(error.key).toBe("errors.connectorProxy.invalidResponse"); @@ -581,19 +516,9 @@ describe("connector shared requests", () => { test("runConnectorProxy appends the sandbox hint when the fetcher cannot open a socket", async () => { const error = await expectCliUserError(runConnectorProxy( - { - apiKey: "secret-1", - endpoint: "oomol.com", - proxyRequest: { - endpoint: "/search", - method: "GET", - }, - serviceName: "tavily", - }, - createRequestContext({ - fetcher: async () => { - throw createFailedToOpenSocketError("network down"); - }, + createProxyRunInput(), + createProxyRequestContext(async () => { + throw createFailedToOpenSocketError("network down"); }), )); @@ -696,3 +621,22 @@ function createRequestContext(options: { translator: createTranslator("en"), }; } + +function createProxyRunInput( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + apiKey: "secret-1", + endpoint: "oomol.com", + proxyRequest: { + endpoint: "/search", + method: "GET", + }, + serviceName: "tavily", + ...overrides, + }; +} + +function createProxyRequestContext(fetcher: Fetcher): ReturnType { + return createRequestContext({ fetcher }); +} From f2d0ff59f241f647d45f6f7382345ed86fb76b98 Mon Sep 17 00:00:00 2001 From: hyrious Date: Wed, 10 Jun 2026 14:10:30 +0800 Subject: [PATCH 5/5] fix(connector): defer proxy app selectors --- docs/commands.md | 4 +- docs/commands.zh-CN.md | 4 +- .../commands/connector/index.cli.test.ts | 17 -------- src/application/commands/connector/proxy.ts | 40 ------------------- .../commands/connector/shared.test.ts | 6 +-- src/application/commands/connector/shared.ts | 20 ---------- .../commands/telemetry-decisions.test.ts | 4 +- src/i18n/catalog.ts | 20 ---------- 8 files changed, 4 insertions(+), 111 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 149e96c4..421d6193 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -634,8 +634,6 @@ Proxy a provider API request through a connected connector app. options. - Options: `--body` is parsed as JSON. To send a text body, pass a JSON string such as `"hello"`. -- Options: `--app-id ` or `--alias ` selects a specific connected - connector app. They cannot be combined. - Options: `--organization ` runs the proxy request under the given organization identity instead of your personal identity. `--org ` is an alias for `--organization `. When omitted, the request runs under the @@ -646,7 +644,7 @@ Proxy a provider API request through a connected connector app. `--organization`. - Options: `--format=json` and `--json` print a JSON object. - Output: JSON output keeps the stable shape - `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`. + `{ data: { status, headers, data }, meta: { executionId, service } }`. - Errors: stderr prints the connector proxy HTTP status and includes the server `message` and `errorCode` when the failure response provides them. - Notes: `oo connector proxy` does not use connector action schemas or schema diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 81d3fbef..ba8b346a 100644 --- a/docs/commands.zh-CN.md +++ b/docs/commands.zh-CN.md @@ -546,8 +546,6 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- credential。 - 选项:`--body` 会按 JSON 解析。如需发送文本 body,请传 JSON string,例如 `"hello"`。 -- 选项:`--app-id ` 或 `--alias ` 用于选择特定已连接的 - connector app。两者不能同时使用。 - 选项:`--organization ` 以指定组织身份运行该 proxy 请求,而非个人身份。 `--org ` 是 `--organization ` 的 alias。省略时,若配置了 `identity.organization` 默认值则使用该组织,否则使用个人身份。 @@ -555,7 +553,7 @@ CLI 默认记录受隐私约束的命令使用 telemetry。事件不包含 free- 不能与 `--organization` 同时使用。 - 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 - 输出:JSON 输出保持稳定结构 - `{ data: { status, headers, data }, meta: { executionId, service, appId? } }`。 + `{ data: { status, headers, data }, meta: { executionId, service } }`。 - 错误:stderr 会打印 connector proxy HTTP 状态;如果失败响应提供了 `message` 和 `errorCode`,也会一并包含。 - 说明:`oo connector proxy` 不使用 connector action schema 或 schema cache。 diff --git a/src/application/commands/connector/index.cli.test.ts b/src/application/commands/connector/index.cli.test.ts index 8fc04eb1..a9da2b69 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -515,8 +515,6 @@ describe("connectorCommand CLI", () => { expect(result.stdout).toContain("--query"); expect(result.stdout).toContain("--headers"); expect(result.stdout).toContain("--body"); - expect(result.stdout).toContain("--app-id"); - expect(result.stdout).toContain("--alias"); expect(result.stdout).toContain("--organization"); expect(result.stdout).toContain("--personal"); expect(help).toContain( @@ -552,8 +550,6 @@ describe("connectorCommand CLI", () => { "{\"accept\":\"application/json\"}", "--body", "{\"query\":\"hello\"}", - "--alias", - "primary", "--organization", "acme", "--json", @@ -573,7 +569,6 @@ describe("connectorCommand CLI", () => { status: 200, }, meta: { - appId: "app-1", executionId: "exec-1", service: "tavily", }, @@ -600,7 +595,6 @@ describe("connectorCommand CLI", () => { status: 200, }, meta: { - appId: "app-1", executionId: "exec-1", service: "tavily", }, @@ -609,8 +603,6 @@ describe("connectorCommand CLI", () => { expect(requests[0]?.method).toBe("POST"); expect(requests[0]?.url).toBe("https://connector.oomol.com/v1/proxy/tavily"); expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); - expect(requests[0]?.headers.get("X-Oomol-Connector-Alias")).toBe("primary"); - expect(requests[0]?.headers.get("X-Oomol-Connector-App-Id")).toBeNull(); await expect(requests[0]?.json()).resolves.toEqual({ body: { query: "hello", @@ -628,15 +620,11 @@ describe("connectorCommand CLI", () => { properties: { command_full: "connector.proxy", data_size_bucket: "<1KB", - has_alias: true, - has_app_id: false, has_body: true, identity_source: "flag", method: "POST", }, }); - expect(telemetryPayload?.properties).not.toHaveProperty("alias"); - expect(telemetryPayload?.properties).not.toHaveProperty("app_id"); expect(telemetryPayload?.properties).not.toHaveProperty("body"); expect(telemetryPayload?.properties).not.toHaveProperty("endpoint"); expect(telemetryPayload?.properties).not.toHaveProperty("headers"); @@ -901,8 +889,6 @@ describe("connectorCommand CLI", () => { "/search", "--method", "GET", - "--alias", - "primary", "--json", ], { @@ -930,15 +916,12 @@ describe("connectorCommand CLI", () => { command_full: "connector.proxy", data_size_bucket: "<1KB", error_code: "invalid_input", - has_alias: true, - has_app_id: false, has_body: false, http_status: 400, identity_source: "personal", method: "GET", }, }); - expect(telemetryPayload?.properties).not.toHaveProperty("alias"); expect(telemetryPayload?.properties).not.toHaveProperty("endpoint"); expect(telemetryPayload?.properties).not.toHaveProperty("service"); } diff --git a/src/application/commands/connector/proxy.ts b/src/application/commands/connector/proxy.ts index d77ebb15..8551fdc1 100644 --- a/src/application/commands/connector/proxy.ts +++ b/src/application/commands/connector/proxy.ts @@ -46,8 +46,6 @@ const proxyRequestSchema = z.object({ }).strict(); interface ConnectorProxyInput { - alias?: string; - appId?: string; body?: string; data?: string; endpoint?: string; @@ -112,18 +110,6 @@ export const connectorProxyCommand: CliCommandDefinition = valueName: "body", descriptionKey: "options.connectorProxyBody", }, - { - name: "appId", - longFlag: "--app-id", - valueName: "appId", - descriptionKey: "options.connectorProxyAppId", - }, - { - name: "alias", - longFlag: "--alias", - valueName: "alias", - descriptionKey: "options.connectorProxyAlias", - }, { name: "organization", longFlag: "--organization", @@ -139,8 +125,6 @@ export const connectorProxyCommand: CliCommandDefinition = ...jsonOutputOptions, ], inputSchema: z.object({ - alias: z.string().optional(), - appId: z.string().optional(), body: z.string().optional(), data: z.string().optional(), endpoint: z.string().optional(), @@ -159,17 +143,11 @@ export const connectorProxyCommand: CliCommandDefinition = throw new CliUserError("errors.connectorRun.identityConflict", 2); } - if (input.appId !== undefined && input.alias !== undefined) { - throw new CliUserError("errors.connectorProxy.selectorConflict", 2); - } - const organizationFlag = input.organization?.trim(); if (input.organization !== undefined && organizationFlag === "") { throw new CliUserError("errors.connectorRun.organizationEmpty", 2); } - const appId = trimOptionalSelector(input.appId, "errors.connectorProxy.appIdEmpty"); - const alias = trimOptionalSelector(input.alias, "errors.connectorProxy.aliasEmpty"); const proxyRequest = await buildConnectorProxyRequest(input, context); const account = await requireCurrentAccount(context); const settings = await context.settingsStore.read(); @@ -183,8 +161,6 @@ export const connectorProxyCommand: CliCommandDefinition = data_size_bucket: bucketTelemetryBytes( Buffer.byteLength(JSON.stringify(proxyRequest)), ), - has_alias: alias !== undefined, - has_app_id: appId !== undefined, has_body: hasProxyBody(proxyRequest), identity_source: identitySource, method: readProxyMethod(proxyRequest), @@ -194,9 +170,7 @@ export const connectorProxyCommand: CliCommandDefinition = try { response = await runConnectorProxy( { - alias, apiKey: account.apiKey, - appId, endpoint: account.endpoint, identity, proxyRequest, @@ -305,20 +279,6 @@ function parseJsonOption(value: string, errorKey: string): unknown { } } -function trimOptionalSelector(value: string | undefined, errorKey: string): string | undefined { - if (value === undefined) { - return undefined; - } - - const trimmed = value.trim(); - - if (trimmed === "") { - throw new CliUserError(errorKey, 2); - } - - return trimmed; -} - function hasProxyBody(proxyRequest: unknown): boolean { return typeof proxyRequest === "object" && proxyRequest !== null diff --git a/src/application/commands/connector/shared.test.ts b/src/application/commands/connector/shared.test.ts index 5b95b43f..779c6ac3 100644 --- a/src/application/commands/connector/shared.test.ts +++ b/src/application/commands/connector/shared.test.ts @@ -296,11 +296,10 @@ describe("connector shared requests", () => { expect(requests[0]?.headers.get("x-oo-organization")).toBeNull(); }); - test("runConnectorProxy sends proxy requests with identity and selector headers", async () => { + test("runConnectorProxy sends proxy requests with identity headers", async () => { const requests: Request[] = []; const response = await runConnectorProxy( { - alias: "primary", apiKey: "secret-1", endpoint: "oomol.com", identity: { @@ -331,7 +330,6 @@ describe("connector shared requests", () => { }, message: "OK", meta: { - appId: "app-1", executionId: "exec-1", service: "tavily", }, @@ -352,7 +350,6 @@ describe("connector shared requests", () => { status: 200, }, meta: { - appId: "app-1", executionId: "exec-1", service: "tavily", }, @@ -361,7 +358,6 @@ describe("connector shared requests", () => { expect(requests[0]?.url).toBe("https://connector.oomol.com/v1/proxy/tavily"); expect(requests[0]?.headers.get("Authorization")).toBe("secret-1"); expect(requests[0]?.headers.get("x-oo-organization")).toBe("acme"); - expect(requests[0]?.headers.get("X-Oomol-Connector-Alias")).toBe("primary"); await expect(requests[0]?.json()).resolves.toEqual({ endpoint: "/search", method: "GET", diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index c91babf0..138d0d3d 100644 --- a/src/application/commands/connector/shared.ts +++ b/src/application/commands/connector/shared.ts @@ -432,9 +432,7 @@ export async function runConnectorAction( export async function runConnectorProxy( options: { - alias?: string; apiKey: string; - appId?: string; endpoint: string; identity?: ConnectorIdentity; proxyRequest: unknown; @@ -468,7 +466,6 @@ export async function runConnectorProxy( "Authorization": options.apiKey, "Content-Type": "application/json", ...connectorIdentityHeaders(options.identity), - ...connectorProxySelectorHeaders(options), }, method: "POST", }); @@ -572,23 +569,6 @@ function createConnectorProxyRequestUrl( ); } -function connectorProxySelectorHeaders(options: { - appId?: string; - alias?: string; -}): Record { - const headers: Record = {}; - - if (options.appId !== undefined) { - headers["X-Oomol-Connector-App-Id"] = options.appId; - } - - if (options.alias !== undefined) { - headers["X-Oomol-Connector-Alias"] = options.alias; - } - - return headers; -} - function parseConnectorFailureResponse( rawResponse: string, ): ConnectorActionFailureResponse | undefined { diff --git a/src/application/commands/telemetry-decisions.test.ts b/src/application/commands/telemetry-decisions.test.ts index 7d617161..e6c7a284 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -151,14 +151,12 @@ const commandTelemetryDecisions = { properties: [ "data_size_bucket", "error_code", - "has_alias", - "has_app_id", "has_body", "http_status", "identity_source", "method", ], - reason: "Records connector proxy bucketed payload size, selector presence, method enum, identity source, stable error code, and HTTP status without service name, endpoint, headers, body, organization name, app id, or alias.", + reason: "Records connector proxy bucketed payload size, method enum, identity source, stable error code, and HTTP status without service name, endpoint, headers, body, or organization name.", }, "connector.search": { kind: "properties", diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 62e58232..2ce7dfe5 100644 --- a/src/i18n/catalog.ts +++ b/src/i18n/catalog.ts @@ -335,10 +335,6 @@ export const enMessages = { "The result action {action} configured for --wait-result must declare an async result lifecycle.", "errors.connectorRun.waitResultUnsupported": "The --wait-result option is only supported for connector actions with an async submit lifecycle.", - "errors.connectorProxy.aliasEmpty": - "The --alias value cannot be empty.", - "errors.connectorProxy.appIdEmpty": - "The --app-id value cannot be empty.", "errors.connectorProxy.dataConflict": "Use either --data or the split proxy request options, not both.", "errors.connectorProxy.dataFilePathRequired": @@ -371,8 +367,6 @@ export const enMessages = { "Connector proxy service {service} returned HTTP {status}: {message}", "errors.connectorProxy.requestFailedWithMessageAndCode": "Connector proxy service {service} returned HTTP {status} (errorCode: {errorCode}): {message}", - "errors.connectorProxy.selectorConflict": - "Use either --app-id or --alias, not both.", "errors.connectorSchema.readFailed": "Failed to read the connector action schema cache at {path}: {message}", "errors.connectorSchema.writeFailed": @@ -820,10 +814,6 @@ export const enMessages = { "Run the action under the given organization identity (alias: --org)", "options.connectorRunPersonal": "Run the action under your personal identity, ignoring any configured default organization", - "options.connectorProxyAlias": - "Run the proxy request with the connector app alias", - "options.connectorProxyAppId": - "Run the proxy request with the connector app id", "options.connectorProxyBody": "Specify the upstream request body as JSON", "options.connectorProxyData": @@ -1464,10 +1454,6 @@ export const zhMessages = { "--wait-result 配置的结果 action {action} 必须声明异步结果 lifecycle。", "errors.connectorRun.waitResultUnsupported": "--wait-result 选项仅支持带有异步 submit lifecycle 的 connector action。", - "errors.connectorProxy.aliasEmpty": - "--alias 的值不能为空。", - "errors.connectorProxy.appIdEmpty": - "--app-id 的值不能为空。", "errors.connectorProxy.dataConflict": "--data 和拆分的 proxy request 选项只能使用其中一种。", "errors.connectorProxy.dataFilePathRequired": @@ -1500,8 +1486,6 @@ export const zhMessages = { "Connector proxy service {service} 返回了 HTTP {status}:{message}", "errors.connectorProxy.requestFailedWithMessageAndCode": "Connector proxy service {service} 返回了 HTTP {status}(errorCode: {errorCode}):{message}", - "errors.connectorProxy.selectorConflict": - "--app-id 和 --alias 只能使用其中一个。", "errors.connectorSchema.readFailed": "读取 {path} 的 connector action schema cache 失败:{message}", "errors.connectorSchema.writeFailed": @@ -1944,10 +1928,6 @@ export const zhMessages = { "以指定组织身份运行该 action(别名:--org)", "options.connectorRunPersonal": "以个人身份运行该 action,忽略已配置的默认组织", - "options.connectorProxyAlias": - "使用 connector app alias 运行 proxy 请求", - "options.connectorProxyAppId": - "使用 connector app id 运行 proxy 请求", "options.connectorProxyBody": "以 JSON 指定上游请求体", "options.connectorProxyData":