From cbbd98be672e2c48868707e8e2e39c236ec2a293 Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Fri, 17 Jul 2026 16:06:31 +0530 Subject: [PATCH 1/2] feat: add lyzr-tools plugin bridging Lyzr-authorized tools into GitAgent GitAgent's LYZR_API_KEY was only wired into the model path, so users with Gmail/Slack/etc. already authorized in Lyzr still had to configure separate local credentials (e.g. local Gmail SMTP app passwords) for tool execution. Adds a lyzr-tools plugin that: - Discovers authorized provider actions (GET /v3/providers/tools/actions/*) and MCP server tools (/v3/tools/mcp/servers*), cross-referenced against connected-account status (/v3/tools/credentials/connected_accounts). - Registers each as a lyzr_-prefixed gitagent tool, avoiding collisions with local skills by construction. - Proxies execution through /v3/inference/tools/execute or /v3/tools/mcp/tools/execute, normalizing results into success / authorization_required / error, with secret redaction on all details. - Adds prompt guidance preferring lyzr_ tools over local duplicates (e.g. the bundled gmail-email skill) when both exist. Enabled by default in agent.yaml; no-ops with a single warning if LYZR_API_KEY isn't set, so it never makes a network call without a key. See docs/lyzr-tool-auth-rca.md for the RCA and design this implements, and docs/lyzr-tool-bridge-test-cases.md for the acceptance criteria covered by test/lyzr-tools.test.ts (32 tests, no real network calls). Co-Authored-By: Claude Sonnet 5 --- agent.yaml | 10 +- docs/lyzr-tool-auth-rca.md | 699 ++++++++++++++++++++++++++++ docs/lyzr-tool-bridge-test-cases.md | 594 +++++++++++++++++++++++ plugins/lyzr-tools/README.md | 60 +++ plugins/lyzr-tools/index.ts | 93 ++++ plugins/lyzr-tools/lib/client.ts | 151 ++++++ plugins/lyzr-tools/lib/config.ts | 34 ++ plugins/lyzr-tools/lib/dedupe.ts | 44 ++ plugins/lyzr-tools/lib/discover.ts | 249 ++++++++++ plugins/lyzr-tools/lib/execute.ts | 151 ++++++ plugins/lyzr-tools/lib/normalize.ts | 31 ++ plugins/lyzr-tools/lib/redact.ts | 35 ++ plugins/lyzr-tools/lib/types.ts | 58 +++ plugins/lyzr-tools/plugin.yaml | 57 +++ plugins/lyzr-tools/prompt.md | 5 + test/lyzr-tools.test.ts | 479 +++++++++++++++++++ 16 files changed, 2749 insertions(+), 1 deletion(-) create mode 100644 docs/lyzr-tool-auth-rca.md create mode 100644 docs/lyzr-tool-bridge-test-cases.md create mode 100644 plugins/lyzr-tools/README.md create mode 100644 plugins/lyzr-tools/index.ts create mode 100644 plugins/lyzr-tools/lib/client.ts create mode 100644 plugins/lyzr-tools/lib/config.ts create mode 100644 plugins/lyzr-tools/lib/dedupe.ts create mode 100644 plugins/lyzr-tools/lib/discover.ts create mode 100644 plugins/lyzr-tools/lib/execute.ts create mode 100644 plugins/lyzr-tools/lib/normalize.ts create mode 100644 plugins/lyzr-tools/lib/redact.ts create mode 100644 plugins/lyzr-tools/lib/types.ts create mode 100644 plugins/lyzr-tools/plugin.yaml create mode 100644 plugins/lyzr-tools/prompt.md create mode 100644 test/lyzr-tools.test.ts diff --git a/agent.yaml b/agent.yaml index 3494d2b..00014f3 100644 --- a/agent.yaml +++ b/agent.yaml @@ -11,4 +11,12 @@ tools: - write - memory runtime: - max_turns: 56 \ No newline at end of file + max_turns: 56 +plugins: + lyzr-tools: + enabled: true + # No-ops with a single warning log line if LYZR_API_KEY isn't set — + # it never makes a network call without an API key. See + # plugins/lyzr-tools/README.md for full configuration options. + config: + api_key: "${LYZR_API_KEY}" \ No newline at end of file diff --git a/docs/lyzr-tool-auth-rca.md b/docs/lyzr-tool-auth-rca.md new file mode 100644 index 0000000..d78dc0c --- /dev/null +++ b/docs/lyzr-tool-auth-rca.md @@ -0,0 +1,699 @@ +# RCA: Lyzr Pre-Authorized Tools Not Available in GitAgent + +Date: 2026-07-15 +Repository: `open-gitagent/gitagent` +Scope: GitAgent + Lyzr tool authorization behavior for Gmail, Slack, and similar ecosystem tools. + +## Executive Summary + +Users authenticating GitAgent with `LYZR_API_KEY` still need to separately authorize tools such as Gmail and Slack because GitAgent currently uses the Lyzr key only as a model/backend credential. It does not discover, import, proxy, or execute Lyzr ecosystem tools through Lyzr's credential vault. + +The current GitAgent implementation executes tools locally through built-in tools, local skills, declarative scripts, SDK-provided tools, or plugins. As a result, any local Gmail or Slack tool must bring its own credentials. The bundled Gmail skill demonstrates this clearly: it sends mail through Gmail SMTP using `GMAIL_USER` and `GMAIL_APP_PASSWORD`, independent of Lyzr. + +The recommended way forward is to implement a Lyzr Tool Bridge plugin/provider. This bridge should discover already-authorized Lyzr tools for the current Lyzr user/workspace/agent, register those tools in GitAgent, and forward tool executions to Lyzr server-side. Lyzr would then execute the requested action with credentials already stored in the Lyzr ecosystem. + +## Impact + +- Users see a duplicated authorization flow for tools they have already authorized in Lyzr. +- GitAgent cannot reliably know which Lyzr ecosystem tools are available. +- GitAgent may select local duplicate skills, such as Gmail SMTP, instead of Lyzr-native OAuth-backed tools. +- Security posture is weaker if users are encouraged to place third-party app passwords or OAuth tokens in local environment files. +- Product experience is inconsistent: the model is Lyzr-backed, but tool execution is not Lyzr-backed. + +## Root Cause + +### Primary Root Cause + +GitAgent does not have a tool-execution integration with Lyzr's authorized connector/tool layer. The Lyzr API key is wired into the model path, not the tool path. + +Evidence: + +- `examples/lyzr-sdk.ts` reads `LYZR_API_KEY` and maps it into `OPENAI_API_KEY` for OpenAI-compatible model access. See `examples/lyzr-sdk.ts:15-24`. +- The same example configures the model as `lyzr:@https://agent-prod.studio.lyzr.ai/v4`. See `examples/lyzr-sdk.ts:36-38`. +- `src/loader.ts` creates a custom OpenAI-compatible model when the model string contains `@baseUrl`. See `src/loader.ts:81-97` and `src/loader.ts:393-400`. +- `src/loader.ts` uses `LYZR_API_KEY` only as a provider key fallback for custom providers so `pi-ai` can resolve an API key. See `src/loader.ts:406-419`. + +### Contributing Cause 1: Tool Execution Is Local by Default + +GitAgent builds tools locally and passes them into `pi-agent-core`. + +Evidence: + +- CLI path builds built-in tools, declarative tools, and plugin tools before creating the `Agent`. See `src/index.ts:532-569` and `src/index.ts:589-596`. +- SDK path does the same with built-ins, declarative tools, plugin tools, and SDK tools. See `src/sdk.ts:176-244` and `src/sdk.ts:301-309`. +- Built-in tools are local filesystem/shell/memory tools. See `src/tools/index.ts:31-58`. +- Declarative tools execute local scripts via `spawn`, passing JSON args through stdin. See `src/tool-loader.ts:50-75` and `src/tool-loader.ts:87-156`. + +### Contributing Cause 2: Gmail Skill Uses Independent SMTP Credentials + +The bundled Gmail skill is not a Lyzr ecosystem tool. It requires Gmail SMTP credentials and does not use Lyzr authorization state. + +Evidence: + +- The Gmail skill describes itself as SMTP with App Password authentication. See `skills/gmail-email/SKILL.md:1-4`. +- The setup instructions require `GMAIL_USER` and `GMAIL_APP_PASSWORD`. See `skills/gmail-email/SKILL.md:19-29`. +- The script reads `GMAIL_USER` and `GMAIL_APP_PASSWORD` from environment variables. See `skills/gmail-email/scripts/send_email.py:24-30`. +- Missing local Gmail credentials trigger an error instructing users to set Gmail credentials. See `skills/gmail-email/scripts/send_email.py:31-44`. +- The script connects directly to Gmail SMTP and logs in locally. See `skills/gmail-email/scripts/send_email.py:55-66`. + +### Contributing Cause 3: Swagger Defines Lyzr Tool APIs, but GitAgent Does Not Consume Them + +The Lyzr Agent API Swagger already exposes tool, credential, MCP, provider, and inference tool-execution endpoints. The gap is not that Lyzr has no tool API surface; the gap is that GitAgent does not call those endpoints to discover and proxy already-authorized tools. + +Evidence: + +- Plugins can register programmatic tools through `registerTool`. See `src/plugin-sdk.ts:10-25` and `src/plugin-sdk.ts:64-66`. +- Plugin loading collects programmatic tools from plugin entrypoints. See `src/plugins.ts:237-284`. +- GitAgent merges plugin tools into the active tool list. CLI path: `src/index.ts:545-560`; SDK path: `src/sdk.ts:192-207`. +- SDK tools are converted into `AgentTool` objects through `toAgentTool`. See `src/tool-utils.ts:7-27`. +- Lyzr Swagger defines user tool listing at `GET /v3/tools/`. +- Lyzr Swagger defines all-user tool listing at `GET /v3/tools/all/user`. +- Lyzr Swagger defines provider/action listing at `GET /v3/providers/tools/actions/{provider_identifier}` and `GET /v3/providers/tools/all`. +- Lyzr Swagger defines MCP server listing, tool listing, OAuth initiation/status, and execution under `/v3/tools/mcp/*`. +- Lyzr Swagger defines generic tool execution at `POST /v3/inference/tools/execute`. +- Lyzr Swagger defines connected accounts and tool credential management under `/v3/tools/credentials/*`. + +## Current Tool Calling Mechanism in GitAgent + +### 1. Agent Loading + +`loadAgent()` reads the agent manifest, identity files, skills, plugins, workflows, examples, and model configuration. It then returns a composed system prompt, model object, plugin list, and metadata. + +Relevant code: + +- Manifest parsing: `src/loader.ts:236-250` +- Plugin discovery: `src/loader.ts:263-264` +- Skills discovery and prompt injection: `src/loader.ts:295-307` +- Model resolution: `src/loader.ts:382-419` + +### 2. Tool Assembly + +GitAgent assembles tools from multiple sources: + +- Built-in tools: `cli`, `read`, `write`, `edit`, `memory`, `capture_photo`, `task_tracker`, `skill_learner` +- Declarative tools from `tools/*.yaml` +- Plugin declarative and programmatic tools +- SDK-provided tools in programmatic usage + +Relevant code: + +- Built-in tool creation: `src/tools/index.ts:31-58` +- CLI tool assembly: `src/index.ts:532-560` +- SDK tool assembly: `src/sdk.ts:176-223` +- Declarative tool loading: `src/tool-loader.ts:161-189` +- Plugin programmatic tool loading: `src/plugins.ts:237-284` + +### 3. Hook Wrapping + +Tools can be wrapped with hooks before execution. Hooks can block or modify tool calls. + +Relevant code: + +- Hook config shape: `src/hooks.ts:7-30` +- Hook execution: `src/hooks.ts:44-155` +- Tool wrapper for `pre_tool_use`: `src/hooks.ts:157-198` +- CLI wraps tools with hooks: `src/index.ts:562-569` +- SDK wraps tools with script and programmatic hooks: `src/sdk.ts:225-244` + +### 4. Agent Execution + +The final `Agent` receives: + +- `systemPrompt` +- `model` +- `tools` +- model options such as temperature and token limits + +Relevant code: + +- CLI creates the agent: `src/index.ts:589-596` +- SDK creates the agent: `src/sdk.ts:301-309` +- CLI sends single-shot prompt: `src/index.ts:638-668` +- SDK sends prompt through `agent.prompt()`: `src/sdk.ts:489-538` + +### 5. Tool Call Events + +When the model chooses a tool, `pi-agent-core` emits tool execution events. GitAgent subscribes to those events and streams/logs tool calls and results. + +Relevant code: + +- CLI handles tool start/end events: `src/index.ts:163-177` +- SDK emits `tool_use` messages: `src/sdk.ts:432-440` +- SDK emits `tool_result` messages: `src/sdk.ts:442-450` +- SDK fires failure and file-change hooks after tool results: `src/sdk.ts:452-473` + +## Why Lyzr Pre-Authorized Tools Are Not Available Today + +The current flow is: + +```text +User sets LYZR_API_KEY + -> GitAgent uses it for Lyzr/OpenAI-compatible model calls + -> GitAgent locally registers built-in/local/plugin tools + -> Model may call a local Gmail/Slack tool + -> Local tool asks for local Gmail/Slack credentials +``` + +The desired flow is: + +```text +User sets LYZR_API_KEY + -> GitAgent authenticates with Lyzr + -> GitAgent discovers Lyzr-authorized tools + -> GitAgent registers those tools locally as proxy tools + -> Model calls a proxy tool + -> GitAgent forwards execution to Lyzr + -> Lyzr executes with stored OAuth credentials + -> GitAgent returns the result to the model/user +``` + +The missing component is the bridge between GitAgent's tool registry and Lyzr's server-side tool execution system. + +## Proposed Implementation + +Implement a `lyzr-tools` GitAgent plugin/provider. + +The plugin should: + +1. Read configuration from `agent.yaml` plugin config and environment variables. +2. Authenticate to Lyzr with `LYZR_API_KEY`. +3. Discover tools already available to the current Lyzr user/workspace/agent. +4. Register each discovered tool as a GitAgent programmatic tool using `api.registerTool()`. +5. Execute tool calls by proxying them to Lyzr. +6. Return structured auth-required errors when a tool is unavailable or not authorized. +7. Optionally add prompt text telling the model to prefer Lyzr-backed tools over local duplicate skills. + +### Proposed GitAgent Configuration + +```yaml +plugins: + lyzr-tools: + enabled: true + config: + api_key: "${LYZR_API_KEY}" + base_url: "https://agent-prod.studio.lyzr.ai" + agent_id: "${GITAGENT_LYZR_AGENT_ID}" + workspace_id: "${LYZR_WORKSPACE_ID}" + prefer_lyzr_tools: true +``` + +### Swagger-Confirmed Lyzr API Contracts + +The Swagger documentation for `https://agent-dev.test.studio.lyzr.ai/swagger#/` confirms that Lyzr already exposes tool discovery, credential, MCP, provider/action, and tool execution APIs. Therefore, the GitAgent implementation should use these existing `/v3` APIs instead of introducing the previously proposed `/v4/tools` endpoints. + +Authentication in these endpoints is defined with `APIKeyHeader`, which uses the `x-api-key` header. The OpenAI-compatible chat endpoints use bearer auth separately. + +#### General Tool Discovery + +```http +GET /v3/tools/ +x-api-key: +``` + +Swagger summary: `Get User Tools` + +```http +GET /v3/tools/all/user +x-api-key: +``` + +Swagger summary: `Get All Tools` + +The Swagger response schemas for these two list endpoints are generic objects, so the plugin should treat them as platform responses and normalize them internally. + +#### Provider and Action Discovery + +```http +GET /v3/providers/tools/actions/{provider_identifier} +x-api-key: +``` + +Swagger summary: `Get Tools Actions` + +Query parameters: + +- `tool_source` +- `app_id` + +```http +GET /v3/providers/tools/all +x-api-key: +``` + +Swagger summary: `Get All Tools` + +```http +GET /v3/providers/lyzr/aci-tools +x-api-key: +``` + +Swagger summary: `List Lyzr Aci Tools` + +These endpoints are the best Swagger-confirmed candidates for discovering Lyzr/ACI-backed app tools such as Gmail and Slack, including action names that can later be placed into `ToolConfig.action_names`. + +#### Connected Accounts and Credential Status + +```http +GET /v3/tools/credentials/connected_accounts?user_id= +x-api-key: +``` + +Swagger summary: `Get Tool Credential By User Id` + +This endpoint should be used by the GitAgent bridge to determine which tool credentials are already connected for the user. + +Credential creation and lifecycle endpoints are also present: + +```http +POST /v3/tools/credentials/oauth +POST /v3/tools/credentials/static +PATCH /v3/tools/credentials/{credential_id}/status +GET /v3/tools/credentials/{credential_id}/test/supported +POST /v3/tools/credentials/{credential_id}/test +DELETE /v3/tools/credentials/{credential_id} +``` + +Relevant Swagger schemas: + +```json +{ + "CreateOAuthToolCredentialModel": { + "required": ["credential_name", "user_id", "provider_uuid"], + "fields": { + "credential_name": "string", + "user_id": "string", + "provider_uuid": "string", + "redirect_url": "string | null", + "grant_type": "authorization_code | client_credentials", + "tenant_id": "string | null", + "token_url": "string | null", + "client_id": "string | null", + "client_secret": "string | null", + "scope": "string | null", + "credentials": "object | null" + } + }, + "CreateStaticToolCredentialModel": { + "required": ["credential_name", "user_id", "provider_uuid", "credentials"], + "fields": { + "credential_name": "string", + "user_id": "string", + "provider_uuid": "string", + "credentials": "object" + } + } +} +``` + +#### MCP Server Tool Discovery and Execution + +For tools exposed through MCP servers, Swagger confirms dedicated endpoints: + +```http +GET /v3/tools/mcp/servers +x-api-key: +``` + +Swagger response schema: `MCPServerListResponse` + +```http +GET /v3/tools/mcp/servers/{server_id}/tools +x-api-key: +``` + +Swagger response schema: `ToolsListResponse` + +The relevant response schema is: + +```json +{ + "server_id": "string", + "server_name": "string", + "tools": [ + { + "name": "string", + "display_name": "string | null", + "description": "string | null", + "input_schema": {} + } + ], + "total": 0 +} +``` + +MCP tool execution: + +```http +POST /v3/tools/mcp/tools/execute +x-api-key: +Content-Type: application/json +``` + +Swagger request schema: `lyzr_agent__tools__mcp_tools__ToolExecuteRequest` + +```json +{ + "server_id": "string", + "tool_name": "string", + "arguments": {} +} +``` + +Swagger response schema: `lyzr_agent__tools__mcp_tools__ToolExecuteResponse` + +```json +{ + "server_id": "string", + "tool_name": "string", + "result": [], + "success": true, + "error": "string | null" +} +``` + +Swagger also confirms MCP OAuth flow support: + +```http +POST /v3/tools/mcp/servers/{server_id}/oauth/initiate +GET /v3/tools/mcp/servers/{server_id}/oauth/status?state= +``` + +#### Generic Inference Tool Execution + +For agent-level tool execution outside the MCP-specific path, Swagger confirms: + +```http +POST /v3/inference/tools/execute +x-api-key: +Content-Type: application/json +``` + +Swagger request schema: `api__factory__v3__inference__models__ToolExecuteRequest` + +```json +{ + "agent_id": "string | null", + "tool_name": "string", + "tool_configs": [ + { + "tool_name": "string", + "tool_source": "string", + "action_names": ["string"], + "persist_auth": false, + "server_id": "string | null", + "provider_uuid": "string | null", + "credential_id": "string | null" + } + ], + "arguments": {}, + "trace_id": "string | null" +} +``` + +Swagger response schema: `api__factory__v3__inference__models__ToolExecuteResponse` + +```json +{ + "tool_name": "string", + "trace_id": "string", + "result": {} +} +``` + +This is the strongest Swagger-confirmed candidate for a GitAgent Lyzr bridge that executes pre-authorized app tools, because `ToolConfig` includes `credential_id`, `provider_uuid`, `server_id`, `action_names`, and `persist_auth`. + +#### OpenAI-Compatible Model Endpoint + +The spec also confirms the model/chat path remains separate: + +```http +POST /v4/chat/completions +Authorization: Bearer +``` + +This supports the RCA conclusion: model authentication and tool credential execution are separate API surfaces. + +#### Remaining API Alignment Item + +Swagger confirms the endpoints needed for discovery and execution, but the RCA still needs product/API confirmation for the exact response shape when a tool is unavailable or not authorized. In particular, the GitAgent bridge needs a deterministic way to map Lyzr responses into: + +```json +{ + "status": "authorization_required", + "provider": "gmail|slack|...", + "auth_url": "https://..." +} +``` + +If Lyzr already returns this through connected-account or execution endpoints, the plugin should preserve that shape. If not, GitAgent should normalize current error payloads into this bridge-level result. + +### Proposed Plugin Shape + +The plugin can use the existing programmatic plugin API: + +- `api.registerTool()` is available at `src/plugin-sdk.ts:18-19`. +- Programmatic tools are collected at `src/plugins.ts:237-284`. +- Those tools are merged into the active tool list in the CLI at `src/index.ts:545-560` and in the SDK at `src/sdk.ts:192-207`. + +Pseudo-implementation: + +```ts +export async function register(api) { + const tools = await fetchLyzrTools(api.config); + + for (const tool of tools) { + api.registerTool({ + name: normalizeToolName(tool.name), + description: tool.description, + inputSchema: tool.input_schema, + handler: async (args) => { + const result = await executeLyzrTool(api.config, { + tool_name: tool.name, + tool_source: tool.source, + action_names: tool.action_names, + credential_id: tool.credential_id, + provider_uuid: tool.provider_uuid, + server_id: tool.server_id + }, args); + if (result.status === "authorization_required") { + return { + text: `Authorization required for ${tool.display_name}: ${result.auth_url}`, + details: result + }; + } + return { + text: result.result?.text ?? JSON.stringify(result.result), + details: result + }; + } + }); + } + + api.addPrompt( + "Prefer Lyzr-backed tools for Gmail, Slack, and other connected apps when available. These tools use pre-authorized Lyzr ecosystem credentials." + ); +} +``` + +## Assurance Model + +This implementation can provide assurance that pre-authorized tools are available only if Lyzr exposes authorized tool discovery and server-side execution. + +Assurance condition: + +```text +If a tool is authorized in Lyzr and included in Lyzr discovery, +then GitAgent will register it as an available tool. +``` + +Execution assurance: + +```text +If GitAgent calls a registered Lyzr-backed tool, +then execution occurs through Lyzr using Lyzr-managed credentials, +not local Gmail/Slack credentials. +``` + +Non-assurance cases: + +- Tool exists in GitAgent locally but is not discoverable from Lyzr. +- Tool is authorized in Lyzr but omitted from the discovery API response. +- Lyzr API key maps to a different workspace/user/agent than the one where the tool was authorized. +- Lyzr refuses to proxy execution and only exposes raw connector tokens, which should be avoided. + +## Implementation Plan of Events + +### Phase 0: Product and API Alignment + +Owner: Lyzr platform + GitAgent integration team + +Events: + +1. Confirm which Swagger-confirmed path should be the primary execution path for GitAgent: generic `POST /v3/inference/tools/execute`, MCP `POST /v3/tools/mcp/tools/execute`, or both. +2. Confirm the discovery sequence for Gmail/Slack: connected accounts, provider/actions, all tools, MCP server tools, or a combined flow. +3. Define required identity scope: `user_id`, `agent_id`, `provider_uuid`, `credential_id`, `server_id`, workspace, organization, or project. +4. Define expected `LYZR_API_KEY` permissions for tool discovery, connected-account lookup, credential status, and execution. +5. Define auth-required and permission-denied error normalization if current Swagger responses do not already return a stable shape. +6. Decide naming convention for registered tools, for example `lyzr_gmail_send_email`. + +Exit criteria: + +- Swagger-backed endpoint sequence is documented for Gmail and Slack. +- Example Gmail and Slack discovery/execution payloads are available. +- Security confirms raw third-party OAuth tokens will not be returned to GitAgent. + +### Phase 1: GitAgent Plugin Skeleton + +Owner: GitAgent integration team + +Events: + +1. Create a `lyzr-tools` plugin directory with `plugin.yaml`. +2. Add config schema for `api_key`, `base_url`, `agent_id`, `workspace_id`, and `prefer_lyzr_tools`. +3. Add an entrypoint that uses `api.registerTool()` from the plugin API. +4. Add prompt text through `api.addPrompt()` to prefer Lyzr-backed tools. +5. Add basic unit tests for config resolution and plugin load failure modes. + +Relevant existing integration points: + +- Plugin config resolution: `src/plugins.ts:62-96` +- Plugin entrypoint loading: `src/plugins.ts:237-250` +- Plugin tool collection: `src/plugins.ts:251-268` +- Plugin API: `src/plugin-sdk.ts:10-36` + +Exit criteria: + +- Plugin loads through existing GitAgent plugin system. +- Plugin can register one static test tool. + +### Phase 2: Tool Discovery Integration + +Owner: GitAgent integration team + Lyzr API team + +Events: + +1. Implement `fetchLyzrTools(config)`. +2. Use Swagger-confirmed discovery inputs from `GET /v3/tools/`, `GET /v3/tools/all/user`, `GET /v3/providers/tools/actions/{provider_identifier}`, `GET /v3/providers/tools/all`, `GET /v3/providers/lyzr/aci-tools`, `GET /v3/tools/credentials/connected_accounts`, and MCP listing endpoints where applicable. +3. Normalize tool names to GitAgent-compatible identifiers. +4. Convert Lyzr `input_schema` / action schemas into GitAgent `inputSchema`. +5. Filter out unauthorized tools or register them with clear auth-required behavior depending on product decision. +6. Detect collisions with existing tool names. +7. Add telemetry/logging for discovered tools count and skipped tools. + +Exit criteria: + +- A user with authorized Gmail sees Gmail tool registered in GitAgent. +- A user without authorized Gmail sees a clear auth-required state, not a request for local SMTP credentials. + +### Phase 3: Tool Execution Proxy + +Owner: GitAgent integration team + Lyzr API team + +Events: + +1. Implement `executeLyzrTool(config, tool, args)` using `POST /v3/inference/tools/execute` for agent-level tools where possible. +2. Implement MCP execution fallback or parallel support using `POST /v3/tools/mcp/tools/execute` for MCP-backed tools. +3. Populate `ToolConfig` with `tool_name`, `tool_source`, `action_names`, and available `credential_id`, `provider_uuid`, or `server_id`. +4. Map execution success into GitAgent tool text result. +5. Map `authorization_required` or equivalent Lyzr errors into a user-facing result with provider and auth URL if available. +6. Map permission errors, validation errors, rate limits, and platform errors into structured `details`. +7. Ensure sensitive values are redacted from logs and tool results. +8. Add retry policy only for safe transient failures. + +Exit criteria: + +- Gmail send executes through Lyzr with no local `GMAIL_USER` or `GMAIL_APP_PASSWORD`. +- Slack send executes through Lyzr with no local Slack bot token. +- Tool result returns to the model as a normal GitAgent tool result. + +### Phase 4: Local Duplicate Tool Deconfliction + +Owner: GitAgent integration team + +Events: + +1. Add prompt guidance to prefer Lyzr tools when duplicate local skills exist. +2. Optionally add an allow/deny tool config that disables local duplicate skills/tools. +3. Consider auto-prefixing Lyzr tools with `lyzr_` to avoid name collisions. +4. Add documentation explaining how Lyzr-backed tools differ from local skills. + +Relevant current behavior: + +- CLI tool collision handling skips colliding plugin tools. See `src/index.ts:545-560`. +- SDK tool collision handling does the same. See `src/sdk.ts:192-207`. + +Exit criteria: + +- Model chooses `lyzr_gmail_send_email` rather than local `gmail-email` SMTP flow. +- Users are not instructed to create local app passwords when Lyzr Gmail is authorized. + +### Phase 5: Tests and Validation + +Owner: GitAgent integration team + +Events: + +1. Unit test discovery success with Gmail and Slack tools. +2. Unit test no tools returned. +3. Unit test `authorization_required`. +4. Unit test execution success. +5. Unit test execution failure and redaction. +6. Integration test against a mocked Lyzr API. +7. Manual E2E test with a real Lyzr account that has Gmail and Slack pre-authorized. + +Acceptance scenarios: + +```text +Given LYZR_API_KEY belongs to a user with Gmail authorized +When GitAgent starts with lyzr-tools enabled +Then GitAgent registers a Gmail send tool +And sending email does not ask for GMAIL_USER or GMAIL_APP_PASSWORD +And execution is proxied through Lyzr +``` + +```text +Given LYZR_API_KEY belongs to a user without Slack authorized +When GitAgent attempts to use Slack +Then GitAgent returns authorization_required with a Lyzr auth URL +And does not ask for local Slack bot credentials +``` + +### Phase 6: Rollout + +Owner: Product + engineering + +Events: + +1. Release plugin behind a feature flag. +2. Enable for internal dogfood accounts. +3. Track metrics: discovery success, execution success, auth-required rate, tool errors. +4. Add docs to install/setup flow. +5. Deprecate local Gmail/Slack credential instructions for Lyzr mode. +6. Roll out broadly after successful internal validation. + +## Risks and Mitigations + +| Risk | Impact | Mitigation | +| --- | --- | --- | +| Multiple Swagger-confirmed discovery paths exist | Plugin may choose incomplete source of truth | Define canonical discovery sequence for Gmail/Slack before implementation | +| Generic and MCP execution paths differ | Tool execution behavior may be inconsistent | Route tools by source: generic `/v3/inference/tools/execute` for agent tools, MCP `/v3/tools/mcp/tools/execute` for MCP tools | +| Tool names collide with local tools | Wrong tool may be selected | Prefix Lyzr tools and add prompt preference | +| API key maps to wrong user/workspace/org | Tools appear missing | Require explicit `user_id` and any required org/workspace context in plugin config | +| Auth-required errors are vague or inconsistent | User confusion persists | Normalize Lyzr errors into structured `auth_url`, provider, and reason | +| Sensitive args/results leak in logs | Security issue | Redact secrets and PII in plugin logging | + +## Final Recommendation + +Proceed with a Lyzr-backed tool bridge rather than trying to pass Gmail/Slack credentials into GitAgent. + +The implementation should guarantee this behavior: + +```text +Lyzr-authorized tool + -> discovered by GitAgent + -> registered as a GitAgent proxy tool + -> executed by Lyzr server-side + -> no local reauthorization required +``` + +This design aligns with the current GitAgent plugin architecture, avoids local credential duplication, and preserves Lyzr as the system of record for connected app authorization. diff --git a/docs/lyzr-tool-bridge-test-cases.md b/docs/lyzr-tool-bridge-test-cases.md new file mode 100644 index 0000000..7772541 --- /dev/null +++ b/docs/lyzr-tool-bridge-test-cases.md @@ -0,0 +1,594 @@ +# Test Cases: Lyzr Pre-Authorized Tool Bridge for GitAgent + +Date: 2026-07-15 +Scope: Reproduce the current duplicate-authorization issue and validate the proposed GitAgent integration with Lyzr's Swagger-confirmed tool APIs. + +## Preconditions + +- GitAgent repo is available locally. +- A Lyzr dev/staging account exists with a valid `LYZR_API_KEY`. +- At least one Lyzr agent exists, with `GITAGENT_LYZR_AGENT_ID` available. +- Test user A has Gmail and Slack authorized inside Lyzr. +- Test user B does not have Gmail or Slack authorized inside Lyzr. +- Lyzr Swagger APIs are reachable: + - `GET /v3/tools/` + - `GET /v3/tools/all/user` + - `GET /v3/providers/tools/actions/{provider_identifier}` + - `GET /v3/providers/tools/all` + - `GET /v3/tools/credentials/connected_accounts` + - `POST /v3/inference/tools/execute` + - MCP APIs under `/v3/tools/mcp/*` + +## Part A: Reproduce Current Issue + +### TC-A01: Lyzr API Key Enables Model but Not Local Gmail Tool + +Objective: Prove that `LYZR_API_KEY` currently works for the model path but not for Gmail tool authorization. + +Steps: + +1. Set only Lyzr model credentials: + ```bash + export LYZR_API_KEY="" + export GITAGENT_LYZR_AGENT_ID="" + unset GMAIL_USER + unset GMAIL_APP_PASSWORD + ``` +2. Run GitAgent with the Lyzr model backend. +3. Ask: "Send an email to qa@example.com with subject Test and body Hello." +4. If GitAgent chooses the bundled Gmail skill, observe the result. + +Expected current behavior: + +- Model call succeeds through Lyzr. +- Gmail action fails or asks for local `GMAIL_USER` and `GMAIL_APP_PASSWORD`. +- User is effectively asked to authorize/configure Gmail again, despite Gmail possibly being authorized in Lyzr. + +Pass condition: + +- The issue is reproduced when local Gmail credentials are required. + +### TC-A02: Bundled Gmail Skill Uses SMTP Credentials + +Objective: Confirm current Gmail path is independent of Lyzr. + +Steps: + +1. Ensure `LYZR_API_KEY` is set. +2. Ensure `GMAIL_USER` and `GMAIL_APP_PASSWORD` are unset. +3. Run: + ```bash + python3 skills/gmail-email/scripts/send_email.py \ + --to "qa@example.com" \ + --subject "Test" \ + --body "Hello" + ``` + +Expected current behavior: + +- Script prints `ERROR: Gmail credentials not found!` +- Script asks for `GMAIL_USER` and `GMAIL_APP_PASSWORD`. + +Pass condition: + +- The script does not use `LYZR_API_KEY`. + +### TC-A03: Slack or Other Local Tool Requires Independent Credential + +Objective: Confirm the same class of issue exists for non-Gmail tools if implemented locally. + +Steps: + +1. Configure Lyzr credentials only. +2. Trigger a Slack action through any local Slack skill/tool if present. +3. Do not provide local Slack bot/user tokens. + +Expected current behavior: + +- Local Slack tool requires its own Slack credentials. +- Lyzr pre-authorization is not reused. + +Pass condition: + +- The issue is reproduced for at least one non-Gmail connected app, or marked not applicable if no local Slack tool exists. + +## Part B: Validate Lyzr Swagger API Availability + +### TC-B01: List User Tools + +Objective: Confirm `GET /v3/tools/` is reachable with `x-api-key`. + +Steps: + +1. Call: + ```bash + curl -sS \ + -H "x-api-key: $LYZR_API_KEY" \ + "https://agent-dev.test.studio.lyzr.ai/v3/tools/" + ``` +2. Inspect response. + +Expected behavior: + +- API returns 200. +- Response contains user tool data or an empty user tool collection. + +Pass condition: + +- Response is authenticated and parseable. + +### TC-B02: List All User Tools + +Objective: Confirm `GET /v3/tools/all/user` returns available tools. + +Steps: + +1. Call: + ```bash + curl -sS \ + -H "x-api-key: $LYZR_API_KEY" \ + "https://agent-dev.test.studio.lyzr.ai/v3/tools/all/user" + ``` + +Expected behavior: + +- API returns 200. +- Response includes available tool/provider data, or a valid empty response. + +Pass condition: + +- GitAgent bridge can use or normalize the response. + +### TC-B03: List Connected Accounts for Authorized User + +Objective: Confirm Lyzr can report connected tool credentials for a user. + +Steps: + +1. Use test user A who has Gmail and Slack authorized. +2. Call: + ```bash + curl -sS \ + -H "x-api-key: $LYZR_API_KEY" \ + "https://agent-dev.test.studio.lyzr.ai/v3/tools/credentials/connected_accounts?user_id=" + ``` + +Expected behavior: + +- API returns 200. +- Response indicates connected Gmail and Slack accounts, or includes credential identifiers usable by execution. + +Pass condition: + +- Response includes enough metadata to map a connected account to `credential_id`, provider, or tool configuration. + +### TC-B04: List Connected Accounts for Unauthorized User + +Objective: Confirm unauthorized state can be detected. + +Steps: + +1. Use test user B who has no Gmail/Slack authorization. +2. Call connected accounts endpoint with user B. + +Expected behavior: + +- API returns 200. +- Response does not include Gmail/Slack credentials. + +Pass condition: + +- Bridge can detect "not authorized" without asking for local credentials. + +### TC-B05: Provider Action Discovery + +Objective: Confirm provider/action endpoint can list app actions. + +Steps: + +1. Call: + ```bash + curl -sS \ + -H "x-api-key: $LYZR_API_KEY" \ + "https://agent-dev.test.studio.lyzr.ai/v3/providers/tools/actions/?tool_source=&app_id=" + ``` +2. Use actual provider identifier/source/app ID from Lyzr configuration. + +Expected behavior: + +- API returns action names for the provider/app. +- Gmail action such as send email or Slack action such as send message is discoverable if configured. + +Pass condition: + +- Actions can be transformed into GitAgent tool definitions. + +### TC-B06: Generic Tool Execution API Contract + +Objective: Confirm `POST /v3/inference/tools/execute` accepts `ToolConfig`. + +Steps: + +1. Prepare a payload using a known authorized Gmail or Slack action: + ```json + { + "agent_id": "", + "tool_name": "", + "tool_configs": [ + { + "tool_name": "", + "tool_source": "", + "action_names": [""], + "persist_auth": true, + "provider_uuid": "", + "credential_id": "" + } + ], + "arguments": {}, + "trace_id": "qa-test" + } + ``` +2. Call: + ```bash + curl -sS \ + -X POST \ + -H "x-api-key: $LYZR_API_KEY" \ + -H "Content-Type: application/json" \ + -d @payload.json \ + "https://agent-dev.test.studio.lyzr.ai/v3/inference/tools/execute" + ``` + +Expected behavior: + +- API returns 200 for valid authorized tool calls. +- Response contains `tool_name`, `trace_id`, and `result`. + +Pass condition: + +- The response can be mapped into a GitAgent tool result. + +## Part C: Validate GitAgent Lyzr Tool Bridge Implementation + +These tests apply after the `lyzr-tools` GitAgent plugin/provider is implemented. + +### TC-C01: Plugin Loads Successfully + +Objective: Confirm GitAgent loads the Lyzr bridge plugin. + +Steps: + +1. Configure `agent.yaml`: + ```yaml + plugins: + lyzr-tools: + enabled: true + config: + api_key: "${LYZR_API_KEY}" + base_url: "https://agent-dev.test.studio.lyzr.ai" + agent_id: "${GITAGENT_LYZR_AGENT_ID}" + user_id: "" + ``` +2. Start GitAgent. + +Expected behavior: + +- GitAgent logs or exposes that `lyzr-tools` plugin loaded. +- No plugin config warnings for required fields. + +Pass condition: + +- Plugin is present in `/plugins` output or startup logs. + +### TC-C02: Authorized Gmail Tool Is Registered + +Objective: Confirm GitAgent registers Lyzr-backed Gmail tool for user A. + +Steps: + +1. Use user A with Gmail authorized in Lyzr. +2. Start GitAgent with `lyzr-tools`. +3. Inspect active tools through startup output or SDK messages. + +Expected behavior: + +- A Gmail send tool appears, for example `lyzr_gmail_send_email`. +- Tool description says it uses Lyzr-backed/pre-authorized credentials. + +Pass condition: + +- Tool is registered without local Gmail credentials. + +### TC-C03: Authorized Slack Tool Is Registered + +Objective: Confirm GitAgent registers Lyzr-backed Slack tool for user A. + +Steps: + +1. Use user A with Slack authorized in Lyzr. +2. Start GitAgent with `lyzr-tools`. +3. Inspect active tools. + +Expected behavior: + +- Slack action tool appears, for example `lyzr_slack_send_message`. + +Pass condition: + +- Tool is registered without local Slack credentials. + +### TC-C04: Unauthorized Tool Produces Auth-Required State + +Objective: Confirm user B does not get local credential prompts. + +Steps: + +1. Use user B without Gmail authorization. +2. Start GitAgent with `lyzr-tools`. +3. Ask: "Send an email to qa@example.com." + +Expected behavior: + +- GitAgent does not ask for `GMAIL_USER` or `GMAIL_APP_PASSWORD`. +- GitAgent returns a structured auth-required result or message. +- If available from Lyzr, the result includes provider and auth URL. + +Pass condition: + +- Missing authorization is represented as Lyzr auth-required, not local credential setup. + +### TC-C05: Gmail Send Executes Through Lyzr + +Objective: Validate full happy path for Gmail. + +Steps: + +1. Use user A with Gmail authorized. +2. Ensure local Gmail credentials are unset: + ```bash + unset GMAIL_USER + unset GMAIL_APP_PASSWORD + ``` +3. Ask GitAgent: "Send an email to qa@example.com with subject Bridge Test and body This came through Lyzr." +4. Observe tool call and result. +5. Check recipient inbox or Lyzr execution logs. + +Expected behavior: + +- GitAgent calls Lyzr-backed Gmail tool. +- Lyzr executes the email send. +- Email is delivered or execution result confirms success. +- No local Gmail credentials are required. + +Pass condition: + +- Email send succeeds through Lyzr. + +### TC-C06: Slack Send Executes Through Lyzr + +Objective: Validate full happy path for Slack. + +Steps: + +1. Use user A with Slack authorized. +2. Ensure local Slack tokens are unset. +3. Ask GitAgent: "Send a Slack message to #qa saying Bridge test passed." +4. Observe tool call and result. +5. Check Slack channel or Lyzr execution logs. + +Expected behavior: + +- GitAgent calls Lyzr-backed Slack tool. +- Slack message is sent. +- No local Slack credentials are required. + +Pass condition: + +- Slack send succeeds through Lyzr. + +### TC-C07: Tool Result Mapping + +Objective: Ensure Lyzr execution results are returned cleanly to the model. + +Steps: + +1. Execute a Lyzr-backed tool through GitAgent. +2. Capture GitAgent `tool_result` event or CLI output. + +Expected behavior: + +- Result is human-readable. +- Raw implementation details are stored in `details` where available. +- Sensitive credentials/tokens are not printed. + +Pass condition: + +- Tool result can be safely shown to user and fed back to model. + +### TC-C08: Local Duplicate Tool Is Not Preferred + +Objective: Ensure GitAgent prefers Lyzr-backed tools over local duplicate skills. + +Steps: + +1. Ensure bundled `gmail-email` skill exists. +2. Enable Lyzr Gmail bridge tool. +3. Ask: "Send an email to qa@example.com." + +Expected behavior: + +- Model selects `lyzr_gmail_send_email`, not local SMTP skill. +- No local Gmail App Password prompt appears. + +Pass condition: + +- Lyzr-backed tool wins over local duplicate. + +### TC-C09: Invalid API Key Fails Clearly + +Objective: Validate failure behavior for bad `LYZR_API_KEY`. + +Steps: + +1. Set invalid key: + ```bash + export LYZR_API_KEY="invalid" + ``` +2. Start GitAgent with `lyzr-tools`. + +Expected behavior: + +- Plugin fails discovery gracefully. +- User sees clear authentication error. +- GitAgent itself does not crash unless configured to fail closed. + +Pass condition: + +- Error is actionable and does not expose secrets. + +### TC-C10: Wrong User or Workspace Context + +Objective: Validate behavior when API key is valid but user/workspace context does not match authorization. + +Steps: + +1. Use valid `LYZR_API_KEY`. +2. Configure wrong `user_id` or workspace context. +3. Start GitAgent and request Gmail/Slack action. + +Expected behavior: + +- Tool is not registered or returns auth-required/permission-denied. +- Error explains context mismatch or missing connected account. + +Pass condition: + +- No local credential prompt appears. +- No raw OAuth tokens are exposed. + +### TC-C11: MCP Tool Discovery + +Objective: Validate MCP-backed tools if Gmail/Slack are exposed through MCP. + +Steps: + +1. Ensure an MCP server exists in Lyzr. +2. Call bridge discovery. +3. Confirm bridge calls: + - `GET /v3/tools/mcp/servers` + - `GET /v3/tools/mcp/servers/{server_id}/tools` + +Expected behavior: + +- MCP server tools are converted into GitAgent tools. +- Tool schema uses `ToolResponse.input_schema`. + +Pass condition: + +- MCP-backed tool is registered and callable. + +### TC-C12: MCP OAuth Flow + +Objective: Validate OAuth flow handoff if MCP server requires authorization. + +Steps: + +1. Use an MCP server requiring OAuth. +2. Start bridge discovery. +3. Trigger OAuth initiation if status is unauthenticated. + +Expected behavior: + +- Bridge calls `POST /v3/tools/mcp/servers/{server_id}/oauth/initiate`. +- User receives auth URL or equivalent next step. +- Bridge can poll/check `GET /v3/tools/mcp/servers/{server_id}/oauth/status?state=`. + +Pass condition: + +- User can authorize via Lyzr, and GitAgent does not request local credentials. + +## Part D: Regression Tests + +### TC-D01: Non-Lyzr Model Still Works + +Objective: Ensure bridge does not break OpenAI/Anthropic model use. + +Steps: + +1. Configure GitAgent with a non-Lyzr model. +2. Disable or omit `lyzr-tools`. +3. Run a normal prompt. + +Expected behavior: + +- GitAgent works as before. + +Pass condition: + +- No regression in non-Lyzr flows. + +### TC-D02: Lyzr Model Without Tool Bridge Still Works + +Objective: Ensure existing Lyzr model flow remains functional without tool bridge. + +Steps: + +1. Configure Lyzr model backend. +2. Do not enable `lyzr-tools`. +3. Ask a normal non-tool prompt. + +Expected behavior: + +- Model call works. +- No tool discovery is attempted. + +Pass condition: + +- Existing Lyzr chat behavior is preserved. + +### TC-D03: Tool Bridge Does Not Leak Credentials + +Objective: Verify secrets are redacted. + +Steps: + +1. Execute Gmail/Slack through bridge. +2. Inspect CLI logs, SDK events, telemetry, and Lyzr returned result. + +Expected behavior: + +- No OAuth access token, refresh token, client secret, Slack bot token, Gmail app password, or raw credential blob is printed. + +Pass condition: + +- Logs contain only safe IDs and execution status. + +### TC-D04: Tool Collision Handling + +Objective: Validate duplicate tool names are handled. + +Steps: + +1. Create a local tool with same name as a Lyzr bridge tool. +2. Start GitAgent. + +Expected behavior: + +- Collision is detected. +- Lyzr tool is prefixed or local duplicate is skipped according to product decision. + +Pass condition: + +- Startup does not silently select the wrong tool. + +## Acceptance Criteria Summary + +Implementation is considered successful when: + +- `LYZR_API_KEY` authenticates GitAgent to Lyzr tool APIs via `x-api-key`. +- GitAgent discovers Lyzr-authorized Gmail/Slack tools. +- GitAgent registers discovered tools as callable agent tools. +- GitAgent executes Gmail/Slack through Lyzr, not local credentials. +- Users with pre-authorized tools are not asked to authorize locally. +- Users without authorization receive a structured Lyzr auth-required response. +- No raw third-party OAuth tokens or app passwords are exposed to GitAgent users/logs. +- Existing non-Lyzr and Lyzr-model-only flows continue to work. diff --git a/plugins/lyzr-tools/README.md b/plugins/lyzr-tools/README.md new file mode 100644 index 0000000..23c9bb6 --- /dev/null +++ b/plugins/lyzr-tools/README.md @@ -0,0 +1,60 @@ +# lyzr-tools + +A gitagent plugin that discovers tools already authorized in [Lyzr](https://lyzr.ai) — Gmail, Slack, and other connected apps — and registers them as gitagent tools that execute **through Lyzr's server-side, pre-authorized credential vault**. Without this plugin, `LYZR_API_KEY` is only wired into gitagent's model path; tool calls fall back to local skills that need their own credentials (e.g. `GMAIL_USER`/`GMAIL_APP_PASSWORD`). + +See [`docs/lyzr-tool-auth-rca.md`](../../docs/lyzr-tool-auth-rca.md) for the full root-cause analysis and design, and [`docs/lyzr-tool-bridge-test-cases.md`](../../docs/lyzr-tool-bridge-test-cases.md) for the acceptance criteria this plugin targets. + +## What it does + +1. On load, reads `LYZR_API_KEY` (and related config) and calls Lyzr's `/v3` tool APIs to discover: + - Provider/app actions for each configured provider (default: `gmail`, `slack`) via `GET /v3/providers/tools/actions/{provider}`. + - Tools exposed through Lyzr MCP servers via `GET /v3/tools/mcp/servers` + `.../{server_id}/tools`. + - Which of those are already authorized for the configured user via `GET /v3/tools/credentials/connected_accounts`. +2. Registers each discovered tool as a gitagent tool named `lyzr__` (or `lyzr_mcp__` for MCP tools). +3. Executes tool calls by proxying to `POST /v3/inference/tools/execute` (provider/action tools) or `POST /v3/tools/mcp/tools/execute` (MCP tools). +4. If a tool isn't authorized, calling it returns a structured `authorization_required` result instead of asking for local credentials. +5. Adds prompt guidance telling the model to prefer `lyzr_*` tools over local duplicate skills (e.g. the bundled `gmail-email` skill). + +## Setup + +The plugin is enabled by default in this repo's `agent.yaml`. It no-ops (with a single warning log line) if `LYZR_API_KEY` isn't set — it will not attempt any network calls without a key. + +```bash +export LYZR_API_KEY="" +# Optional, defaults shown: +export LYZR_BASE_URL="https://agent-prod.studio.lyzr.ai" +export LYZR_USER_ID="" # needed to resolve authorization status +export GITAGENT_LYZR_AGENT_ID="" # needed for agent-level tool execution +export LYZR_TOOL_PROVIDERS="gmail,slack" # comma-separated provider identifiers to discover +``` + +Or configure it explicitly in `agent.yaml`: + +```yaml +plugins: + lyzr-tools: + enabled: true + config: + api_key: "${LYZR_API_KEY}" + base_url: "https://agent-prod.studio.lyzr.ai" + agent_id: "${GITAGENT_LYZR_AGENT_ID}" + user_id: "${LYZR_USER_ID}" + providers: "gmail,slack" + prefer_lyzr_tools: true +``` + +## Known limitations / open items + +- `GET /v3/tools/` and `GET /v3/tools/all/user` are not used as discovery sources: their Swagger response schema is a generic `{}` object with no documented shape to normalize. The client (`lib/client.ts`) still exposes them for future use once Lyzr documents a concrete response shape. +- The exact field pairing for `POST /v3/inference/tools/execute` (which value goes in the top-level `tool_name` vs. `ToolConfig.tool_name`) isn't fully pinned by the Swagger schema. `lib/execute.ts` documents the assumption made; this is flagged in the RCA as a "Remaining API Alignment Item" that needs product/API confirmation. +- Authorization-required detection uses HTTP status codes plus a keyword heuristic over the error body (`lib/execute.ts: detectAuthRequired`), since Lyzr doesn't yet document a stable `authorization_required` response shape for these endpoints. If/when Lyzr standardizes that shape, replace the heuristic with a direct field check. + +## Testing + +Unit tests live in [`test/lyzr-tools.test.ts`](../../test/lyzr-tools.test.ts) at the repo root (consistent with gitagent's existing `test/*.test.ts` convention) and run via: + +```bash +npm test +``` + +They exercise discovery (success, empty, provider errors), execution (success, error, authorization-required, MCP), redaction, and name normalization — all against a fake `LyzrClient`, with no real network calls. diff --git a/plugins/lyzr-tools/index.ts b/plugins/lyzr-tools/index.ts new file mode 100644 index 0000000..a1c8c18 --- /dev/null +++ b/plugins/lyzr-tools/index.ts @@ -0,0 +1,93 @@ +// lyzr-tools: Lyzr Tool Bridge plugin. +// +// Discovers tools already authorized in Lyzr (Gmail, Slack, and other +// connected apps) and registers them as gitagent tools that execute +// server-side through Lyzr, instead of requiring the user to separately +// authorize/configure them locally. +// +// See docs/lyzr-tool-auth-rca.md for the root-cause analysis and full +// design, and docs/lyzr-tool-bridge-test-cases.md for the acceptance +// criteria this implementation targets. + +import type { GitagentPluginApi } from "../../src/plugin-sdk.ts"; +import { createLyzrClient, type LyzrClient } from "./lib/client.ts"; +import { resolveConfig } from "./lib/config.ts"; +import { buildDedupePrompt } from "./lib/dedupe.ts"; +import { discoverLyzrTools } from "./lib/discover.ts"; +import { executeLyzrTool } from "./lib/execute.ts"; +import type { LyzrDiscoveredTool, ResolvedConfig } from "./lib/types.ts"; + +export async function register(api: GitagentPluginApi): Promise { + const config = resolveConfig(api.config); + + if (!config.apiKey) { + api.logger.warn( + 'api_key is not set (config "api_key" / env "LYZR_API_KEY"); lyzr-tools will not discover or register any tools.', + ); + return; + } + + const client = createLyzrClient(config); + await registerWithClient(api, config, client); +} + +export default register; + +/** + * Core registration logic, factored out from register() so it can be + * exercised in tests with a fake LyzrClient instead of real network calls. + */ +export async function registerWithClient( + api: GitagentPluginApi, + config: ResolvedConfig, + client: LyzrClient, +): Promise { + let tools: LyzrDiscoveredTool[] = []; + try { + const discovered = await discoverLyzrTools(client, config, api.logger); + tools = discovered.tools; + + api.logger.info( + `Discovered ${tools.length} Lyzr-backed tool(s) across ${discovered.stats.providersQueried} provider(s) and ${discovered.stats.mcpServersQueried} MCP server(s); ${discovered.stats.unauthorized} not yet authorized.`, + ); + if (discovered.stats.errors.length > 0) { + api.logger.warn(`Some discovery calls failed: ${discovered.stats.errors.join("; ")}`); + } + } catch (err: any) { + // Discovery must never take down the rest of plugin loading. + api.logger.error(`Discovery failed: ${err?.message ?? err}`); + return []; + } + + for (const tool of tools) { + api.registerTool({ + name: tool.toolName, + description: buildToolDescription(tool), + inputSchema: tool.inputSchema, + handler: async (args: Record) => { + try { + return await executeLyzrTool(client, config, tool, args ?? {}); + } catch (err: any) { + api.logger.error(`Execution of "${tool.toolName}" failed: ${err?.message ?? err}`); + return { + text: `Lyzr tool "${tool.displayName}" failed unexpectedly.`, + details: { status: "error", tool: tool.toolName }, + }; + } + }, + }); + } + + if (config.preferLyzrTools && tools.length > 0) { + api.addPrompt(buildDedupePrompt(tools)); + } + + return tools; +} + +function buildToolDescription(tool: LyzrDiscoveredTool): string { + const authNote = tool.authorized + ? " Executed through Lyzr's pre-authorized credentials." + : " Not yet authorized in Lyzr; calling it returns an authorization link."; + return `${tool.description}${tool.description.endsWith(".") ? "" : "."}${authNote}`; +} diff --git a/plugins/lyzr-tools/lib/client.ts b/plugins/lyzr-tools/lib/client.ts new file mode 100644 index 0000000..45fbf31 --- /dev/null +++ b/plugins/lyzr-tools/lib/client.ts @@ -0,0 +1,151 @@ +// Thin HTTP client over the Lyzr Agent API "/v3" tool-related endpoints. +// +// Endpoints and auth scheme (x-api-key header, per the `APIKeyHeader` +// securityScheme) are confirmed against the Lyzr Swagger document referenced +// in docs/lyzr-tool-auth-rca.md. Response shapes for /v3/tools/, +// /v3/tools/all/user, and /v3/providers/tools/all are documented as opaque +// generic objects in that Swagger, so callers of this client must normalize +// them defensively (see lib/discover.ts) rather than trusting a fixed shape. +// +// The client never throws on HTTP/network failure — every method resolves +// to a LyzrResult so callers can degrade gracefully instead of crashing +// plugin discovery (see docs/lyzr-tool-bridge-test-cases.md TC-C09). + +import type { ResolvedConfig } from "./types.ts"; + +export interface LyzrResult { + ok: boolean; + status?: number; + data?: T; + error?: string; +} + +export type FetchLike = (input: string, init?: RequestInit) => Promise; + +export interface LyzrClient { + listUserTools(): Promise; + listAllUserTools(): Promise; + listConnectedAccounts(userId: string): Promise; + listProviderActions( + providerIdentifier: string, + opts?: { toolSource?: string; appId?: string }, + ): Promise; + listAllProviderTools(): Promise; + listAciTools(): Promise; + listMcpServers(): Promise; + listMcpServerTools(serverId: string): Promise; + executeInferenceTool(payload: Record): Promise; + executeMcpTool(payload: Record): Promise; + initiateMcpOAuth(serverId: string): Promise; + getMcpOAuthStatus(serverId: string, state: string): Promise; +} + +function extractErrorMessage(data: unknown): string | undefined { + if (!data) return undefined; + if (typeof data === "string") return data; + if (typeof data === "object") { + const obj = data as Record; + if (typeof obj.error === "string") return obj.error; + if (typeof obj.message === "string") return obj.message; + if (typeof obj.detail === "string") return obj.detail; + } + return undefined; +} + +export function createLyzrClient( + config: Pick, + fetchImpl: FetchLike = fetch as FetchLike, +): LyzrClient { + const baseUrl = config.baseUrl.replace(/\/+$/, ""); + const timeoutMs = config.timeoutMs > 0 ? config.timeoutMs : 10_000; + + async function request( + method: string, + path: string, + opts: { query?: Record; body?: unknown } = {}, + ): Promise> { + const url = new URL(baseUrl + path); + if (opts.query) { + for (const [key, value] of Object.entries(opts.query)) { + if (value !== undefined && value !== "") url.searchParams.set(key, value); + } + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetchImpl(url.toString(), { + method, + headers: { + "x-api-key": config.apiKey, + ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: controller.signal, + }); + + const text = await res.text(); + let data: any; + if (text) { + try { + data = JSON.parse(text); + } catch { + data = text; + } + } + + if (!res.ok) { + return { + ok: false, + status: res.status, + data, + error: extractErrorMessage(data) ?? `HTTP ${res.status}`, + }; + } + return { ok: true, status: res.status, data }; + } catch (err: any) { + const message = err?.name === "AbortError" ? "Request timed out" : (err?.message ?? String(err)); + return { ok: false, error: message }; + } finally { + clearTimeout(timer); + } + } + + return { + listUserTools: () => request("GET", "/v3/tools/"), + + listAllUserTools: () => request("GET", "/v3/tools/all/user"), + + listConnectedAccounts: (userId: string) => + request("GET", "/v3/tools/credentials/connected_accounts", { query: { user_id: userId } }), + + listProviderActions: (providerIdentifier: string, opts: { toolSource?: string; appId?: string } = {}) => + request("GET", `/v3/providers/tools/actions/${encodeURIComponent(providerIdentifier)}`, { + query: { tool_source: opts.toolSource, app_id: opts.appId }, + }), + + listAllProviderTools: () => request("GET", "/v3/providers/tools/all"), + + listAciTools: () => request("GET", "/v3/providers/lyzr/aci-tools"), + + listMcpServers: () => request("GET", "/v3/tools/mcp/servers"), + + listMcpServerTools: (serverId: string) => + request("GET", `/v3/tools/mcp/servers/${encodeURIComponent(serverId)}/tools`), + + executeInferenceTool: (payload: Record) => + request("POST", "/v3/inference/tools/execute", { body: payload }), + + executeMcpTool: (payload: Record) => + request("POST", "/v3/tools/mcp/tools/execute", { body: payload }), + + initiateMcpOAuth: (serverId: string) => + request("POST", `/v3/tools/mcp/servers/${encodeURIComponent(serverId)}/oauth/initiate`), + + getMcpOAuthStatus: (serverId: string, state: string) => + request("GET", `/v3/tools/mcp/servers/${encodeURIComponent(serverId)}/oauth/status`, { + query: { state }, + }), + }; +} diff --git a/plugins/lyzr-tools/lib/config.ts b/plugins/lyzr-tools/lib/config.ts new file mode 100644 index 0000000..971e496 --- /dev/null +++ b/plugins/lyzr-tools/lib/config.ts @@ -0,0 +1,34 @@ +// Resolve the plugin's raw config (from plugin.yaml defaults / env vars / +// agent.yaml overrides, already merged by gitagent's plugin loader) into a +// strongly-typed ResolvedConfig. + +import type { ResolvedConfig } from "./types.ts"; + +const DEFAULT_BASE_URL = "https://agent-prod.studio.lyzr.ai"; +const DEFAULT_PROVIDERS = "gmail,slack"; +const DEFAULT_TIMEOUT_MS = 10_000; + +export function resolveConfig(raw: Record | undefined): ResolvedConfig { + const cfg = raw ?? {}; + + const providersRaw = typeof cfg.providers === "string" && cfg.providers.trim() ? cfg.providers : DEFAULT_PROVIDERS; + const providers = providersRaw + .split(",") + .map((p: string) => p.trim()) + .filter(Boolean); + + const timeoutMs = Number(cfg.timeout_ms); + + return { + apiKey: String(cfg.api_key ?? "").trim(), + baseUrl: String(cfg.base_url || DEFAULT_BASE_URL).replace(/\/+$/, ""), + agentId: cfg.agent_id ? String(cfg.agent_id) : undefined, + userId: cfg.user_id ? String(cfg.user_id) : undefined, + workspaceId: cfg.workspace_id ? String(cfg.workspace_id) : undefined, + providers, + includeMcp: cfg.include_mcp !== false, + preferLyzrTools: cfg.prefer_lyzr_tools !== false, + persistAuth: cfg.persist_auth !== false, + timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS, + }; +} diff --git a/plugins/lyzr-tools/lib/dedupe.ts b/plugins/lyzr-tools/lib/dedupe.ts new file mode 100644 index 0000000..952b212 --- /dev/null +++ b/plugins/lyzr-tools/lib/dedupe.ts @@ -0,0 +1,44 @@ +// Phase 4: Local duplicate tool deconfliction. +// +// gitagent can't disable a local skill from a plugin, so dedupe is done the +// same way the RCA proposes: prompt guidance naming the exact Lyzr-backed +// tool to use instead, plus the "lyzr_" prefix (see lib/normalize.ts) so +// tool names can never collide outright (docs/lyzr-tool-auth-rca.md Phase 4, +// docs/lyzr-tool-bridge-test-cases.md TC-C08/TC-D04). + +import type { LyzrDiscoveredTool } from "./types.ts"; + +// Known local bundled skills that duplicate a Lyzr-backed provider. +// Extend this as more bundled skills/tools ship with gitagent. +const KNOWN_LOCAL_DUPLICATES: Record = { + gmail: "the local gmail-email skill (Gmail SMTP with an app password)", +}; + +export function buildDedupePrompt(tools: LyzrDiscoveredTool[]): string { + if (tools.length === 0) return ""; + + const lines = [ + "### Lyzr-backed tools discovered for this session", + "", + "The following tools are proxied through Lyzr's pre-authorized credential vault. " + + "Prefer them over any local skill or script that duplicates the same connected app — " + + "the user does not need to provide app passwords, OAuth tokens, or other local credentials for these.", + "", + ]; + + for (const tool of tools) { + const duplicate = tool.provider ? KNOWN_LOCAL_DUPLICATES[tool.provider] : undefined; + const authNote = tool.authorized + ? "" + : " (not yet authorized in Lyzr — calling it returns an authorization link instead of asking for local credentials)"; + const duplicateNote = duplicate ? ` Use this instead of ${duplicate}.` : ""; + lines.push(`- \`${tool.toolName}\`: ${tool.description}${authNote}${duplicateNote}`); + } + + lines.push( + "", + 'If a call to one of these tools returns status "authorization_required", tell the user to authorize that app in Lyzr (using the auth_url if one is provided). Never ask for local API keys, app passwords, or OAuth tokens for a tool listed above.', + ); + + return lines.join("\n"); +} diff --git a/plugins/lyzr-tools/lib/discover.ts b/plugins/lyzr-tools/lib/discover.ts new file mode 100644 index 0000000..d21bede --- /dev/null +++ b/plugins/lyzr-tools/lib/discover.ts @@ -0,0 +1,249 @@ +// Phase 2: Tool discovery. +// +// Discovers tools from two Swagger-confirmed, typed-enough sources: +// +// 1. Provider/action discovery — GET /v3/providers/tools/actions/{provider} +// for each configured provider (e.g. "gmail", "slack"). This is the +// primary path for connected-app tools per docs/lyzr-tool-auth-rca.md. +// 2. MCP server tools — GET /v3/tools/mcp/servers + .../{server_id}/tools, +// which *is* fully typed in Swagger (MCPServerListResponse / +// ToolsListResponse). +// +// GET /v3/tools/ and /v3/tools/all/user are intentionally not used as a +// registration source: their Swagger response schema is a generic `{}` +// object, so there is no reliable field to normalize into a callable tool. +// They remain available on the client for future use once Lyzr documents a +// concrete shape (see RCA "Remaining API Alignment Item"). +// +// Connected-account status (GET /v3/tools/credentials/connected_accounts) +// is cross-referenced so each discovered tool carries an accurate +// `authorized` flag and, where available, the credential_id/provider_uuid +// needed for execution. + +import type { LyzrClient } from "./client.ts"; +import type { ConnectedAccount, LyzrDiscoveredTool, Logger, ResolvedConfig } from "./types.ts"; +import { normalizeProviderKey, normalizeToolName } from "./normalize.ts"; + +export interface DiscoveryStats { + providersQueried: number; + providerActionsFound: number; + mcpServersQueried: number; + mcpToolsFound: number; + unauthorized: number; + errors: string[]; +} + +export interface DiscoveryResult { + tools: LyzrDiscoveredTool[]; + stats: DiscoveryStats; +} + +export async function discoverLyzrTools( + client: LyzrClient, + config: ResolvedConfig, + logger: Logger, +): Promise { + const stats: DiscoveryStats = { + providersQueried: 0, + providerActionsFound: 0, + mcpServersQueried: 0, + mcpToolsFound: 0, + unauthorized: 0, + errors: [], + }; + const tools: LyzrDiscoveredTool[] = []; + const seenNames = new Set(); + + const connected = await fetchConnectedAccounts(client, config, stats, logger); + + await discoverProviderActions(client, config, connected, tools, seenNames, stats, logger); + + if (config.includeMcp) { + await discoverMcpTools(client, tools, seenNames, stats, logger); + } + + return { tools, stats }; +} + +// ── Provider/action discovery ────────────────────────────────────────── + +async function discoverProviderActions( + client: LyzrClient, + config: ResolvedConfig, + connected: Map, + tools: LyzrDiscoveredTool[], + seenNames: Set, + stats: DiscoveryStats, + logger: Logger, +): Promise { + for (const providerId of config.providers) { + stats.providersQueried++; + const res = await client.listProviderActions(providerId); + if (!res.ok) { + stats.errors.push(`provider "${providerId}": ${res.error}`); + logger.warn(`lyzr-tools: failed to list actions for provider "${providerId}": ${res.error}`); + continue; + } + + const actions = extractList(res.data, ["actions", "data", "items", "results"]); + const providerKey = providerId.toLowerCase(); + const connectedAccount = connected.get(providerKey); + + for (const action of actions) { + const actionName = String(action.name ?? action.action_name ?? action.id ?? "").trim(); + if (!actionName) continue; + + const toolName = normalizeToolName(`${providerId}_${actionName}`); + if (seenNames.has(toolName)) continue; + seenNames.add(toolName); + stats.providerActionsFound++; + + const authorized = connectedAccount?.authorized ?? false; + if (!authorized) stats.unauthorized++; + + tools.push({ + rawName: actionName, + toolName, + displayName: String(action.display_name ?? action.title ?? `${providerId} ${actionName}`).trim(), + description: String( + action.description ?? + action.desc ?? + `${actionName} action for ${providerId}, executed through Lyzr's pre-authorized credentials.`, + ), + inputSchema: normalizeInputSchema(action.input_schema ?? action.parameters ?? action.schema), + execSource: "agent", + provider: providerKey, + toolSource: providerId, + actionName, + actionNames: [actionName], + providerUuid: connectedAccount?.providerUuid ?? action.provider_uuid, + credentialId: connectedAccount?.credentialId, + authorized, + authUrl: connectedAccount?.authUrl, + }); + } + } +} + +// ── MCP discovery ─────────────────────────────────────────────────────── + +async function discoverMcpTools( + client: LyzrClient, + tools: LyzrDiscoveredTool[], + seenNames: Set, + stats: DiscoveryStats, + logger: Logger, +): Promise { + const serversRes = await client.listMcpServers(); + if (!serversRes.ok) { + stats.errors.push(`mcp servers: ${serversRes.error}`); + logger.warn(`lyzr-tools: failed to list MCP servers: ${serversRes.error}`); + return; + } + + const servers = extractList(serversRes.data, ["servers", "data", "items"]); + for (const server of servers) { + const serverId = String(server.id ?? server.server_id ?? "").trim(); + if (!serverId) continue; + stats.mcpServersQueried++; + + const toolsRes = await client.listMcpServerTools(serverId); + if (!toolsRes.ok) { + stats.errors.push(`mcp server "${serverId}": ${toolsRes.error}`); + logger.warn(`lyzr-tools: failed to list tools for MCP server "${serverId}": ${toolsRes.error}`); + continue; + } + + const data = toolsRes.data as { server_name?: string; tools?: Array> } | undefined; + const serverName = data?.server_name ?? server.name ?? serverId; + const providerKey = normalizeProviderKey(String(serverName)); + + // auth_type "oauth" without an active token means the server is not + // yet authorized; everything else (no_auth, api_key) is treated as + // already usable since Lyzr owns those credentials server-side. + const authType = String(server.auth_type ?? "").toLowerCase(); + const hasToken = Boolean(server.has_active_token ?? server.hasActiveToken); + const serverAuthorized = authType !== "oauth" || hasToken; + + for (const t of data?.tools ?? []) { + const actionName = String(t.name ?? "").trim(); + if (!actionName) continue; + + const toolName = normalizeToolName(`mcp_${serverName}_${actionName}`); + if (seenNames.has(toolName)) continue; + seenNames.add(toolName); + stats.mcpToolsFound++; + + if (!serverAuthorized) stats.unauthorized++; + + tools.push({ + rawName: actionName, + toolName, + displayName: String(t.display_name ?? actionName), + description: String(t.description ?? `${actionName} tool on MCP server "${serverName}", executed through Lyzr.`), + inputSchema: normalizeInputSchema(t.input_schema), + execSource: "mcp", + provider: providerKey, + serverId, + actionName, + authorized: serverAuthorized, + }); + } + } +} + +// ── Connected accounts ────────────────────────────────────────────────── + +async function fetchConnectedAccounts( + client: LyzrClient, + config: ResolvedConfig, + stats: DiscoveryStats, + logger: Logger, +): Promise> { + const map = new Map(); + if (!config.userId) return map; + + const res = await client.listConnectedAccounts(config.userId); + if (!res.ok) { + stats.errors.push(`connected_accounts: ${res.error}`); + logger.warn(`lyzr-tools: failed to list connected accounts: ${res.error}`); + return map; + } + + const entries = extractList(res.data, ["accounts", "data", "items", "connected_accounts"]); + for (const entry of entries) { + const provider = String( + entry.provider ?? entry.app_id ?? entry.provider_name ?? entry.tool_source ?? "", + ).toLowerCase(); + if (!provider) continue; + + const status = entry.status ? String(entry.status).toLowerCase() : undefined; + map.set(provider, { + authorized: status ? status !== "expired" && status !== "revoked" && status !== "disconnected" : true, + credentialId: entry.credential_id ?? entry.id, + providerUuid: entry.provider_uuid, + authUrl: entry.auth_url, + }); + } + return map; +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +function extractList(data: unknown, keys: string[]): Array> { + if (Array.isArray(data)) return data as Array>; + if (data && typeof data === "object") { + for (const key of keys) { + const v = (data as Record)[key]; + if (Array.isArray(v)) return v; + } + } + return []; +} + +function normalizeInputSchema(schema: unknown): { properties: Record; required?: string[] } { + if (schema && typeof schema === "object" && "properties" in (schema as Record)) { + return schema as { properties: Record; required?: string[] }; + } + return { properties: {} }; +} diff --git a/plugins/lyzr-tools/lib/execute.ts b/plugins/lyzr-tools/lib/execute.ts new file mode 100644 index 0000000..0701ee6 --- /dev/null +++ b/plugins/lyzr-tools/lib/execute.ts @@ -0,0 +1,151 @@ +// Phase 3: Tool execution proxy. +// +// Routes a tool call to the correct Lyzr execution surface and normalizes +// the result into gitagent's { text, details } tool-result shape. Three +// outcomes are distinguished: +// +// - authorization_required: the provider/MCP server is not (yet) +// authorized in Lyzr. The model is told to ask the user to authorize in +// Lyzr — never to collect local credentials (RCA "Assurance Model"). +// - error: execution failed for any other reason. +// - success: the call went through; Lyzr's result is returned verbatim. +// +// All `details` payloads are passed through redactSecrets() before being +// returned, so no credential/token/secret field can leak into logs or model +// context (docs/lyzr-tool-bridge-test-cases.md TC-D03). + +import type { LyzrClient } from "./client.ts"; +import type { LyzrDiscoveredTool, ResolvedConfig, ToolCallResult } from "./types.ts"; +import { redactSecrets } from "./redact.ts"; + +const AUTH_HINT_RE = + /(unauthor|not\s*connect|not\s*authoriz|authoriz(e|ation)\s*required|no\s*credential|missing\s*credential|reconnect|expired\s*token|invalid_grant|please\s*(re)?authenticate)/i; + +export function detectAuthRequired(status: number | undefined, body: unknown): boolean { + if (status === 401 || status === 403) return true; + if (body === undefined || body === null) return false; + const text = typeof body === "string" ? body : safeStringify(body); + return AUTH_HINT_RE.test(text); +} + +export async function executeLyzrTool( + client: LyzrClient, + config: ResolvedConfig, + tool: LyzrDiscoveredTool, + args: Record, +): Promise { + // Known-unauthorized at discovery time: don't even make the call, and + // never fall back to asking for local credentials. + if (!tool.authorized) { + return authRequiredResult(tool); + } + + if (tool.execSource === "mcp") { + return executeMcp(client, tool, args); + } + return executeAgentTool(client, config, tool, args); +} + +async function executeMcp( + client: LyzrClient, + tool: LyzrDiscoveredTool, + args: Record, +): Promise { + const res = await client.executeMcpTool({ + server_id: tool.serverId, + tool_name: tool.actionName ?? tool.rawName, + arguments: args, + }); + + if (!res.ok) { + if (detectAuthRequired(res.status, res.data ?? res.error)) return authRequiredResult(tool); + return errorResult(tool, res.error ?? "MCP tool execution failed"); + } + + const data = res.data as { success?: boolean; error?: string | null; result?: unknown } | undefined; + if (data && data.success === false) { + if (AUTH_HINT_RE.test(data.error ?? "")) return authRequiredResult(tool); + return errorResult(tool, data.error ?? "MCP tool execution failed"); + } + + return successResult(tool, data?.result); +} + +async function executeAgentTool( + client: LyzrClient, + config: ResolvedConfig, + tool: LyzrDiscoveredTool, + args: Record, +): Promise { + const traceId = `lyzr-tools-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + // NOTE: the exact pairing between the top-level `tool_name` and + // `ToolConfig` fields is not fully pinned down by the Swagger response + // schema for POST /v3/inference/tools/execute (flagged as a "Remaining + // API Alignment Item" in docs/lyzr-tool-auth-rca.md). This mapping is + // our best-effort interpretation: `tool_name` is the specific action to + // invoke, and `tool_configs[0]` describes the provider/credential + // context that action runs under. + const res = await client.executeInferenceTool({ + agent_id: config.agentId || undefined, + tool_name: tool.actionName ?? tool.rawName, + tool_configs: [ + { + tool_name: tool.toolSource ?? tool.provider ?? tool.rawName, + tool_source: tool.toolSource ?? tool.provider ?? "unknown", + action_names: + tool.actionNames && tool.actionNames.length > 0 ? tool.actionNames : [tool.actionName ?? tool.rawName], + persist_auth: config.persistAuth, + provider_uuid: tool.providerUuid, + credential_id: tool.credentialId, + }, + ], + arguments: args, + trace_id: traceId, + }); + + if (!res.ok) { + if (detectAuthRequired(res.status, res.data ?? res.error)) return authRequiredResult(tool); + return errorResult(tool, res.error ?? `Lyzr tool execution failed (HTTP ${res.status ?? "unknown"})`); + } + + const data = res.data as { result?: unknown; trace_id?: string } | undefined; + return successResult(tool, data?.result, data?.trace_id ?? traceId); +} + +// ── Result builders ────────────────────────────────────────────────────── + +function authRequiredResult(tool: LyzrDiscoveredTool): ToolCallResult { + return { + text: `Authorization required for ${tool.displayName}. Ask the user to authorize "${tool.provider ?? tool.displayName}" in Lyzr${tool.authUrl ? `: ${tool.authUrl}` : "."} Do not ask the user for local API keys, passwords, or OAuth tokens for this tool.`, + details: redactSecrets({ + status: "authorization_required", + provider: tool.provider ?? null, + tool: tool.toolName, + auth_url: tool.authUrl ?? null, + }), + }; +} + +function errorResult(tool: LyzrDiscoveredTool, message: string): ToolCallResult { + return { + text: `Lyzr tool "${tool.displayName}" failed: ${message}`, + details: redactSecrets({ status: "error", tool: tool.toolName, error: message }), + }; +} + +function successResult(tool: LyzrDiscoveredTool, result: unknown, traceId?: string): ToolCallResult { + const text = typeof result === "string" ? result : result === undefined ? "Done." : safeStringify(result); + return { + text, + details: redactSecrets({ status: "success", tool: tool.toolName, trace_id: traceId ?? null, result }), + }; +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} diff --git a/plugins/lyzr-tools/lib/normalize.ts b/plugins/lyzr-tools/lib/normalize.ts new file mode 100644 index 0000000..80a40c5 --- /dev/null +++ b/plugins/lyzr-tools/lib/normalize.ts @@ -0,0 +1,31 @@ +// Tool name normalization. +// +// gitagent tool names should be stable, collision-resistant identifiers. +// Every Lyzr-backed tool is prefixed with "lyzr_" so it can never collide +// with a local skill/tool of the same base name (e.g. a local "gmail" tool +// vs. Lyzr's "gmail" provider) — this is the RCA's "auto-prefixing" dedupe +// mitigation (see docs/lyzr-tool-auth-rca.md, Phase 4). + +export function normalizeToolName(raw: string): string { + const cleaned = raw + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") // strip diacritics + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/[\s-]+/g, "_") + .toLowerCase(); + + if (!cleaned) return "lyzr_tool"; + + const withPrefix = cleaned.startsWith("lyzr_") ? cleaned : `lyzr_${cleaned}`; + const result = withPrefix.replace(/_+/g, "_").replace(/^_+|_+$/g, ""); + return result === "lyzr" || !result ? "lyzr_tool" : result; +} + +/** Normalize a free-form provider/server label into a stable lowercase key. */ +export function normalizeProviderKey(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} diff --git a/plugins/lyzr-tools/lib/redact.ts b/plugins/lyzr-tools/lib/redact.ts new file mode 100644 index 0000000..3cead4f --- /dev/null +++ b/plugins/lyzr-tools/lib/redact.ts @@ -0,0 +1,35 @@ +// Secret redaction for logs and tool results. +// +// Only values whose *key* looks sensitive are masked. We deliberately do not +// try to pattern-match "secret-looking" strings by shape, because that would +// also mangle legitimate tool output (email bodies, message text, etc.) — +// see docs/lyzr-tool-bridge-test-cases.md TC-D03. + +const SENSITIVE_KEY_RE = + /(api[_-]?key|token|secret|password|passwd|credential|authorization|access_token|refresh_token|client_secret|bearer)/i; + +export function redactSecrets(value: T): T { + return redactValue(value) as T; +} + +function redactValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((v) => redactValue(v)); + } + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, v] of Object.entries(value as Record)) { + out[key] = SENSITIVE_KEY_RE.test(key) ? maskValue(v) : redactValue(v); + } + return out; + } + return value; +} + +function maskValue(v: unknown): string { + if (v === null || v === undefined) return "[redacted]"; + const s = typeof v === "string" ? v : JSON.stringify(v); + if (!s) return "[redacted]"; + if (s.length <= 4) return "****"; + return `${s.slice(0, 2)}${"*".repeat(Math.min(8, s.length - 4))}${s.slice(-2)}`; +} diff --git a/plugins/lyzr-tools/lib/types.ts b/plugins/lyzr-tools/lib/types.ts new file mode 100644 index 0000000..ed9e76e --- /dev/null +++ b/plugins/lyzr-tools/lib/types.ts @@ -0,0 +1,58 @@ +// Shared types for the lyzr-tools plugin. + +export interface ResolvedConfig { + apiKey: string; + baseUrl: string; + agentId?: string; + userId?: string; + workspaceId?: string; + providers: string[]; + includeMcp: boolean; + preferLyzrTools: boolean; + persistAuth: boolean; + timeoutMs: number; +} + +export interface ConnectedAccount { + authorized: boolean; + credentialId?: string; + providerUuid?: string; + authUrl?: string; +} + +/** A tool discovered from Lyzr, normalized into a shape gitagent can register. */ +export interface LyzrDiscoveredTool { + /** The raw action/tool name as reported by Lyzr. */ + rawName: string; + /** The gitagent-safe, prefixed tool name (e.g. "lyzr_gmail_send_email"). */ + toolName: string; + displayName: string; + description: string; + inputSchema: { properties: Record; required?: string[] }; + /** Which Lyzr execution surface this tool must be routed through. */ + execSource: "agent" | "mcp"; + /** Normalized provider key, e.g. "gmail", used for auth lookups and dedupe hints. */ + provider?: string; + /** Provider/tool-source identifier as understood by Lyzr's ToolConfig. */ + toolSource?: string; + actionName?: string; + actionNames?: string[]; + providerUuid?: string; + credentialId?: string; + /** MCP server id, only set when execSource === "mcp". */ + serverId?: string; + /** Whether Lyzr currently reports this provider/tool as authorized for the configured user. */ + authorized: boolean; + authUrl?: string; +} + +export interface ToolCallResult { + text: string; + details?: unknown; +} + +export interface Logger { + info(msg: string): void; + warn(msg: string): void; + error(msg: string): void; +} diff --git a/plugins/lyzr-tools/plugin.yaml b/plugins/lyzr-tools/plugin.yaml new file mode 100644 index 0000000..603b95d --- /dev/null +++ b/plugins/lyzr-tools/plugin.yaml @@ -0,0 +1,57 @@ +id: lyzr-tools +name: Lyzr Tool Bridge +version: 0.1.0 +description: Discovers tools already authorized in Lyzr (Gmail, Slack, and other connected apps) and proxies execution through Lyzr instead of requiring local credentials. +author: open-gitagent +license: MIT + +provides: + prompt: prompt.md + +config: + properties: + api_key: + type: string + description: Lyzr API key, sent as the x-api-key header on every request. + env: LYZR_API_KEY + base_url: + type: string + description: Lyzr Agent API base URL. + default: "https://agent-prod.studio.lyzr.ai" + env: LYZR_BASE_URL + agent_id: + type: string + description: Lyzr agent ID used for agent-level tool execution (POST /v3/inference/tools/execute). + env: GITAGENT_LYZR_AGENT_ID + user_id: + type: string + description: Lyzr user ID used to look up connected accounts and authorization status. + env: LYZR_USER_ID + workspace_id: + type: string + description: Optional Lyzr workspace/org ID, for future scoping needs. + env: LYZR_WORKSPACE_ID + providers: + type: string + description: Comma-separated Lyzr provider identifiers to discover actions for. + default: "gmail,slack" + env: LYZR_TOOL_PROVIDERS + include_mcp: + type: boolean + description: Also discover and register tools exposed through Lyzr MCP servers. + default: true + prefer_lyzr_tools: + type: boolean + description: Add prompt guidance telling the model to prefer Lyzr-backed tools over local duplicate skills. + default: true + persist_auth: + type: boolean + description: Passed through as ToolConfig.persist_auth on agent-level tool execution requests. + default: true + timeout_ms: + type: number + description: HTTP request timeout for Lyzr API calls, in milliseconds. + default: 10000 + required: [api_key] + +entry: index.ts diff --git a/plugins/lyzr-tools/prompt.md b/plugins/lyzr-tools/prompt.md new file mode 100644 index 0000000..ba8d5f4 --- /dev/null +++ b/plugins/lyzr-tools/prompt.md @@ -0,0 +1,5 @@ +## Lyzr-backed tools + +Tools prefixed with `lyzr_` are proxied through Lyzr's server-side, pre-authorized credential vault — not executed locally. Prefer them over any local skill or script for the same connected app (for example, prefer a `lyzr_gmail_*` tool over a local Gmail SMTP skill) whenever one is available. + +The user does not need to provide app passwords, OAuth tokens, or other local credentials for these tools. If a call returns `status: "authorization_required"`, tell the user to authorize that app in Lyzr (using the `auth_url` if one is included) — do not ask them to set up local credentials instead. diff --git a/test/lyzr-tools.test.ts b/test/lyzr-tools.test.ts new file mode 100644 index 0000000..9a69775 --- /dev/null +++ b/test/lyzr-tools.test.ts @@ -0,0 +1,479 @@ +// Unit tests for the lyzr-tools plugin (plugins/lyzr-tools). +// +// Strategy: exercise the plugin's library modules directly against a fake +// LyzrClient — no real network calls, no dependency on a live Lyzr account. +// This covers docs/lyzr-tool-bridge-test-cases.md Part C/D scenarios that +// don't require an actual GitAgent process or Lyzr backend. + +import test, { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import type { LyzrClient, LyzrResult } from "../plugins/lyzr-tools/lib/client.ts"; +import { resolveConfig } from "../plugins/lyzr-tools/lib/config.ts"; +import { buildDedupePrompt } from "../plugins/lyzr-tools/lib/dedupe.ts"; +import { discoverLyzrTools } from "../plugins/lyzr-tools/lib/discover.ts"; +import { detectAuthRequired, executeLyzrTool } from "../plugins/lyzr-tools/lib/execute.ts"; +import { normalizeProviderKey, normalizeToolName } from "../plugins/lyzr-tools/lib/normalize.ts"; +import { redactSecrets } from "../plugins/lyzr-tools/lib/redact.ts"; +import type { LyzrDiscoveredTool, Logger, ResolvedConfig } from "../plugins/lyzr-tools/lib/types.ts"; +import { registerWithClient } from "../plugins/lyzr-tools/index.ts"; +import type { GitagentPluginApi } from "../src/plugin-sdk.ts"; + +// ── Test scaffolding ─────────────────────────────────────────────────── + +function ok(data: T): LyzrResult { + return { ok: true, status: 200, data }; +} + +function fail(status: number, data?: unknown, error?: string): LyzrResult { + return { ok: false, status, data, error: error ?? `HTTP ${status}` }; +} + +function silentLogger(): Logger & { messages: string[] } { + const messages: string[] = []; + return { + messages, + info: (m) => messages.push(`info: ${m}`), + warn: (m) => messages.push(`warn: ${m}`), + error: (m) => messages.push(`error: ${m}`), + }; +} + +function fakeClient(overrides: Partial = {}): LyzrClient & { calls: Array<{ method: string; args: any[] }> } { + const calls: Array<{ method: string; args: any[] }> = []; + const record = + (method: string, fn: (...args: A) => Promise) => + async (...args: A) => { + calls.push({ method, args }); + return fn(...args); + }; + + const defaults: LyzrClient = { + listUserTools: async () => ok([]), + listAllUserTools: async () => ok([]), + listConnectedAccounts: async () => ok([]), + listProviderActions: async () => ok([]), + listAllProviderTools: async () => ok([]), + listAciTools: async () => ok([]), + listMcpServers: async () => ok([]), + listMcpServerTools: async () => ok({ tools: [] }), + executeInferenceTool: async () => ok({ result: "ok", trace_id: "t-1" }), + executeMcpTool: async () => ok({ success: true, result: ["ok"] }), + initiateMcpOAuth: async () => ok({ auth_url: "https://lyzr.example/oauth" }), + getMcpOAuthStatus: async () => ok({ status: "pending" }), + }; + + const merged = { ...defaults, ...overrides }; + const wrapped: any = {}; + for (const key of Object.keys(merged) as (keyof LyzrClient)[]) { + wrapped[key] = record(key, merged[key] as any); + } + return { ...wrapped, calls }; +} + +function baseConfig(overrides: Partial = {}): ResolvedConfig { + return { + apiKey: "test-key", + baseUrl: "https://agent-prod.studio.lyzr.ai", + agentId: "agent-1", + userId: "user-1", + providers: ["gmail", "slack"], + includeMcp: true, + preferLyzrTools: true, + persistAuth: true, + timeoutMs: 5000, + ...overrides, + }; +} + +// ── normalize.ts ─────────────────────────────────────────────────────── + +describe("normalizeToolName", () => { + it("prefixes with lyzr_", () => { + assert.equal(normalizeToolName("gmail_send_email"), "lyzr_gmail_send_email"); + }); + + it("does not double-prefix an already-prefixed name", () => { + assert.equal(normalizeToolName("lyzr_gmail_send_email"), "lyzr_gmail_send_email"); + }); + + it("strips special characters and collapses whitespace/dashes", () => { + assert.equal(normalizeToolName("Gmail: Send Email! (v2)"), "lyzr_gmail_send_email_v2"); + }); + + it("falls back to a safe default for empty input", () => { + assert.equal(normalizeToolName(""), "lyzr_tool"); + }); +}); + +describe("normalizeProviderKey", () => { + it("lowercases and replaces non-alphanumeric runs with underscores", () => { + assert.equal(normalizeProviderKey("Google Workspace MCP"), "google_workspace_mcp"); + }); +}); + +// ── redact.ts ────────────────────────────────────────────────────────── + +describe("redactSecrets", () => { + it("masks values under sensitive keys", () => { + const redacted: any = redactSecrets({ + credential_id: "cred_abcdef123456", + access_token: "shpat_1234567890abcdef", + nested: { client_secret: "s3cr3tvalue" }, + }); + assert.notEqual(redacted.credential_id, "cred_abcdef123456"); + assert.ok(!String(redacted.credential_id).includes("abcdef123456")); + assert.notEqual(redacted.access_token, "shpat_1234567890abcdef"); + assert.notEqual(redacted.nested.client_secret, "s3cr3tvalue"); + }); + + it("leaves non-sensitive fields (including long text) untouched", () => { + const body = "This came through Lyzr. ".repeat(5); + const redacted: any = redactSecrets({ status: "success", result: body, tool: "lyzr_gmail_send_email" }); + assert.equal(redacted.result, body); + assert.equal(redacted.status, "success"); + assert.equal(redacted.tool, "lyzr_gmail_send_email"); + }); + + it("handles arrays and nested structures", () => { + const redacted: any = redactSecrets({ accounts: [{ token: "abc123def456" }, { token: "xyz789uvw012" }] }); + assert.notEqual(redacted.accounts[0].token, "abc123def456"); + assert.notEqual(redacted.accounts[1].token, "xyz789uvw012"); + }); +}); + +// ── discover.ts ──────────────────────────────────────────────────────── + +describe("discoverLyzrTools", () => { + it("discovers provider actions and marks authorization from connected accounts", async () => { + const client = fakeClient({ + listConnectedAccounts: async () => + ok([ + { provider: "gmail", status: "connected", credential_id: "cred-gmail-1", provider_uuid: "puid-1" }, + ]), + listProviderActions: async (providerId: string) => { + if (providerId === "gmail") { + return ok({ actions: [{ name: "send_email", description: "Send an email via Gmail." }] }); + } + if (providerId === "slack") { + return ok({ actions: [{ name: "send_message", description: "Send a Slack message." }] }); + } + return ok({ actions: [] }); + }, + }); + + const { tools, stats } = await discoverLyzrTools(client, baseConfig(), silentLogger()); + + assert.equal(tools.length, 2); + const gmail = tools.find((t) => t.provider === "gmail")!; + const slack = tools.find((t) => t.provider === "slack")!; + + assert.equal(gmail.toolName, "lyzr_gmail_send_email"); + assert.equal(gmail.authorized, true); + assert.equal(gmail.credentialId, "cred-gmail-1"); + assert.equal(gmail.execSource, "agent"); + + assert.equal(slack.toolName, "lyzr_slack_send_message"); + assert.equal(slack.authorized, false); // not present in connected accounts + assert.equal(stats.unauthorized, 1); + assert.equal(stats.providerActionsFound, 2); + }); + + it("returns no tools when providers have no actions", async () => { + const client = fakeClient({ listProviderActions: async () => ok({ actions: [] }) }); + const { tools, stats } = await discoverLyzrTools(client, baseConfig(), silentLogger()); + assert.equal(tools.length, 0); + assert.equal(stats.providerActionsFound, 0); + assert.equal(stats.errors.length, 0); + }); + + it("degrades gracefully when a provider call fails, without throwing", async () => { + const client = fakeClient({ + listProviderActions: async (providerId: string) => { + if (providerId === "gmail") return fail(401, { error: "invalid api key" }); + return ok({ actions: [{ name: "send_message", description: "Send a Slack message." }] }); + }, + }); + + const { tools, stats } = await discoverLyzrTools(client, baseConfig(), silentLogger()); + assert.equal(tools.length, 1); + assert.equal(tools[0].provider, "slack"); + assert.equal(stats.errors.length, 1); + assert.match(stats.errors[0], /gmail/); + }); + + it("discovers MCP server tools and treats non-oauth servers as authorized", async () => { + const client = fakeClient({ + listProviderActions: async () => ok({ actions: [] }), + listMcpServers: async () => ok({ servers: [{ id: "srv-1", name: "Notion", auth_type: "api_key" }] }), + listMcpServerTools: async (serverId: string) => { + assert.equal(serverId, "srv-1"); + return ok({ server_name: "Notion", tools: [{ name: "search_pages", description: "Search Notion pages." }] }); + }, + }); + + const { tools, stats } = await discoverLyzrTools(client, baseConfig(), silentLogger()); + assert.equal(tools.length, 1); + assert.equal(tools[0].execSource, "mcp"); + assert.equal(tools[0].toolName, "lyzr_mcp_notion_search_pages"); + assert.equal(tools[0].authorized, true); + assert.equal(stats.mcpServersQueried, 1); + assert.equal(stats.mcpToolsFound, 1); + }); + + it("marks oauth MCP servers without an active token as unauthorized", async () => { + const client = fakeClient({ + listProviderActions: async () => ok({ actions: [] }), + listMcpServers: async () => + ok({ servers: [{ id: "srv-2", name: "Linear", auth_type: "oauth", has_active_token: false }] }), + listMcpServerTools: async () => ok({ server_name: "Linear", tools: [{ name: "create_issue" }] }), + }); + + const { tools } = await discoverLyzrTools(client, baseConfig(), silentLogger()); + assert.equal(tools.length, 1); + assert.equal(tools[0].authorized, false); + }); + + it("skips MCP discovery entirely when include_mcp is false", async () => { + const client = fakeClient({ listProviderActions: async () => ok({ actions: [] }) }); + const { stats } = await discoverLyzrTools(client, baseConfig({ includeMcp: false }), silentLogger()); + assert.equal(stats.mcpServersQueried, 0); + assert.equal(client.calls.some((c) => c.method === "listMcpServers"), false); + }); +}); + +// ── execute.ts ───────────────────────────────────────────────────────── + +function makeTool(overrides: Partial = {}): LyzrDiscoveredTool { + return { + rawName: "send_email", + toolName: "lyzr_gmail_send_email", + displayName: "Gmail: Send Email", + description: "Send an email via Gmail.", + inputSchema: { properties: {} }, + execSource: "agent", + provider: "gmail", + toolSource: "gmail", + actionName: "send_email", + actionNames: ["send_email"], + authorized: true, + ...overrides, + }; +} + +describe("detectAuthRequired", () => { + it("is true for 401/403", () => { + assert.equal(detectAuthRequired(401, {}), true); + assert.equal(detectAuthRequired(403, {}), true); + }); + + it("is true when the error body hints at authorization", () => { + assert.equal(detectAuthRequired(400, { error: "Gmail is not connected for this user" }), true); + assert.equal(detectAuthRequired(500, "Please reauthenticate with the provider"), true); + }); + + it("is false for benign errors", () => { + assert.equal(detectAuthRequired(400, { error: "recipient address is invalid" }), false); + assert.equal(detectAuthRequired(undefined, undefined), false); + }); +}); + +describe("executeLyzrTool", () => { + it("returns authorization_required without calling the API when the tool is unauthorized", async () => { + const client = fakeClient(); + const tool = makeTool({ authorized: false, authUrl: "https://lyzr.example/authorize/gmail" }); + + const result = await executeLyzrTool(client, baseConfig(), tool, {}); + + assert.match(result.text, /[Aa]uthorization required/); + assert.match(result.text, /lyzr.example\/authorize\/gmail/); + assert.doesNotMatch(result.text, /GMAIL_APP_PASSWORD|GMAIL_USER/i); + assert.deepEqual(result.details, { + status: "authorization_required", + provider: "gmail", + tool: "lyzr_gmail_send_email", + auth_url: "https://lyzr.example/authorize/gmail", + }); + assert.equal(client.calls.length, 0); + }); + + it("executes an authorized agent-level tool and returns the result", async () => { + const client = fakeClient({ + executeInferenceTool: async (payload: any) => { + assert.equal(payload.tool_name, "send_email"); + assert.equal(payload.tool_configs[0].tool_source, "gmail"); + return ok({ result: "Email sent to qa@example.com", trace_id: "trace-123" }); + }, + }); + + const result = await executeLyzrTool(client, baseConfig(), makeTool(), { to: "qa@example.com" }); + + assert.equal(result.text, "Email sent to qa@example.com"); + assert.equal((result.details as any).status, "success"); + assert.equal((result.details as any).trace_id, "trace-123"); + }); + + it("maps a runtime 401 into authorization_required even if discovery thought it was authorized", async () => { + const client = fakeClient({ + executeInferenceTool: async () => fail(401, { error: "credential expired" }), + }); + + const result = await executeLyzrTool(client, baseConfig(), makeTool({ authorized: true }), {}); + assert.equal((result.details as any).status, "authorization_required"); + }); + + it("returns a redacted error result on failure, without leaking secret fields", async () => { + const client = fakeClient({ + executeInferenceTool: async () => + fail(500, { error: "provider timeout", credential_id: "cred_super_secret_value" }, "provider timeout"), + }); + + const result = await executeLyzrTool(client, baseConfig(), makeTool(), {}); + + assert.equal((result.details as any).status, "error"); + assert.match(result.text, /provider timeout/); + assert.doesNotMatch(JSON.stringify(result.details), /cred_super_secret_value/); + }); + + it("routes MCP tools through executeMcpTool and reports MCP failures", async () => { + const client = fakeClient({ + executeMcpTool: async (payload: any) => { + assert.equal(payload.server_id, "srv-1"); + return ok({ success: false, error: "rate limited" }); + }, + }); + + const tool = makeTool({ execSource: "mcp", serverId: "srv-1", provider: "notion", actionName: "search_pages" }); + const result = await executeLyzrTool(client, baseConfig(), tool, {}); + assert.equal((result.details as any).status, "error"); + assert.match(result.text, /rate limited/); + }); + + it("maps MCP authorization errors into authorization_required", async () => { + const client = fakeClient({ + executeMcpTool: async () => ok({ success: false, error: "server is not authorized, please reconnect" }), + }); + const tool = makeTool({ execSource: "mcp", serverId: "srv-1", provider: "notion" }); + const result = await executeLyzrTool(client, baseConfig(), tool, {}); + assert.equal((result.details as any).status, "authorization_required"); + }); +}); + +// ── dedupe.ts ────────────────────────────────────────────────────────── + +describe("buildDedupePrompt", () => { + it("returns an empty string for no tools", () => { + assert.equal(buildDedupePrompt([]), ""); + }); + + it("names the known local duplicate for gmail", () => { + const prompt = buildDedupePrompt([makeTool()]); + assert.match(prompt, /lyzr_gmail_send_email/); + assert.match(prompt, /gmail-email skill/); + }); + + it("notes unauthorized tools distinctly", () => { + const prompt = buildDedupePrompt([makeTool({ authorized: false, provider: "slack", toolName: "lyzr_slack_send_message" })]); + assert.match(prompt, /not yet authorized/); + }); +}); + +// ── config.ts ────────────────────────────────────────────────────────── + +describe("resolveConfig", () => { + it("applies defaults when given an empty config", () => { + const cfg = resolveConfig({}); + assert.equal(cfg.apiKey, ""); + assert.equal(cfg.baseUrl, "https://agent-prod.studio.lyzr.ai"); + assert.deepEqual(cfg.providers, ["gmail", "slack"]); + assert.equal(cfg.includeMcp, true); + assert.equal(cfg.preferLyzrTools, true); + }); + + it("splits and trims the providers list", () => { + const cfg = resolveConfig({ providers: " gmail , slack ,notion" }); + assert.deepEqual(cfg.providers, ["gmail", "slack", "notion"]); + }); + + it("strips trailing slashes from base_url", () => { + const cfg = resolveConfig({ base_url: "https://example.com/" }); + assert.equal(cfg.baseUrl, "https://example.com"); + }); +}); + +// ── index.ts (registerWithClient) ─────────────────────────────────────── + +function fakeApi(config: Record = {}): GitagentPluginApi & { + registeredTools: any[]; + promptAdditions: string[]; + logMessages: string[]; +} { + const registeredTools: any[] = []; + const promptAdditions: string[] = []; + const logMessages: string[] = []; + return { + pluginId: "lyzr-tools", + pluginDir: "/fake/plugins/lyzr-tools", + config, + registerTool: (def) => registeredTools.push(def), + registerHook: () => {}, + addPrompt: (text: string) => promptAdditions.push(text), + registerMemoryLayer: () => {}, + logger: { + info: (m: string) => logMessages.push(`info: ${m}`), + warn: (m: string) => logMessages.push(`warn: ${m}`), + error: (m: string) => logMessages.push(`error: ${m}`), + }, + registeredTools, + promptAdditions, + logMessages, + }; +} + +describe("registerWithClient", () => { + it("registers a lyzr_-prefixed tool per discovered action and adds dedupe prompt guidance", async () => { + const client = fakeClient({ + listConnectedAccounts: async () => ok([{ provider: "gmail", status: "connected" }]), + listProviderActions: async (providerId: string) => + providerId === "gmail" + ? ok({ actions: [{ name: "send_email", description: "Send an email via Gmail." }] }) + : ok({ actions: [] }), + }); + const api = fakeApi(); + + const tools = await registerWithClient(api, baseConfig({ providers: ["gmail"] }), client); + + assert.equal(tools.length, 1); + assert.equal(api.registeredTools.length, 1); + assert.equal(api.registeredTools[0].name, "lyzr_gmail_send_email"); + assert.equal(typeof api.registeredTools[0].handler, "function"); + assert.equal(api.promptAdditions.length, 1); + assert.match(api.promptAdditions[0], /lyzr_gmail_send_email/); + assert.match(api.promptAdditions[0], /gmail-email skill/); + }); + + it("registered tool handlers proxy execution through the client", async () => { + const client = fakeClient({ + listConnectedAccounts: async () => ok([{ provider: "gmail", status: "connected" }]), + listProviderActions: async () => ok({ actions: [{ name: "send_email", description: "Send email." }] }), + executeInferenceTool: async () => ok({ result: "sent!" }), + }); + const api = fakeApi(); + await registerWithClient(api, baseConfig({ providers: ["gmail"] }), client); + + const handlerResult = await api.registeredTools[0].handler({ to: "qa@example.com" }); + assert.equal(handlerResult.text, "sent!"); + assert.ok(client.calls.some((c) => c.method === "executeInferenceTool")); + }); + + it("registers nothing and does not throw when discovery finds no tools", async () => { + const client = fakeClient({ listProviderActions: async () => ok({ actions: [] }) }); + const api = fakeApi(); + + const tools = await registerWithClient(api, baseConfig(), client); + + assert.equal(tools.length, 0); + assert.equal(api.registeredTools.length, 0); + assert.equal(api.promptAdditions.length, 0); + }); +}); From 30060cb48b1582490e1a2cae8d6f1423037ec18c Mon Sep 17 00:00:00 2001 From: Akshat Kumar Date: Thu, 13 Aug 2026 17:34:35 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(lyzr-tools):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20agent-based=20discovery=20+=20redaction=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Discover tools from the agent's own tool_configs (GET /v3/agents/{id}) instead of per-provider listProviderActions calls: a real agent's tool_configs already carry human-named connected integrations (tool_name, tool_source, action_names, provider_uuid, credential_id), which is confirmed correct against a live account. This sidesteps the missing tool_source query param that made the old path 400 on the default gmail,slack config. - Warn when user_id is unset instead of silently marking every tool unauthorized with no diagnostic trail. - Normalize OpenAI/ACI-style array input schemas instead of dropping them to an empty {properties: {}}. - Redact token-shaped strings by pattern, not just by key name, so a raw OAuth token returned under an innocuous key (e.g. `result`) still gets masked; document the residual risk in README. - Add rendered HTML copies of the RCA and test-case docs. - agent.yaml: add missing trailing newline. Co-Authored-By: Claude Sonnet 5 --- agent.yaml | 2 +- docs/lyzr-tool-auth-rca.html | 159 +++++++++++++++++++++++ docs/lyzr-tool-bridge-test-cases.html | 1 + plugins/lyzr-tools/README.md | 21 +-- plugins/lyzr-tools/index.ts | 2 +- plugins/lyzr-tools/lib/client.ts | 3 + plugins/lyzr-tools/lib/config.ts | 9 -- plugins/lyzr-tools/lib/discover.ts | 154 ++++++++++++++-------- plugins/lyzr-tools/lib/execute.ts | 42 +++--- plugins/lyzr-tools/lib/redact.ts | 26 +++- plugins/lyzr-tools/lib/types.ts | 20 ++- plugins/lyzr-tools/plugin.yaml | 13 +- test/lyzr-tools.test.ts | 177 +++++++++++++++++--------- 13 files changed, 454 insertions(+), 175 deletions(-) create mode 100644 docs/lyzr-tool-auth-rca.html create mode 100644 docs/lyzr-tool-bridge-test-cases.html diff --git a/agent.yaml b/agent.yaml index 00014f3..39f6722 100644 --- a/agent.yaml +++ b/agent.yaml @@ -19,4 +19,4 @@ plugins: # it never makes a network call without an API key. See # plugins/lyzr-tools/README.md for full configuration options. config: - api_key: "${LYZR_API_KEY}" \ No newline at end of file + api_key: "${LYZR_API_KEY}" diff --git a/docs/lyzr-tool-auth-rca.html b/docs/lyzr-tool-auth-rca.html new file mode 100644 index 0000000..9d267b8 --- /dev/null +++ b/docs/lyzr-tool-auth-rca.html @@ -0,0 +1,159 @@ +RCA: Lyzr Pre-Authorized Tools Not Available in GitAgent

RCA: Lyzr Pre-Authorized Tools Not Available in GitAgent

Date: 2026-07-15

Repository: open-gitagent/gitagent

Scope: GitAgent + Lyzr tool authorization behavior for Gmail, Slack, and similar ecosystem tools.

Executive Summary

Users authenticating GitAgent with LYZR_API_KEY still need to separately authorize tools such as Gmail and Slack because GitAgent currently uses the Lyzr key only as a model/backend credential. It does not discover, import, proxy, or execute Lyzr ecosystem tools through Lyzr's credential vault.

The current GitAgent implementation executes tools locally through built-in tools, local skills, declarative scripts, SDK-provided tools, or plugins. As a result, any local Gmail or Slack tool must bring its own credentials. The bundled Gmail skill demonstrates this clearly: it sends mail through Gmail SMTP using GMAIL_USER and GMAIL_APP_PASSWORD, independent of Lyzr.

The recommended way forward is to implement a Lyzr Tool Bridge plugin/provider. This bridge should discover already-authorized Lyzr tools for the current Lyzr user/workspace/agent, register those tools in GitAgent, and forward tool executions to Lyzr server-side. Lyzr would then execute the requested action with credentials already stored in the Lyzr ecosystem.

Impact

  • Users see a duplicated authorization flow for tools they have already authorized in Lyzr.
  • GitAgent cannot reliably know which Lyzr ecosystem tools are available.
  • GitAgent may select local duplicate skills, such as Gmail SMTP, instead of Lyzr-native OAuth-backed tools.
  • Security posture is weaker if users are encouraged to place third-party app passwords or OAuth tokens in local environment files.
  • Product experience is inconsistent: the model is Lyzr-backed, but tool execution is not Lyzr-backed.

Root Cause

Primary Root Cause

GitAgent does not have a tool-execution integration with Lyzr's authorized connector/tool layer. The Lyzr API key is wired into the model path, not the tool path.

Evidence:

  • examples/lyzr-sdk.ts reads LYZR_API_KEY and maps it into OPENAI_API_KEY for OpenAI-compatible model access. See examples/lyzr-sdk.ts:15-24.
  • The same example configures the model as lyzr:<agent-id>@https://agent-prod.studio.lyzr.ai/v4. See examples/lyzr-sdk.ts:36-38.
  • src/loader.ts creates a custom OpenAI-compatible model when the model string contains @baseUrl. See src/loader.ts:81-97 and src/loader.ts:393-400.
  • src/loader.ts uses LYZR_API_KEY only as a provider key fallback for custom providers so pi-ai can resolve an API key. See src/loader.ts:406-419.

Contributing Cause 1: Tool Execution Is Local by Default

GitAgent builds tools locally and passes them into pi-agent-core.

Evidence:

  • CLI path builds built-in tools, declarative tools, and plugin tools before creating the Agent. See src/index.ts:532-569 and src/index.ts:589-596.
  • SDK path does the same with built-ins, declarative tools, plugin tools, and SDK tools. See src/sdk.ts:176-244 and src/sdk.ts:301-309.
  • Built-in tools are local filesystem/shell/memory tools. See src/tools/index.ts:31-58.
  • Declarative tools execute local scripts via spawn, passing JSON args through stdin. See src/tool-loader.ts:50-75 and src/tool-loader.ts:87-156.

Contributing Cause 2: Gmail Skill Uses Independent SMTP Credentials

The bundled Gmail skill is not a Lyzr ecosystem tool. It requires Gmail SMTP credentials and does not use Lyzr authorization state.

Evidence:

  • The Gmail skill describes itself as SMTP with App Password authentication. See skills/gmail-email/SKILL.md:1-4.
  • The setup instructions require GMAIL_USER and GMAIL_APP_PASSWORD. See skills/gmail-email/SKILL.md:19-29.
  • The script reads GMAIL_USER and GMAIL_APP_PASSWORD from environment variables. See skills/gmail-email/scripts/send_email.py:24-30.
  • Missing local Gmail credentials trigger an error instructing users to set Gmail credentials. See skills/gmail-email/scripts/send_email.py:31-44.
  • The script connects directly to Gmail SMTP and logs in locally. See skills/gmail-email/scripts/send_email.py:55-66.

Contributing Cause 3: Swagger Defines Lyzr Tool APIs, but GitAgent Does Not Consume Them

The Lyzr Agent API Swagger already exposes tool, credential, MCP, provider, and inference tool-execution endpoints. The gap is not that Lyzr has no tool API surface; the gap is that GitAgent does not call those endpoints to discover and proxy already-authorized tools.

Evidence:

  • Plugins can register programmatic tools through registerTool. See src/plugin-sdk.ts:10-25 and src/plugin-sdk.ts:64-66.
  • Plugin loading collects programmatic tools from plugin entrypoints. See src/plugins.ts:237-284.
  • GitAgent merges plugin tools into the active tool list. CLI path: src/index.ts:545-560; SDK path: src/sdk.ts:192-207.
  • SDK tools are converted into AgentTool objects through toAgentTool. See src/tool-utils.ts:7-27.
  • Lyzr Swagger defines user tool listing at GET /v3/tools/.
  • Lyzr Swagger defines all-user tool listing at GET /v3/tools/all/user.
  • Lyzr Swagger defines provider/action listing at GET /v3/providers/tools/actions/{provider_identifier} and GET /v3/providers/tools/all.
  • Lyzr Swagger defines MCP server listing, tool listing, OAuth initiation/status, and execution under /v3/tools/mcp/*.
  • Lyzr Swagger defines generic tool execution at POST /v3/inference/tools/execute.
  • Lyzr Swagger defines connected accounts and tool credential management under /v3/tools/credentials/*.

Current Tool Calling Mechanism in GitAgent

1. Agent Loading

loadAgent() reads the agent manifest, identity files, skills, plugins, workflows, examples, and model configuration. It then returns a composed system prompt, model object, plugin list, and metadata.

Relevant code:

  • Manifest parsing: src/loader.ts:236-250
  • Plugin discovery: src/loader.ts:263-264
  • Skills discovery and prompt injection: src/loader.ts:295-307
  • Model resolution: src/loader.ts:382-419

2. Tool Assembly

GitAgent assembles tools from multiple sources:

  • Built-in tools: cli, read, write, edit, memory, capture_photo, task_tracker, skill_learner
  • Declarative tools from tools/*.yaml
  • Plugin declarative and programmatic tools
  • SDK-provided tools in programmatic usage

Relevant code:

  • Built-in tool creation: src/tools/index.ts:31-58
  • CLI tool assembly: src/index.ts:532-560
  • SDK tool assembly: src/sdk.ts:176-223
  • Declarative tool loading: src/tool-loader.ts:161-189
  • Plugin programmatic tool loading: src/plugins.ts:237-284

3. Hook Wrapping

Tools can be wrapped with hooks before execution. Hooks can block or modify tool calls.

Relevant code:

  • Hook config shape: src/hooks.ts:7-30
  • Hook execution: src/hooks.ts:44-155
  • Tool wrapper for pre_tool_use: src/hooks.ts:157-198
  • CLI wraps tools with hooks: src/index.ts:562-569
  • SDK wraps tools with script and programmatic hooks: src/sdk.ts:225-244

4. Agent Execution

The final Agent receives:

  • systemPrompt
  • model
  • tools
  • model options such as temperature and token limits

Relevant code:

  • CLI creates the agent: src/index.ts:589-596
  • SDK creates the agent: src/sdk.ts:301-309
  • CLI sends single-shot prompt: src/index.ts:638-668
  • SDK sends prompt through agent.prompt(): src/sdk.ts:489-538

5. Tool Call Events

When the model chooses a tool, pi-agent-core emits tool execution events. GitAgent subscribes to those events and streams/logs tool calls and results.

Relevant code:

  • CLI handles tool start/end events: src/index.ts:163-177
  • SDK emits tool_use messages: src/sdk.ts:432-440
  • SDK emits tool_result messages: src/sdk.ts:442-450
  • SDK fires failure and file-change hooks after tool results: src/sdk.ts:452-473

Why Lyzr Pre-Authorized Tools Are Not Available Today

The current flow is:

User sets LYZR_API_KEY
+  -> GitAgent uses it for Lyzr/OpenAI-compatible model calls
+  -> GitAgent locally registers built-in/local/plugin tools
+  -> Model may call a local Gmail/Slack tool
+  -> Local tool asks for local Gmail/Slack credentials

The desired flow is:

User sets LYZR_API_KEY
+  -> GitAgent authenticates with Lyzr
+  -> GitAgent discovers Lyzr-authorized tools
+  -> GitAgent registers those tools locally as proxy tools
+  -> Model calls a proxy tool
+  -> GitAgent forwards execution to Lyzr
+  -> Lyzr executes with stored OAuth credentials
+  -> GitAgent returns the result to the model/user

The missing component is the bridge between GitAgent's tool registry and Lyzr's server-side tool execution system.

Proposed Implementation

Implement a lyzr-tools GitAgent plugin/provider.

The plugin should:

  1. Read configuration from agent.yaml plugin config and environment variables.
  2. Authenticate to Lyzr with LYZR_API_KEY.
  3. Discover tools already available to the current Lyzr user/workspace/agent.
  4. Register each discovered tool as a GitAgent programmatic tool using api.registerTool().
  5. Execute tool calls by proxying them to Lyzr.
  6. Return structured auth-required errors when a tool is unavailable or not authorized.
  7. Optionally add prompt text telling the model to prefer Lyzr-backed tools over local duplicate skills.

Proposed GitAgent Configuration

plugins:
+  lyzr-tools:
+    enabled: true
+    config:
+      api_key: "${LYZR_API_KEY}"
+      base_url: "https://agent-prod.studio.lyzr.ai"
+      agent_id: "${GITAGENT_LYZR_AGENT_ID}"
+      workspace_id: "${LYZR_WORKSPACE_ID}"
+      prefer_lyzr_tools: true

Swagger-Confirmed Lyzr API Contracts

The Swagger documentation for https://agent-dev.test.studio.lyzr.ai/swagger#/ confirms that Lyzr already exposes tool discovery, credential, MCP, provider/action, and tool execution APIs. Therefore, the GitAgent implementation should use these existing /v3 APIs instead of introducing the previously proposed /v4/tools endpoints.

Authentication in these endpoints is defined with APIKeyHeader, which uses the x-api-key header. The OpenAI-compatible chat endpoints use bearer auth separately.

General Tool Discovery

GET /v3/tools/
+x-api-key: <LYZR_API_KEY>

Swagger summary: Get User Tools

GET /v3/tools/all/user
+x-api-key: <LYZR_API_KEY>

Swagger summary: Get All Tools

The Swagger response schemas for these two list endpoints are generic objects, so the plugin should treat them as platform responses and normalize them internally.

Provider and Action Discovery

GET /v3/providers/tools/actions/{provider_identifier}
+x-api-key: <LYZR_API_KEY>

Swagger summary: Get Tools Actions

Query parameters:

  • tool_source
  • app_id

GET /v3/providers/tools/all
+x-api-key: <LYZR_API_KEY>

Swagger summary: Get All Tools

GET /v3/providers/lyzr/aci-tools
+x-api-key: <LYZR_API_KEY>

Swagger summary: List Lyzr Aci Tools

These endpoints are the best Swagger-confirmed candidates for discovering Lyzr/ACI-backed app tools such as Gmail and Slack, including action names that can later be placed into ToolConfig.action_names.

Connected Accounts and Credential Status

GET /v3/tools/credentials/connected_accounts?user_id=<user_id>
+x-api-key: <LYZR_API_KEY>

Swagger summary: Get Tool Credential By User Id

This endpoint should be used by the GitAgent bridge to determine which tool credentials are already connected for the user.

Credential creation and lifecycle endpoints are also present:

POST /v3/tools/credentials/oauth
+POST /v3/tools/credentials/static
+PATCH /v3/tools/credentials/{credential_id}/status
+GET /v3/tools/credentials/{credential_id}/test/supported
+POST /v3/tools/credentials/{credential_id}/test
+DELETE /v3/tools/credentials/{credential_id}

Relevant Swagger schemas:

{
+  "CreateOAuthToolCredentialModel": {
+    "required": ["credential_name", "user_id", "provider_uuid"],
+    "fields": {
+      "credential_name": "string",
+      "user_id": "string",
+      "provider_uuid": "string",
+      "redirect_url": "string | null",
+      "grant_type": "authorization_code | client_credentials",
+      "tenant_id": "string | null",
+      "token_url": "string | null",
+      "client_id": "string | null",
+      "client_secret": "string | null",
+      "scope": "string | null",
+      "credentials": "object | null"
+    }
+  },
+  "CreateStaticToolCredentialModel": {
+    "required": ["credential_name", "user_id", "provider_uuid", "credentials"],
+    "fields": {
+      "credential_name": "string",
+      "user_id": "string",
+      "provider_uuid": "string",
+      "credentials": "object"
+    }
+  }
+}

MCP Server Tool Discovery and Execution

For tools exposed through MCP servers, Swagger confirms dedicated endpoints:

GET /v3/tools/mcp/servers
+x-api-key: <LYZR_API_KEY>

Swagger response schema: MCPServerListResponse

GET /v3/tools/mcp/servers/{server_id}/tools
+x-api-key: <LYZR_API_KEY>

Swagger response schema: ToolsListResponse

The relevant response schema is:

{
+  "server_id": "string",
+  "server_name": "string",
+  "tools": [
+    {
+      "name": "string",
+      "display_name": "string | null",
+      "description": "string | null",
+      "input_schema": {}
+    }
+  ],
+  "total": 0
+}

MCP tool execution:

POST /v3/tools/mcp/tools/execute
+x-api-key: <LYZR_API_KEY>
+Content-Type: application/json

Swagger request schema: lyzr_agent__tools__mcp_tools__ToolExecuteRequest

{
+  "server_id": "string",
+  "tool_name": "string",
+  "arguments": {}
+}

Swagger response schema: lyzr_agent__tools__mcp_tools__ToolExecuteResponse

{
+  "server_id": "string",
+  "tool_name": "string",
+  "result": [],
+  "success": true,
+  "error": "string | null"
+}

Swagger also confirms MCP OAuth flow support:

POST /v3/tools/mcp/servers/{server_id}/oauth/initiate
+GET /v3/tools/mcp/servers/{server_id}/oauth/status?state=<state>

Generic Inference Tool Execution

For agent-level tool execution outside the MCP-specific path, Swagger confirms:

POST /v3/inference/tools/execute
+x-api-key: <LYZR_API_KEY>
+Content-Type: application/json

Swagger request schema: api__factory__v3__inference__models__ToolExecuteRequest

{
+  "agent_id": "string | null",
+  "tool_name": "string",
+  "tool_configs": [
+    {
+      "tool_name": "string",
+      "tool_source": "string",
+      "action_names": ["string"],
+      "persist_auth": false,
+      "server_id": "string | null",
+      "provider_uuid": "string | null",
+      "credential_id": "string | null"
+    }
+  ],
+  "arguments": {},
+  "trace_id": "string | null"
+}

Swagger response schema: api__factory__v3__inference__models__ToolExecuteResponse

{
+  "tool_name": "string",
+  "trace_id": "string",
+  "result": {}
+}

This is the strongest Swagger-confirmed candidate for a GitAgent Lyzr bridge that executes pre-authorized app tools, because ToolConfig includes credential_id, provider_uuid, server_id, action_names, and persist_auth.

OpenAI-Compatible Model Endpoint

The spec also confirms the model/chat path remains separate:

POST /v4/chat/completions
+Authorization: Bearer <LYZR_API_KEY>

This supports the RCA conclusion: model authentication and tool credential execution are separate API surfaces.

Remaining API Alignment Item

Swagger confirms the endpoints needed for discovery and execution, but the RCA still needs product/API confirmation for the exact response shape when a tool is unavailable or not authorized. In particular, the GitAgent bridge needs a deterministic way to map Lyzr responses into:

{
+  "status": "authorization_required",
+  "provider": "gmail|slack|...",
+  "auth_url": "https://..."
+}

If Lyzr already returns this through connected-account or execution endpoints, the plugin should preserve that shape. If not, GitAgent should normalize current error payloads into this bridge-level result.

Proposed Plugin Shape

The plugin can use the existing programmatic plugin API:

  • api.registerTool() is available at src/plugin-sdk.ts:18-19.
  • Programmatic tools are collected at src/plugins.ts:237-284.
  • Those tools are merged into the active tool list in the CLI at src/index.ts:545-560 and in the SDK at src/sdk.ts:192-207.

Pseudo-implementation:

export async function register(api) {
+  const tools = await fetchLyzrTools(api.config);
+
+  for (const tool of tools) {
+    api.registerTool({
+      name: normalizeToolName(tool.name),
+      description: tool.description,
+      inputSchema: tool.input_schema,
+      handler: async (args) => {
+        const result = await executeLyzrTool(api.config, {
+          tool_name: tool.name,
+          tool_source: tool.source,
+          action_names: tool.action_names,
+          credential_id: tool.credential_id,
+          provider_uuid: tool.provider_uuid,
+          server_id: tool.server_id
+        }, args);
+        if (result.status === "authorization_required") {
+          return {
+            text: `Authorization required for ${tool.display_name}: ${result.auth_url}`,
+            details: result
+          };
+        }
+        return {
+          text: result.result?.text ?? JSON.stringify(result.result),
+          details: result
+        };
+      }
+    });
+  }
+
+  api.addPrompt(
+    "Prefer Lyzr-backed tools for Gmail, Slack, and other connected apps when available. These tools use pre-authorized Lyzr ecosystem credentials."
+  );
+}

Assurance Model

This implementation can provide assurance that pre-authorized tools are available only if Lyzr exposes authorized tool discovery and server-side execution.

Assurance condition:

If a tool is authorized in Lyzr and included in Lyzr discovery,
+then GitAgent will register it as an available tool.

Execution assurance:

If GitAgent calls a registered Lyzr-backed tool,
+then execution occurs through Lyzr using Lyzr-managed credentials,
+not local Gmail/Slack credentials.

Non-assurance cases:

  • Tool exists in GitAgent locally but is not discoverable from Lyzr.
  • Tool is authorized in Lyzr but omitted from the discovery API response.
  • Lyzr API key maps to a different workspace/user/agent than the one where the tool was authorized.
  • Lyzr refuses to proxy execution and only exposes raw connector tokens, which should be avoided.

Implementation Plan of Events

Phase 0: Product and API Alignment

Owner: Lyzr platform + GitAgent integration team

Events:

  1. Confirm which Swagger-confirmed path should be the primary execution path for GitAgent: generic POST /v3/inference/tools/execute, MCP POST /v3/tools/mcp/tools/execute, or both.
  2. Confirm the discovery sequence for Gmail/Slack: connected accounts, provider/actions, all tools, MCP server tools, or a combined flow.
  3. Define required identity scope: user_id, agent_id, provider_uuid, credential_id, server_id, workspace, organization, or project.
  4. Define expected LYZR_API_KEY permissions for tool discovery, connected-account lookup, credential status, and execution.
  5. Define auth-required and permission-denied error normalization if current Swagger responses do not already return a stable shape.
  6. Decide naming convention for registered tools, for example lyzr_gmail_send_email.

Exit criteria:

  • Swagger-backed endpoint sequence is documented for Gmail and Slack.
  • Example Gmail and Slack discovery/execution payloads are available.
  • Security confirms raw third-party OAuth tokens will not be returned to GitAgent.

Phase 1: GitAgent Plugin Skeleton

Owner: GitAgent integration team

Events:

  1. Create a lyzr-tools plugin directory with plugin.yaml.
  2. Add config schema for api_key, base_url, agent_id, workspace_id, and prefer_lyzr_tools.
  3. Add an entrypoint that uses api.registerTool() from the plugin API.
  4. Add prompt text through api.addPrompt() to prefer Lyzr-backed tools.
  5. Add basic unit tests for config resolution and plugin load failure modes.

Relevant existing integration points:

  • Plugin config resolution: src/plugins.ts:62-96
  • Plugin entrypoint loading: src/plugins.ts:237-250
  • Plugin tool collection: src/plugins.ts:251-268
  • Plugin API: src/plugin-sdk.ts:10-36

Exit criteria:

  • Plugin loads through existing GitAgent plugin system.
  • Plugin can register one static test tool.

Phase 2: Tool Discovery Integration

Owner: GitAgent integration team + Lyzr API team

Events:

  1. Implement fetchLyzrTools(config).
  2. Use Swagger-confirmed discovery inputs from GET /v3/tools/, GET /v3/tools/all/user, GET /v3/providers/tools/actions/{provider_identifier}, GET /v3/providers/tools/all, GET /v3/providers/lyzr/aci-tools, GET /v3/tools/credentials/connected_accounts, and MCP listing endpoints where applicable.
  3. Normalize tool names to GitAgent-compatible identifiers.
  4. Convert Lyzr input_schema / action schemas into GitAgent inputSchema.
  5. Filter out unauthorized tools or register them with clear auth-required behavior depending on product decision.
  6. Detect collisions with existing tool names.
  7. Add telemetry/logging for discovered tools count and skipped tools.

Exit criteria:

  • A user with authorized Gmail sees Gmail tool registered in GitAgent.
  • A user without authorized Gmail sees a clear auth-required state, not a request for local SMTP credentials.

Phase 3: Tool Execution Proxy

Owner: GitAgent integration team + Lyzr API team

Events:

  1. Implement executeLyzrTool(config, tool, args) using POST /v3/inference/tools/execute for agent-level tools where possible.
  2. Implement MCP execution fallback or parallel support using POST /v3/tools/mcp/tools/execute for MCP-backed tools.
  3. Populate ToolConfig with tool_name, tool_source, action_names, and available credential_id, provider_uuid, or server_id.
  4. Map execution success into GitAgent tool text result.
  5. Map authorization_required or equivalent Lyzr errors into a user-facing result with provider and auth URL if available.
  6. Map permission errors, validation errors, rate limits, and platform errors into structured details.
  7. Ensure sensitive values are redacted from logs and tool results.
  8. Add retry policy only for safe transient failures.

Exit criteria:

  • Gmail send executes through Lyzr with no local GMAIL_USER or GMAIL_APP_PASSWORD.
  • Slack send executes through Lyzr with no local Slack bot token.
  • Tool result returns to the model as a normal GitAgent tool result.

Phase 4: Local Duplicate Tool Deconfliction

Owner: GitAgent integration team

Events:

  1. Add prompt guidance to prefer Lyzr tools when duplicate local skills exist.
  2. Optionally add an allow/deny tool config that disables local duplicate skills/tools.
  3. Consider auto-prefixing Lyzr tools with lyzr_ to avoid name collisions.
  4. Add documentation explaining how Lyzr-backed tools differ from local skills.

Relevant current behavior:

  • CLI tool collision handling skips colliding plugin tools. See src/index.ts:545-560.
  • SDK tool collision handling does the same. See src/sdk.ts:192-207.

Exit criteria:

  • Model chooses lyzr_gmail_send_email rather than local gmail-email SMTP flow.
  • Users are not instructed to create local app passwords when Lyzr Gmail is authorized.

Phase 5: Tests and Validation

Owner: GitAgent integration team

Events:

  1. Unit test discovery success with Gmail and Slack tools.
  2. Unit test no tools returned.
  3. Unit test authorization_required.
  4. Unit test execution success.
  5. Unit test execution failure and redaction.
  6. Integration test against a mocked Lyzr API.
  7. Manual E2E test with a real Lyzr account that has Gmail and Slack pre-authorized.

Acceptance scenarios:

Given LYZR_API_KEY belongs to a user with Gmail authorized
+When GitAgent starts with lyzr-tools enabled
+Then GitAgent registers a Gmail send tool
+And sending email does not ask for GMAIL_USER or GMAIL_APP_PASSWORD
+And execution is proxied through Lyzr

Given LYZR_API_KEY belongs to a user without Slack authorized
+When GitAgent attempts to use Slack
+Then GitAgent returns authorization_required with a Lyzr auth URL
+And does not ask for local Slack bot credentials

Phase 6: Rollout

Owner: Product + engineering

Events:

  1. Release plugin behind a feature flag.
  2. Enable for internal dogfood accounts.
  3. Track metrics: discovery success, execution success, auth-required rate, tool errors.
  4. Add docs to install/setup flow.
  5. Deprecate local Gmail/Slack credential instructions for Lyzr mode.
  6. Roll out broadly after successful internal validation.

Risks and Mitigations

RiskImpactMitigation
Multiple Swagger-confirmed discovery paths existPlugin may choose incomplete source of truthDefine canonical discovery sequence for Gmail/Slack before implementation
Generic and MCP execution paths differTool execution behavior may be inconsistentRoute tools by source: generic /v3/inference/tools/execute for agent tools, MCP /v3/tools/mcp/tools/execute for MCP tools
Tool names collide with local toolsWrong tool may be selectedPrefix Lyzr tools and add prompt preference
API key maps to wrong user/workspace/orgTools appear missingRequire explicit user_id and any required org/workspace context in plugin config
Auth-required errors are vague or inconsistentUser confusion persistsNormalize Lyzr errors into structured auth_url, provider, and reason
Sensitive args/results leak in logsSecurity issueRedact secrets and PII in plugin logging

Final Recommendation

Proceed with a Lyzr-backed tool bridge rather than trying to pass Gmail/Slack credentials into GitAgent.

The implementation should guarantee this behavior:

Lyzr-authorized tool
+  -> discovered by GitAgent
+  -> registered as a GitAgent proxy tool
+  -> executed by Lyzr server-side
+  -> no local reauthorization required

This design aligns with the current GitAgent plugin architecture, avoids local credential duplication, and preserves Lyzr as the system of record for connected app authorization.

\ No newline at end of file diff --git a/docs/lyzr-tool-bridge-test-cases.html b/docs/lyzr-tool-bridge-test-cases.html new file mode 100644 index 0000000..8088f64 --- /dev/null +++ b/docs/lyzr-tool-bridge-test-cases.html @@ -0,0 +1 @@ +Test Cases: Lyzr Pre-Authorized Tool Bridge for GitAgent

Test Cases: Lyzr Pre-Authorized Tool Bridge for GitAgent

Date: 2026-07-15

Scope: Reproduce the current duplicate-authorization issue and validate the proposed GitAgent integration with Lyzr's Swagger-confirmed tool APIs.

Preconditions

  • GitAgent repo is available locally.
  • A Lyzr dev/staging account exists with a valid LYZR_API_KEY.
  • At least one Lyzr agent exists, with GITAGENT_LYZR_AGENT_ID available.
  • Test user A has Gmail and Slack authorized inside Lyzr.
  • Test user B does not have Gmail or Slack authorized inside Lyzr.
  • Lyzr Swagger APIs are reachable:

- GET /v3/tools/

- GET /v3/tools/all/user

- GET /v3/providers/tools/actions/{provider_identifier}

- GET /v3/providers/tools/all

- GET /v3/tools/credentials/connected_accounts

- POST /v3/inference/tools/execute

- MCP APIs under /v3/tools/mcp/*

Part A: Reproduce Current Issue

TC-A01: Lyzr API Key Enables Model but Not Local Gmail Tool

Objective: Prove that LYZR_API_KEY currently works for the model path but not for Gmail tool authorization.

Steps:

  1. Set only Lyzr model credentials:

```bash

export LYZR_API_KEY="<valid-key>"

export GITAGENT_LYZR_AGENT_ID="<agent-id>"

unset GMAIL_USER

unset GMAIL_APP_PASSWORD

```

  1. Run GitAgent with the Lyzr model backend.
  2. Ask: "Send an email to qa@example.com with subject Test and body Hello."
  3. If GitAgent chooses the bundled Gmail skill, observe the result.

Expected current behavior:

  • Model call succeeds through Lyzr.
  • Gmail action fails or asks for local GMAIL_USER and GMAIL_APP_PASSWORD.
  • User is effectively asked to authorize/configure Gmail again, despite Gmail possibly being authorized in Lyzr.

Pass condition:

  • The issue is reproduced when local Gmail credentials are required.

TC-A02: Bundled Gmail Skill Uses SMTP Credentials

Objective: Confirm current Gmail path is independent of Lyzr.

Steps:

  1. Ensure LYZR_API_KEY is set.
  2. Ensure GMAIL_USER and GMAIL_APP_PASSWORD are unset.
  3. Run:

```bash

python3 skills/gmail-email/scripts/send_email.py \

--to "qa@example.com" \

--subject "Test" \

--body "Hello"

```

Expected current behavior:

  • Script prints ERROR: Gmail credentials not found!
  • Script asks for GMAIL_USER and GMAIL_APP_PASSWORD.

Pass condition:

  • The script does not use LYZR_API_KEY.

TC-A03: Slack or Other Local Tool Requires Independent Credential

Objective: Confirm the same class of issue exists for non-Gmail tools if implemented locally.

Steps:

  1. Configure Lyzr credentials only.
  2. Trigger a Slack action through any local Slack skill/tool if present.
  3. Do not provide local Slack bot/user tokens.

Expected current behavior:

  • Local Slack tool requires its own Slack credentials.
  • Lyzr pre-authorization is not reused.

Pass condition:

  • The issue is reproduced for at least one non-Gmail connected app, or marked not applicable if no local Slack tool exists.

Part B: Validate Lyzr Swagger API Availability

TC-B01: List User Tools

Objective: Confirm GET /v3/tools/ is reachable with x-api-key.

Steps:

  1. Call:

```bash

curl -sS \

-H "x-api-key: $LYZR_API_KEY" \

"https://agent-dev.test.studio.lyzr.ai/v3/tools/"

```

  1. Inspect response.

Expected behavior:

  • API returns 200.
  • Response contains user tool data or an empty user tool collection.

Pass condition:

  • Response is authenticated and parseable.

TC-B02: List All User Tools

Objective: Confirm GET /v3/tools/all/user returns available tools.

Steps:

  1. Call:

```bash

curl -sS \

-H "x-api-key: $LYZR_API_KEY" \

"https://agent-dev.test.studio.lyzr.ai/v3/tools/all/user"

```

Expected behavior:

  • API returns 200.
  • Response includes available tool/provider data, or a valid empty response.

Pass condition:

  • GitAgent bridge can use or normalize the response.

TC-B03: List Connected Accounts for Authorized User

Objective: Confirm Lyzr can report connected tool credentials for a user.

Steps:

  1. Use test user A who has Gmail and Slack authorized.
  2. Call:

```bash

curl -sS \

-H "x-api-key: $LYZR_API_KEY" \

"https://agent-dev.test.studio.lyzr.ai/v3/tools/credentials/connected_accounts?user_id=<user-a-id>"

```

Expected behavior:

  • API returns 200.
  • Response indicates connected Gmail and Slack accounts, or includes credential identifiers usable by execution.

Pass condition:

  • Response includes enough metadata to map a connected account to credential_id, provider, or tool configuration.

TC-B04: List Connected Accounts for Unauthorized User

Objective: Confirm unauthorized state can be detected.

Steps:

  1. Use test user B who has no Gmail/Slack authorization.
  2. Call connected accounts endpoint with user B.

Expected behavior:

  • API returns 200.
  • Response does not include Gmail/Slack credentials.

Pass condition:

  • Bridge can detect "not authorized" without asking for local credentials.

TC-B05: Provider Action Discovery

Objective: Confirm provider/action endpoint can list app actions.

Steps:

  1. Call:

```bash

curl -sS \

-H "x-api-key: $LYZR_API_KEY" \

"https://agent-dev.test.studio.lyzr.ai/v3/providers/tools/actions/<provider_identifier>?tool_source=<source>&app_id=<app-id>"

```

  1. Use actual provider identifier/source/app ID from Lyzr configuration.

Expected behavior:

  • API returns action names for the provider/app.
  • Gmail action such as send email or Slack action such as send message is discoverable if configured.

Pass condition:

  • Actions can be transformed into GitAgent tool definitions.

TC-B06: Generic Tool Execution API Contract

Objective: Confirm POST /v3/inference/tools/execute accepts ToolConfig.

Steps:

  1. Prepare a payload using a known authorized Gmail or Slack action:

```json

{

"agent_id": "<agent-id>",

"tool_name": "<tool-name>",

"tool_configs": [

{

"tool_name": "<tool-name>",

"tool_source": "<tool-source>",

"action_names": ["<action-name>"],

"persist_auth": true,

"provider_uuid": "<provider-uuid>",

"credential_id": "<credential-id>"

}

],

"arguments": {},

"trace_id": "qa-test"

}

```

  1. Call:

```bash

curl -sS \

-X POST \

-H "x-api-key: $LYZR_API_KEY" \

-H "Content-Type: application/json" \

-d @payload.json \

"https://agent-dev.test.studio.lyzr.ai/v3/inference/tools/execute"

```

Expected behavior:

  • API returns 200 for valid authorized tool calls.
  • Response contains tool_name, trace_id, and result.

Pass condition:

  • The response can be mapped into a GitAgent tool result.

Part C: Validate GitAgent Lyzr Tool Bridge Implementation

These tests apply after the lyzr-tools GitAgent plugin/provider is implemented.

TC-C01: Plugin Loads Successfully

Objective: Confirm GitAgent loads the Lyzr bridge plugin.

Steps:

  1. Configure agent.yaml:

```yaml

plugins:

lyzr-tools:

enabled: true

config:

api_key: "${LYZR_API_KEY}"

base_url: "https://agent-dev.test.studio.lyzr.ai"

agent_id: "${GITAGENT_LYZR_AGENT_ID}"

user_id: "<user-a-id>"

```

  1. Start GitAgent.

Expected behavior:

  • GitAgent logs or exposes that lyzr-tools plugin loaded.
  • No plugin config warnings for required fields.

Pass condition:

  • Plugin is present in /plugins output or startup logs.

TC-C02: Authorized Gmail Tool Is Registered

Objective: Confirm GitAgent registers Lyzr-backed Gmail tool for user A.

Steps:

  1. Use user A with Gmail authorized in Lyzr.
  2. Start GitAgent with lyzr-tools.
  3. Inspect active tools through startup output or SDK messages.

Expected behavior:

  • A Gmail send tool appears, for example lyzr_gmail_send_email.
  • Tool description says it uses Lyzr-backed/pre-authorized credentials.

Pass condition:

  • Tool is registered without local Gmail credentials.

TC-C03: Authorized Slack Tool Is Registered

Objective: Confirm GitAgent registers Lyzr-backed Slack tool for user A.

Steps:

  1. Use user A with Slack authorized in Lyzr.
  2. Start GitAgent with lyzr-tools.
  3. Inspect active tools.

Expected behavior:

  • Slack action tool appears, for example lyzr_slack_send_message.

Pass condition:

  • Tool is registered without local Slack credentials.

TC-C04: Unauthorized Tool Produces Auth-Required State

Objective: Confirm user B does not get local credential prompts.

Steps:

  1. Use user B without Gmail authorization.
  2. Start GitAgent with lyzr-tools.
  3. Ask: "Send an email to qa@example.com."

Expected behavior:

  • GitAgent does not ask for GMAIL_USER or GMAIL_APP_PASSWORD.
  • GitAgent returns a structured auth-required result or message.
  • If available from Lyzr, the result includes provider and auth URL.

Pass condition:

  • Missing authorization is represented as Lyzr auth-required, not local credential setup.

TC-C05: Gmail Send Executes Through Lyzr

Objective: Validate full happy path for Gmail.

Steps:

  1. Use user A with Gmail authorized.
  2. Ensure local Gmail credentials are unset:

```bash

unset GMAIL_USER

unset GMAIL_APP_PASSWORD

```

  1. Ask GitAgent: "Send an email to qa@example.com with subject Bridge Test and body This came through Lyzr."
  2. Observe tool call and result.
  3. Check recipient inbox or Lyzr execution logs.

Expected behavior:

  • GitAgent calls Lyzr-backed Gmail tool.
  • Lyzr executes the email send.
  • Email is delivered or execution result confirms success.
  • No local Gmail credentials are required.

Pass condition:

  • Email send succeeds through Lyzr.

TC-C06: Slack Send Executes Through Lyzr

Objective: Validate full happy path for Slack.

Steps:

  1. Use user A with Slack authorized.
  2. Ensure local Slack tokens are unset.
  3. Ask GitAgent: "Send a Slack message to #qa saying Bridge test passed."
  4. Observe tool call and result.
  5. Check Slack channel or Lyzr execution logs.

Expected behavior:

  • GitAgent calls Lyzr-backed Slack tool.
  • Slack message is sent.
  • No local Slack credentials are required.

Pass condition:

  • Slack send succeeds through Lyzr.

TC-C07: Tool Result Mapping

Objective: Ensure Lyzr execution results are returned cleanly to the model.

Steps:

  1. Execute a Lyzr-backed tool through GitAgent.
  2. Capture GitAgent tool_result event or CLI output.

Expected behavior:

  • Result is human-readable.
  • Raw implementation details are stored in details where available.
  • Sensitive credentials/tokens are not printed.

Pass condition:

  • Tool result can be safely shown to user and fed back to model.

TC-C08: Local Duplicate Tool Is Not Preferred

Objective: Ensure GitAgent prefers Lyzr-backed tools over local duplicate skills.

Steps:

  1. Ensure bundled gmail-email skill exists.
  2. Enable Lyzr Gmail bridge tool.
  3. Ask: "Send an email to qa@example.com."

Expected behavior:

  • Model selects lyzr_gmail_send_email, not local SMTP skill.
  • No local Gmail App Password prompt appears.

Pass condition:

  • Lyzr-backed tool wins over local duplicate.

TC-C09: Invalid API Key Fails Clearly

Objective: Validate failure behavior for bad LYZR_API_KEY.

Steps:

  1. Set invalid key:

```bash

export LYZR_API_KEY="invalid"

```

  1. Start GitAgent with lyzr-tools.

Expected behavior:

  • Plugin fails discovery gracefully.
  • User sees clear authentication error.
  • GitAgent itself does not crash unless configured to fail closed.

Pass condition:

  • Error is actionable and does not expose secrets.

TC-C10: Wrong User or Workspace Context

Objective: Validate behavior when API key is valid but user/workspace context does not match authorization.

Steps:

  1. Use valid LYZR_API_KEY.
  2. Configure wrong user_id or workspace context.
  3. Start GitAgent and request Gmail/Slack action.

Expected behavior:

  • Tool is not registered or returns auth-required/permission-denied.
  • Error explains context mismatch or missing connected account.

Pass condition:

  • No local credential prompt appears.
  • No raw OAuth tokens are exposed.

TC-C11: MCP Tool Discovery

Objective: Validate MCP-backed tools if Gmail/Slack are exposed through MCP.

Steps:

  1. Ensure an MCP server exists in Lyzr.
  2. Call bridge discovery.
  3. Confirm bridge calls:

- GET /v3/tools/mcp/servers

- GET /v3/tools/mcp/servers/{server_id}/tools

Expected behavior:

  • MCP server tools are converted into GitAgent tools.
  • Tool schema uses ToolResponse.input_schema.

Pass condition:

  • MCP-backed tool is registered and callable.

TC-C12: MCP OAuth Flow

Objective: Validate OAuth flow handoff if MCP server requires authorization.

Steps:

  1. Use an MCP server requiring OAuth.
  2. Start bridge discovery.
  3. Trigger OAuth initiation if status is unauthenticated.

Expected behavior:

  • Bridge calls POST /v3/tools/mcp/servers/{server_id}/oauth/initiate.
  • User receives auth URL or equivalent next step.
  • Bridge can poll/check GET /v3/tools/mcp/servers/{server_id}/oauth/status?state=<state>.

Pass condition:

  • User can authorize via Lyzr, and GitAgent does not request local credentials.

Part D: Regression Tests

TC-D01: Non-Lyzr Model Still Works

Objective: Ensure bridge does not break OpenAI/Anthropic model use.

Steps:

  1. Configure GitAgent with a non-Lyzr model.
  2. Disable or omit lyzr-tools.
  3. Run a normal prompt.

Expected behavior:

  • GitAgent works as before.

Pass condition:

  • No regression in non-Lyzr flows.

TC-D02: Lyzr Model Without Tool Bridge Still Works

Objective: Ensure existing Lyzr model flow remains functional without tool bridge.

Steps:

  1. Configure Lyzr model backend.
  2. Do not enable lyzr-tools.
  3. Ask a normal non-tool prompt.

Expected behavior:

  • Model call works.
  • No tool discovery is attempted.

Pass condition:

  • Existing Lyzr chat behavior is preserved.

TC-D03: Tool Bridge Does Not Leak Credentials

Objective: Verify secrets are redacted.

Steps:

  1. Execute Gmail/Slack through bridge.
  2. Inspect CLI logs, SDK events, telemetry, and Lyzr returned result.

Expected behavior:

  • No OAuth access token, refresh token, client secret, Slack bot token, Gmail app password, or raw credential blob is printed.

Pass condition:

  • Logs contain only safe IDs and execution status.

TC-D04: Tool Collision Handling

Objective: Validate duplicate tool names are handled.

Steps:

  1. Create a local tool with same name as a Lyzr bridge tool.
  2. Start GitAgent.

Expected behavior:

  • Collision is detected.
  • Lyzr tool is prefixed or local duplicate is skipped according to product decision.

Pass condition:

  • Startup does not silently select the wrong tool.

Acceptance Criteria Summary

Implementation is considered successful when:

  • LYZR_API_KEY authenticates GitAgent to Lyzr tool APIs via x-api-key.
  • GitAgent discovers Lyzr-authorized Gmail/Slack tools.
  • GitAgent registers discovered tools as callable agent tools.
  • GitAgent executes Gmail/Slack through Lyzr, not local credentials.
  • Users with pre-authorized tools are not asked to authorize locally.
  • Users without authorization receive a structured Lyzr auth-required response.
  • No raw third-party OAuth tokens or app passwords are exposed to GitAgent users/logs.
  • Existing non-Lyzr and Lyzr-model-only flows continue to work.

\ No newline at end of file diff --git a/plugins/lyzr-tools/README.md b/plugins/lyzr-tools/README.md index 23c9bb6..4181f1e 100644 --- a/plugins/lyzr-tools/README.md +++ b/plugins/lyzr-tools/README.md @@ -6,11 +6,11 @@ See [`docs/lyzr-tool-auth-rca.md`](../../docs/lyzr-tool-auth-rca.md) for the ful ## What it does -1. On load, reads `LYZR_API_KEY` (and related config) and calls Lyzr's `/v3` tool APIs to discover: - - Provider/app actions for each configured provider (default: `gmail`, `slack`) via `GET /v3/providers/tools/actions/{provider}`. +1. On load, reads `LYZR_API_KEY`/`GITAGENT_LYZR_AGENT_ID` (and related config) and calls Lyzr's `/v3` tool APIs to discover: + - The configured agent's own `tool_configs` via `GET /v3/agents/{agent_id}` — each entry is a connected integration a human already wired up in Lyzr Studio (tool_name, tool_source, action_names, provider_uuid, credential_id), used verbatim rather than reconstructed. - Tools exposed through Lyzr MCP servers via `GET /v3/tools/mcp/servers` + `.../{server_id}/tools`. - - Which of those are already authorized for the configured user via `GET /v3/tools/credentials/connected_accounts`. -2. Registers each discovered tool as a gitagent tool named `lyzr__` (or `lyzr_mcp__` for MCP tools). + - Connected-account status via `GET /v3/tools/credentials/connected_accounts`, as a secondary authorization signal alongside each tool_config's own `credential_id`. +2. Registers one gitagent tool per `action_names` entry, named after the action (e.g. `lyzr_gmail_send_email` for `GMAIL_SEND_EMAIL`), or `lyzr_mcp__` for MCP tools. 3. Executes tool calls by proxying to `POST /v3/inference/tools/execute` (provider/action tools) or `POST /v3/tools/mcp/tools/execute` (MCP tools). 4. If a tool isn't authorized, calling it returns a structured `authorization_required` result instead of asking for local credentials. 5. Adds prompt guidance telling the model to prefer `lyzr_*` tools over local duplicate skills (e.g. the bundled `gmail-email` skill). @@ -21,11 +21,10 @@ The plugin is enabled by default in this repo's `agent.yaml`. It no-ops (with a ```bash export LYZR_API_KEY="" +export GITAGENT_LYZR_AGENT_ID="" # required: source of tool_configs to discover, and target for execution # Optional, defaults shown: export LYZR_BASE_URL="https://agent-prod.studio.lyzr.ai" -export LYZR_USER_ID="" # needed to resolve authorization status -export GITAGENT_LYZR_AGENT_ID="" # needed for agent-level tool execution -export LYZR_TOOL_PROVIDERS="gmail,slack" # comma-separated provider identifiers to discover +export LYZR_USER_ID="" # secondary signal for resolving authorization status ``` Or configure it explicitly in `agent.yaml`: @@ -39,15 +38,17 @@ plugins: base_url: "https://agent-prod.studio.lyzr.ai" agent_id: "${GITAGENT_LYZR_AGENT_ID}" user_id: "${LYZR_USER_ID}" - providers: "gmail,slack" prefer_lyzr_tools: true ``` ## Known limitations / open items +- **No per-action input schema.** `GET /v3/agents/{agent_id}` (the discovery source, confirmed against a live account) exposes each connected integration's `tool_name`/`tool_source`/`action_names`/`provider_uuid`/`credential_id`, but nothing documenting an action's parameters (e.g. `GMAIL_SEND_EMAIL`'s `to`/`subject`/`body`). Registered tools currently get a permissive empty `inputSchema`, so the model must infer arguments from the tool's name/description alone. A targeted fast-follow would fetch schemas per matched action from `GET /v3/providers/tools/actions/{provider_id}?tool_source=...` (still exposed on `lib/client.ts`) without reintroducing catalog-based tool_config reconstruction. - `GET /v3/tools/` and `GET /v3/tools/all/user` are not used as discovery sources: their Swagger response schema is a generic `{}` object with no documented shape to normalize. The client (`lib/client.ts`) still exposes them for future use once Lyzr documents a concrete response shape. -- The exact field pairing for `POST /v3/inference/tools/execute` (which value goes in the top-level `tool_name` vs. `ToolConfig.tool_name`) isn't fully pinned by the Swagger schema. `lib/execute.ts` documents the assumption made; this is flagged in the RCA as a "Remaining API Alignment Item" that needs product/API confirmation. -- Authorization-required detection uses HTTP status codes plus a keyword heuristic over the error body (`lib/execute.ts: detectAuthRequired`), since Lyzr doesn't yet document a stable `authorization_required` response shape for these endpoints. If/when Lyzr standardizes that shape, replace the heuristic with a direct field check. +- `GET /v3/providers/tools/all` (the provider catalog) is no longer used for discovery — an earlier version of this plugin reconstructed `tool_configs` entries from it, but a real agent's own `tool_configs[].tool_name` turned out to be a human-named connected-integration label (e.g. `"gmail-Akshat Gmail Integration"`), not the catalog's generic `provider_id`, and `provider_uuid` didn't match `meta_data.app_id` either. Reading the agent's own config directly avoids that whole class of guesswork. The catalog client method remains available for the input-schema fast-follow above. +- The exact field pairing for `POST /v3/inference/tools/execute` (specifically: whether the top-level `tool_name` is "the action to invoke" while `tool_configs[0]` is "the credential context it runs under") isn't fully pinned by the Swagger schema — it's inferred from the shape of a real agent's stored config, not from a captured real execute request/response. Flagged in the RCA as a "Remaining API Alignment Item." +- Authorization detection treats a non-empty `credential_id` already present on the agent's tool_config as primary evidence of authorization, OR'd with `GET /v3/tools/credentials/connected_accounts` status. At execution time, HTTP status codes plus a keyword heuristic over the error body (`lib/execute.ts: detectAuthRequired`) still catch anything that slips through, since Lyzr doesn't yet document a stable `authorization_required` response shape. +- **Redaction is key-name-based first, shape-based second.** `lib/redact.ts` masks any field whose *key* looks sensitive (`token`, `secret`, `credential`, etc.), and additionally checks string leaves by *shape* (known token prefixes, JWTs, long opaque alphanumeric strings) so a raw OAuth token returned under an innocuous key like `result` still gets masked. This is a heuristic, not a guarantee: Lyzr's execution backend should not return raw credential blobs in tool `result` payloads in the first place — if it does and the value doesn't match the shape heuristic (e.g. a short-lived token or an unusual format), it can still reach model context. ## Testing diff --git a/plugins/lyzr-tools/index.ts b/plugins/lyzr-tools/index.ts index a1c8c18..c6f6652 100644 --- a/plugins/lyzr-tools/index.ts +++ b/plugins/lyzr-tools/index.ts @@ -48,7 +48,7 @@ export async function registerWithClient( tools = discovered.tools; api.logger.info( - `Discovered ${tools.length} Lyzr-backed tool(s) across ${discovered.stats.providersQueried} provider(s) and ${discovered.stats.mcpServersQueried} MCP server(s); ${discovered.stats.unauthorized} not yet authorized.`, + `Discovered ${tools.length} Lyzr-backed tool(s) from ${discovered.stats.agentToolConfigsFound} configured integration(s) and ${discovered.stats.mcpServersQueried} MCP server(s); ${discovered.stats.unauthorized} not yet authorized.`, ); if (discovered.stats.errors.length > 0) { api.logger.warn(`Some discovery calls failed: ${discovered.stats.errors.join("; ")}`); diff --git a/plugins/lyzr-tools/lib/client.ts b/plugins/lyzr-tools/lib/client.ts index 45fbf31..8f2b8a6 100644 --- a/plugins/lyzr-tools/lib/client.ts +++ b/plugins/lyzr-tools/lib/client.ts @@ -23,6 +23,7 @@ export interface LyzrResult { export type FetchLike = (input: string, init?: RequestInit) => Promise; export interface LyzrClient { + getAgent(agentId: string): Promise; listUserTools(): Promise; listAllUserTools(): Promise; listConnectedAccounts(userId: string): Promise; @@ -113,6 +114,8 @@ export function createLyzrClient( } return { + getAgent: (agentId: string) => request("GET", `/v3/agents/${encodeURIComponent(agentId)}`), + listUserTools: () => request("GET", "/v3/tools/"), listAllUserTools: () => request("GET", "/v3/tools/all/user"), diff --git a/plugins/lyzr-tools/lib/config.ts b/plugins/lyzr-tools/lib/config.ts index 971e496..c09f0db 100644 --- a/plugins/lyzr-tools/lib/config.ts +++ b/plugins/lyzr-tools/lib/config.ts @@ -5,18 +5,11 @@ import type { ResolvedConfig } from "./types.ts"; const DEFAULT_BASE_URL = "https://agent-prod.studio.lyzr.ai"; -const DEFAULT_PROVIDERS = "gmail,slack"; const DEFAULT_TIMEOUT_MS = 10_000; export function resolveConfig(raw: Record | undefined): ResolvedConfig { const cfg = raw ?? {}; - const providersRaw = typeof cfg.providers === "string" && cfg.providers.trim() ? cfg.providers : DEFAULT_PROVIDERS; - const providers = providersRaw - .split(",") - .map((p: string) => p.trim()) - .filter(Boolean); - const timeoutMs = Number(cfg.timeout_ms); return { @@ -25,10 +18,8 @@ export function resolveConfig(raw: Record | undefined): ResolvedCon agentId: cfg.agent_id ? String(cfg.agent_id) : undefined, userId: cfg.user_id ? String(cfg.user_id) : undefined, workspaceId: cfg.workspace_id ? String(cfg.workspace_id) : undefined, - providers, includeMcp: cfg.include_mcp !== false, preferLyzrTools: cfg.prefer_lyzr_tools !== false, - persistAuth: cfg.persist_auth !== false, timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS, }; } diff --git a/plugins/lyzr-tools/lib/discover.ts b/plugins/lyzr-tools/lib/discover.ts index d21bede..43e5827 100644 --- a/plugins/lyzr-tools/lib/discover.ts +++ b/plugins/lyzr-tools/lib/discover.ts @@ -1,32 +1,46 @@ // Phase 2: Tool discovery. // -// Discovers tools from two Swagger-confirmed, typed-enough sources: +// Discovers tools from two sources: // -// 1. Provider/action discovery — GET /v3/providers/tools/actions/{provider} -// for each configured provider (e.g. "gmail", "slack"). This is the -// primary path for connected-app tools per docs/lyzr-tool-auth-rca.md. +// 1. Agent tool_configs — a real, working Lyzr agent (GET +// /v3/agents/{agent_id}) already carries a `tool_configs` array that a +// human wired up in Lyzr Studio: one entry per connected integration, +// each with a human-named `tool_name` (e.g. +// "gmail-Akshat Gmail Integration", NOT the generic provider id), +// `tool_source` ("composio" | "aci"), `action_names` +// (UPPERCASE_SNAKE_CASE, e.g. "GMAIL_SEND_EMAIL"), and a pre-resolved +// `provider_uuid` / `credential_id`. Reading this directly (rather than +// independently reconstructing an equivalent config from the provider +// catalog + connected-accounts APIs, which was tried first and got the +// field semantics wrong — see git history) is the only source that is +// confirmed correct against a live account, so it's used verbatim: one +// gitagent tool per action_name, with the *entire* original tool_config +// entry stored and sent back unchanged as `tool_configs[0]` at execution +// time (see execute.ts). // 2. MCP server tools — GET /v3/tools/mcp/servers + .../{server_id}/tools, // which *is* fully typed in Swagger (MCPServerListResponse / -// ToolsListResponse). +// ToolsListResponse) and unaffected by the above. // -// GET /v3/tools/ and /v3/tools/all/user are intentionally not used as a -// registration source: their Swagger response schema is a generic `{}` -// object, so there is no reliable field to normalize into a callable tool. -// They remain available on the client for future use once Lyzr documents a -// concrete shape (see RCA "Remaining API Alignment Item"). +// Known gap: the agent's own tool_configs carry no per-action input schema +// (no field in GET /v3/agents/{agent_id} documents e.g. GMAIL_SEND_EMAIL's +// to/subject/body parameters), so tools registered here get a permissive +// empty inputSchema — the model must infer arguments from the tool's name/ +// description alone. See README "Known limitations". // // Connected-account status (GET /v3/tools/credentials/connected_accounts) -// is cross-referenced so each discovered tool carries an accurate -// `authorized` flag and, where available, the credential_id/provider_uuid -// needed for execution. +// is still cross-referenced as a secondary signal: a non-empty +// credential_id already on the agent's tool_config is treated as the +// primary evidence of authorization (a human already connected it), OR'd +// with what connected_accounts reports, so authUrl can still be surfaced +// when available. import type { LyzrClient } from "./client.ts"; import type { ConnectedAccount, LyzrDiscoveredTool, Logger, ResolvedConfig } from "./types.ts"; import { normalizeProviderKey, normalizeToolName } from "./normalize.ts"; export interface DiscoveryStats { - providersQueried: number; - providerActionsFound: number; + agentToolConfigsFound: number; + agentActionsFound: number; mcpServersQueried: number; mcpToolsFound: number; unauthorized: number; @@ -44,8 +58,8 @@ export async function discoverLyzrTools( logger: Logger, ): Promise { const stats: DiscoveryStats = { - providersQueried: 0, - providerActionsFound: 0, + agentToolConfigsFound: 0, + agentActionsFound: 0, mcpServersQueried: 0, mcpToolsFound: 0, unauthorized: 0, @@ -56,7 +70,7 @@ export async function discoverLyzrTools( const connected = await fetchConnectedAccounts(client, config, stats, logger); - await discoverProviderActions(client, config, connected, tools, seenNames, stats, logger); + await discoverAgentTools(client, config, connected, tools, seenNames, stats, logger); if (config.includeMcp) { await discoverMcpTools(client, tools, seenNames, stats, logger); @@ -65,9 +79,9 @@ export async function discoverLyzrTools( return { tools, stats }; } -// ── Provider/action discovery ────────────────────────────────────────── +// ── Agent tool_configs discovery ──────────────────────────────────────── -async function discoverProviderActions( +async function discoverAgentTools( client: LyzrClient, config: ResolvedConfig, connected: Map, @@ -76,48 +90,64 @@ async function discoverProviderActions( stats: DiscoveryStats, logger: Logger, ): Promise { - for (const providerId of config.providers) { - stats.providersQueried++; - const res = await client.listProviderActions(providerId); - if (!res.ok) { - stats.errors.push(`provider "${providerId}": ${res.error}`); - logger.warn(`lyzr-tools: failed to list actions for provider "${providerId}": ${res.error}`); - continue; - } + if (!config.agentId) { + logger.warn( + 'lyzr-tools: agent_id is not set (config "agent_id" / env "GITAGENT_LYZR_AGENT_ID"); cannot discover tools from an agent\'s own configuration.', + ); + return; + } + + const res = await client.getAgent(config.agentId); + if (!res.ok) { + stats.errors.push(`agent "${config.agentId}": ${res.error}`); + logger.warn(`lyzr-tools: failed to fetch Lyzr agent "${config.agentId}": ${res.error}`); + return; + } - const actions = extractList(res.data, ["actions", "data", "items", "results"]); - const providerKey = providerId.toLowerCase(); - const connectedAccount = connected.get(providerKey); + const agent = res.data as { tool_configs?: Array> } | undefined; + const toolConfigs = Array.isArray(agent?.tool_configs) ? agent!.tool_configs! : []; + stats.agentToolConfigsFound = toolConfigs.length; - for (const action of actions) { - const actionName = String(action.name ?? action.action_name ?? action.id ?? "").trim(); - if (!actionName) continue; + for (const toolConfig of toolConfigs) { + const rawToolName = String(toolConfig.tool_name ?? "").trim(); + const toolSource = String(toolConfig.tool_source ?? ""); + const actionNames: string[] = Array.isArray(toolConfig.action_names) ? toolConfig.action_names.map(String) : []; + const credentialId = toolConfig.credential_id ? String(toolConfig.credential_id) : undefined; + const providerUuid = toolConfig.provider_uuid ? String(toolConfig.provider_uuid) : undefined; + + // Best-effort provider key inferred from the "-