diff --git a/contrib/skills/shared/oo/references/connector-execution.md b/contrib/skills/shared/oo/references/connector-execution.md index a63ded5..80286c3 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 7cc433e..421d619 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,48 @@ 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: `--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. 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 + `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. + 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: `--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 } }`. +- 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. + ## Search ### `oo search ` diff --git a/docs/commands.zh-CN.md b/docs/commands.zh-CN.md index 9dbc4d9..ba8b346 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,42 @@ 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? }`。 +- 选项:`--input ` 是 `--data ` 的 alias。 +- 选项:未传 `--data` 时,使用 `--endpoint ` 和 + `--method `,以及可选的 `--query `、`--headers `、 + `--body ` 组装同样的 request object。`--data` 形式不能与这些拆分 + request 选项同时使用。 +- 选项:`--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"`。 +- 选项:`--organization ` 以指定组织身份运行该 proxy 请求,而非个人身份。 + `--org ` 是 `--organization ` 的 alias。省略时,若配置了 + `identity.organization` 默认值则使用该组织,否则使用个人身份。 +- 选项:`--personal` 以个人身份运行该 proxy 请求,并忽略已配置的默认组织。 + 不能与 `--organization` 同时使用。 +- 选项:`--format=json` 和 `--json` 会输出 JSON 对象。 +- 输出:JSON 输出保持稳定结构 + `{ data: { status, headers, data }, meta: { executionId, service } }`。 +- 错误:stderr 会打印 connector proxy HTTP 状态;如果失败响应提供了 + `message` 和 `errorCode`,也会一并包含。 +- 说明:`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 09f51f9..dfbcb58 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 76c61f9..a0f6e15 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 d076b55..a9da2b6 100644 --- a/src/application/commands/connector/index.cli.test.ts +++ b/src/application/commands/connector/index.cli.test.ts @@ -501,6 +501,435 @@ 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("--organization"); + expect(result.stdout).toContain("--personal"); + 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(); + } + }); + + 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\"}", + "--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: { + 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: { + 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"); + 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", + data_size_bucket: "<1KB", + has_body: true, + identity_source: "flag", + method: "POST", + }, + }); + 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("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(); + + try { + const result = await sandbox.run([ + "connector", + "proxy", + "tavily", + "--endpoint", + "/search", + "--method", + "TRACE", + ]); + + 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", + "--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_body: false, + http_status: 400, + identity_source: "personal", + method: "GET", + }, + }); + expect(telemetryPayload?.properties).not.toHaveProperty("endpoint"); + expect(telemetryPayload?.properties).not.toHaveProperty("service"); + } + finally { + await sandbox.cleanup(); + } + }); + test("supports connector run with cached schema and json output", async () => { const sandbox = await createCliSandbox(); @@ -2756,7 +3185,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 +3275,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 +3328,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 f2c1d21..bef4a62 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 0000000..8551fdc --- /dev/null +++ b/src/application/commands/connector/proxy.ts @@ -0,0 +1,311 @@ +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"; +import { recordConnectorFailureTelemetry } from "./telemetry.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 connectorProxyMethodSchema = z.string() + .trim() + .transform(value => value.toUpperCase()) + .pipe(z.enum(connectorProxyMethods)); + +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: connectorProxyMethodSchema, + query: z.record(z.string(), proxyQueryValueSchema).optional(), +}).strict(); + +interface ConnectorProxyInput { + body?: string; + data?: string; + endpoint?: string; + format?: (typeof connectorFormatValues)[number]; + headers?: string; + method?: string; + 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: "organization", + longFlag: "--organization", + aliasFlags: ["--org"], + valueName: "organization", + descriptionKey: "options.connectorProxyOrganization", + }, + { + name: "personal", + longFlag: "--personal", + descriptionKey: "options.connectorProxyPersonal", + }, + ...jsonOutputOptions, + ], + inputSchema: z.object({ + body: z.string().optional(), + data: z.string().optional(), + endpoint: z.string().optional(), + format: z.enum(connectorFormatValues).optional(), + headers: z.string().optional(), + method: z.string().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); + } + + const organizationFlag = input.organization?.trim(); + if (input.organization !== undefined && organizationFlag === "") { + throw new CliUserError("errors.connectorRun.organizationEmpty", 2); + } + + const proxyRequest = await buildConnectorProxyRequest(input, context); + const account = await requireCurrentAccount(context); + const settings = await context.settingsStore.read(); + const { identity, source: identitySource } = resolveConnectorIdentity({ + configOrganization: getConfiguredIdentityOrganization(settings), + organizationFlag, + personalFlag: input.personal === true, + }); + + context.telemetry?.recordProperties({ + data_size_bucket: bucketTelemetryBytes( + Buffer.byteLength(JSON.stringify(proxyRequest)), + ), + has_body: hasProxyBody(proxyRequest), + identity_source: identitySource, + method: readProxyMethod(proxyRequest), + }); + + let response: ConnectorProxyResponse; + try { + response = await runConnectorProxy( + { + apiKey: account.apiKey, + 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, { + 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) { + const issue = parsed.error.issues[0]; + throw new CliUserError("errors.connectorProxy.invalidPayload", 2, { + 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; + } + catch (error) { + throw new CliUserError(errorKey, 2, { + message: error instanceof Error ? error.message : String(error), + }); + } +} + +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) ?? "null", + ].join("\n"); +} diff --git a/src/application/commands/connector/run.ts b/src/application/commands/connector/run.ts index b568d52..7c48081 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 481e468..779c6ac 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,235 @@ describe("connector shared requests", () => { expect(requests[0]?.headers.get("x-oo-organization")).toBeNull(); }); + test("runConnectorProxy sends proxy requests with identity headers", async () => { + const requests: Request[] = []; + const response = await runConnectorProxy( + { + 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: { + executionId: "exec-1", + service: "tavily", + }, + success: true, + })); + }, + }), + ); + + expect(response).toEqual({ + data: { + data: { + answer: "world", + }, + headers: { + "content-type": "application/json", + }, + status: 200, + }, + meta: { + 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"); + await expect(requests[0]?.json()).resolves.toEqual({ + endpoint: "/search", + method: "GET", + query: { + q: "hello", + }, + }); + }); + + test("runConnectorProxy accepts proxy responses without headers or data fields", async () => { + const response = await runConnectorProxy( + createProxyRunInput({ + proxyRequest: { + endpoint: "/empty", + method: "GET", + }, + }), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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("runConnectorProxy surfaces status when the failure response has no message or errorCode", async () => { + const error = await expectCliUserError(runConnectorProxy( + createProxyRunInput(), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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( + createProxyRunInput(), + createProxyRequestContext(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( { @@ -387,3 +617,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 }); +} diff --git a/src/application/commands/connector/shared.ts b/src/application/commands/connector/shared.ts index 594411c..138d0d3 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().optional().default(null), + headers: z.record(z.string(), z.unknown()).optional().default({}), + 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,143 @@ export async function runConnectorAction( } } +export async function runConnectorProxy( + options: { + apiKey: 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), + }, + 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); - - return requestUrl; +function createConnectorProxyRequestUrl( + endpoint: string, + serviceName: string, +): URL { + return new URL( + `https://connector.${endpoint}/v1/proxy/${encodeURIComponent(serviceName)}`, + ); } function parseConnectorFailureResponse( @@ -560,6 +695,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/connector/telemetry.ts b/src/application/commands/connector/telemetry.ts new file mode 100644 index 0000000..b3a09bd --- /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 2444e49..e6c7a28 100644 --- a/src/application/commands/telemetry-decisions.test.ts +++ b/src/application/commands/telemetry-decisions.test.ts @@ -146,6 +146,18 @@ 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", + "error_code", + "has_body", + "http_status", + "identity_source", + "method", + ], + 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", properties: [ diff --git a/src/i18n/catalog.ts b/src/i18n/catalog.ts index 7fa5768..2ce7dfe 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,38 @@ 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.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.connectorSchema.readFailed": "Failed to read the connector action schema cache at {path}: {message}", "errors.connectorSchema.writeFailed": @@ -779,6 +814,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.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.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", "options.description": "Set the required generated skill description", "options.days": @@ -1039,6 +1090,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 +1196,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 +1454,38 @@ export const zhMessages = { "--wait-result 配置的结果 action {action} 必须声明异步结果 lifecycle。", "errors.connectorRun.waitResultUnsupported": "--wait-result 选项仅支持带有异步 submit lifecycle 的 connector action。", + "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.connectorSchema.readFailed": "读取 {path} 的 connector action schema cache 失败:{message}", "errors.connectorSchema.writeFailed": @@ -1840,6 +1928,22 @@ export const zhMessages = { "以指定组织身份运行该 action(别名:--org)", "options.connectorRunPersonal": "以个人身份运行该 action,忽略已配置的默认组织", + "options.connectorProxyBody": + "以 JSON 指定上游请求体", + "options.connectorProxyData": + "提供完整 proxy request JSON,或使用 @路径读取 JSON 文件", + "options.connectorProxyEndpoint": + "指定上游 endpoint path 或允许的绝对 HTTPS URL", + "options.connectorProxyHeaders": + "以 JSON object 指定非认证上游请求头", + "options.connectorProxyMethod": + "指定上游 HTTP method", + "options.connectorProxyOrganization": + "以指定组织身份运行该 proxy 请求(别名:--org)", + "options.connectorProxyPersonal": + "以个人身份运行该 proxy 请求,忽略已配置的默认组织", + "options.connectorProxyQuery": + "以 JSON object 指定上游 query 参数", "options.debug": "在 CLI 退出时打印当前日志文件路径", "options.description": "设置必填的生成 skill 描述", "options.days": "设置私有包临时分享天数(默认 7,最长 7)", @@ -2096,6 +2200,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":