Skip to content

[pull] main from oomol-lab:main - #19

Open
pull[bot] wants to merge 65 commits into
LJAYi:mainfrom
oomol-lab:main
Open

[pull] main from oomol-lab:main#19
pull[bot] wants to merge 65 commits into
LJAYi:mainfrom
oomol-lab:main

Conversation

@pull

@pull pull Bot commented Jul 28, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

github-actions Bot and others added 3 commits July 28, 2026 04:06
## 中文

### 概要

新增 Qdrant Cloud Provider,通过官方 REST API 提供 7 个可本地执行的向量数据库 Action:

- `qdrant.list_collections`
- `qdrant.get_collection`
- `qdrant.create_collection`
- `qdrant.upsert_points`
- `qdrant.get_point`
- `qdrant.query_points`
- `qdrant.scroll_points`

### 主要变更

- 使用 `custom_credential`,支持 Qdrant Cloud `clusterUrl` 和 `apiKey`。
- Credential Validator 通过 `GET /collections` 验证数据库访问权限。
- 仅支持 HTTPS、官方 `*.cloud.qdrant.io` 主机和 `6333` 端口。
- 所有请求使用共享 SSRF 防护 Fetch,不启用私网访问或 DNS 校验绕过。
- 支持 unnamed dense vectors、JSON payload、基础 Qdrant filter 和单页 scroll 分页。
- Upsert 使用单次 `PUT .../points?wait=true` 请求,避免写入与可见性之间的竞态。
- 统一处理 Qdrant 响应包络、超时、取消、非 JSON 响应和 HTTP 错误。
- 严格拒绝 named/sparse vector 字段,保持首版能力边界清晰。
- 未增加 npm 依赖、共享运行时改动或 Provider Proxy。

### 验证

- Qdrant 定向测试:9/9 通过。
- 完整测试:58 个文件、557 个测试全部通过。
- TypeScript 检查通过。
- oxlint 通过。
- oxfmt 格式检查通过。
- Catalog 已生成并包含 7 个 Qdrant Action;生成文件未提交。

## English

### Summary

Add a locally executable Qdrant Cloud Provider backed by the official
REST API with seven vector-database actions:

- `qdrant.list_collections`
- `qdrant.get_collection`
- `qdrant.create_collection`
- `qdrant.upsert_points`
- `qdrant.get_point`
- `qdrant.query_points`
- `qdrant.scroll_points`

### Changes

- Add `custom_credential` authentication with Qdrant Cloud `clusterUrl`
and `apiKey`.
- Validate credentials through `GET /collections` to verify database
access.
- Restrict URLs to HTTPS, official `*.cloud.qdrant.io` hosts, and port
`6333`.
- Route every request through the shared SSRF-protected Fetch without
private-network access or DNS-validation bypasses.
- Support unnamed dense vectors, JSON payloads, basic Qdrant filters,
and single-page scroll pagination.
- Use one `PUT .../points?wait=true` request for upserts to provide
write visibility without a separate race-prone request.
- Normalize Qdrant response envelopes and handle timeouts, cancellation,
non-JSON responses, and HTTP errors consistently.
- Reject named/sparse vector fields explicitly to keep the initial
capability boundary clear.
- Add no npm dependencies, shared runtime changes, or Provider Proxy.

### Verification

- Qdrant focused tests: 9/9 passed.
- Full test suite: 58 files, 557 tests passed.
- TypeScript check passed.
- oxlint passed.
- oxfmt check passed.
- Catalog generation includes all seven Qdrant actions; generated files
are not committed.

## Scope Notes

This initial provider intentionally excludes self-hosted Qdrant, gRPC,
named/sparse/multivectors, deletion operations, aliases, snapshots,
payload indexes, and arbitrary endpoint proxying.

---------

Co-authored-by: l1shen <648952316@qq.com>
@pull pull Bot locked and limited conversation to collaborators Jul 28, 2026
@pull pull Bot added ⤵️ pull merge-conflict Resolve conflicts manually labels Jul 28, 2026
github-actions Bot and others added 24 commits July 29, 2026 04:07
## Summary
 
- Home Assistant connections to a LAN instance fail with `request URL
must not target private or reserved IP addresses`, even when the
deployment sets
  `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`.
- `src/providers/home_assistant/executors.ts` never passed
`allowPrivateNetwork` to `defineProviderExecutors`, so the flag had no
effect for this
  provider.
- Adds the one-line opt-in, matching the 26 other providers that already
wire it.

  ## Problem
 
`defineProviderExecutors` builds a private-network-aware egress fetch
only when a provider opts in; otherwise it falls back to the public-only
`providerFetch`. Home Assistant took that fallback, so the flag could
never reach a `192.168.x.x` or `10.x.x.x` instance — the self-hosted
case it exists for. `homeassistant.local:8123`, the placeholder shown in
the connection form, was unreachable for the same reason.
 
Default behavior is unchanged: with the flag unset, private targets stay
blocked. Loopback, link-local, and cloud-metadata targets stay blocked
either way.

  ## Scope

Only the executor needed wiring. Home Assistant has no
`assertPublicHttpUrl` call site to thread the flag into
`resolveHomeAssistantBaseUrl` and `validateHomeAssistantCredential` both
route through the provider-local `normalizeBaseUrl` — and no
`proxy.registry.ts` entry. Migrating `normalizeBaseUrl` onto the shared
`assertPublicHttpUrl` is a separate change, not attempted here.
  
  ## Tests

Added to `src/providers/provider-runtime.test.ts` rather than a
provider-local file, per AGENTS.md: "Keep open-source-only
shared-infrastructure tests beside the shared module rather than inside
a provider directory." Three cases cover the shared opt-in path: default
blocks a private target, an opted-in executor reaches it once the flag
is enabled, and loopback stays blocked even then.

  ## Verification

`npm run fix-check` (clean) and `npm test` — 57 files, 566 tests
passing.
… v1 (#221)

Discover the served Grafana App Platform API version at runtime so dashboard and folder actions work across Grafana 12 and 13. Cache only successful discoveries with a bounded cache, and retry discovery after transient failures.

Tests: npm run fix-check; npm test
…228)

Fixes #229.

`list_repository_issues` fetches one GitHub page and filters pull
requests out of it, which destroys the only pagination signal
page-number callers have: the filtered array's length says nothing about
the raw page length. A short page may be a full page with PRs mixed in,
and an empty page may be 100 consecutive PRs — so any paginating
consumer that stops on a short or empty page silently drops every later
issue, and no sound termination rule can be built from the filtered
response alone (details in #229).

The response now carries **`pageInfo.fetched`** — the number of items
GitHub returned before filtering — declared in the output schema and in
the action description: callers must continue paginating while `fetched`
equals the requested page size, even when `issues` comes back short or
empty. The PR-filtering behavior itself is unchanged.

Tests cover the mixed page (`fetched=3`, filtered ids `[1,3]`), the
all-pull-requests page (`fetched=2`, `issues=[]` — the case that defeats
every downstream heuristic), and the schema declaration. `npm run
typecheck`, oxfmt, and the full vitest suite (557 tests) pass.

---------

Co-authored-by: l1shen <648952316@qq.com>
…ons (#227)

## Summary

- add `openGuardedWebSocket` in `src/core/guarded-websocket.ts`, so
provider egress that is not a fetch is held to the same SSRF policy
- extract the per-hop check behind `createGuardedFetch` as
`assertGuardedEgressUrl`, and make both transports use it
- add five Home Assistant actions that the REST API cannot serve:
registries, related-item search, device automation capabilities, script
execution, and config validation
- document the WebSocket egress rule in AGENTS.md

## Problem

The Home Assistant provider exposes entity states but not the structure
behind them. `/api/states` returns `light.living_room` and nothing that
says which device, area, or floor it belongs to, so "turn off the lights
downstairs" is a string-matching guess rather than a query.

Those registries are only reachable over Home Assistant's WebSocket API.
So are related-item search, per-device automation capabilities,
multi-step script execution, and trigger/condition/action validation.
None of them have a REST equivalent.

## Egress guard

This is the part worth review attention.

Every non-fetch egress in this repository so far targets a host fixed in
code: the IMAP/SMTP runtime resolves its hosts from a per-provider
allowlist (`netease_mail`) or a literal (`qq_mail`), and the Pushover
realtime socket uses a constant `wss://` URL. None of them needed a
guard, which is why `src/core` did not have one.

A self-hosted Home Assistant is the first case where a non-fetch
transport connects to a **credential-supplied host**, which is exactly
the case AGENTS.md says the DNS resolved-address check must cover.

Rather than add a second, narrower check for WebSockets, this extracts
the hop check that `createGuardedFetch` already performs —
`assertPublicHttpUrl` on the literal, then resolved-address validation —
into `assertGuardedEgressUrl`, and has both transports call it.
`ws`/`wss` targets are validated as their `http`/`https` equivalents and
then connected over the normalized `ws` form. `allowPrivateNetwork` and
`skipDnsValidation` behave as they do for fetch.

Two limits are inherent to the transport and match what the fetch guard
can offer: the constructor re-resolves the hostname itself, so low-TTL
rebinding remains possible, and WebSocket handshakes have no redirects
to revalidate. Both are stated in the JSDoc.

## Runtime support

Node 22+ and workerd both expose a client `WebSocket` constructor, so
this is one code path rather than a Node-only capability. Verified on
workerd with `wrangler dev` and the repository's compatibility settings
rather than from documentation:

```json
{ "hasConstructor": "function", "constructed": true, "readyState": 0,
  "hasAddEventListener": "function", "hasSend": "function",
  "hasClose": "function", "hasNodeDns": "function" }
```

`node:dns` being present under `nodejs_compat` means the
resolved-address check applies on Cloudflare too, consistent with the
note added in #135.

The provider is therefore **not** marked `nodeOnly`; doing so would also
drop the eight REST actions that work on Cloudflare today. Home
Assistant authenticates in-band, with the token in a message rather than
a header, so nothing here needs a constructor that can set request
headers.

Reachability is a separate matter from the guard: Workers cannot route
to private addresses, so a LAN instance remains a Node/Docker/Fly
deployment concern regardless of `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`.
AGENTS.md now says so.

## Actions

| Action | WebSocket commands |
| --- | --- |
| `get_registries` |
`config/{entity,device,area,floor,label}_registry/list` |
| `search_related` | `search/related` |
| `list_device_automations` |
`device_automation/{trigger,condition,action}/list` |
| `execute_script` | `execute_script` |
| `validate_config` | `validate_config` |

All five are one-shot commands, so they map onto the existing
request/response executor model unchanged.

Subscription commands are deliberately excluded. The action model has no
streaming, and every Home Assistant subscription that carries an initial
snapshot already has a one-shot equivalent — `render_template` is a
subscription whose first message equals `POST /api/template`, and
`subscribe_entities` starts from what `/api/states` already returns.
What remains is increment-only (`subscribe_events`, `subscribe_trigger`,
`logbook/event_stream`), which a bounded listen window would serve
badly. That needs a webhook design, not an action.

`get_registries` sends all five registry commands over one connection.
The handshake costs two round trips before any command can be sent, so
batching matters more here than it would over HTTP.

Reference: https://developers.home-assistant.io/docs/api/websocket

## Verification

- `npm run fix-check`, `npm test` (574 passing, 20 new)
- workerd probe above, via `wrangler dev`
- end-to-end against a real Home Assistant instance: `get_registries`
returned 160 devices and 7 areas over a single connection with ids 1–5;
`search_related` resolved a device to its area, config entry,
integration, and three entities; auth failure mapped to 401; a loopback
base URL stayed blocked with the private-network flag enabled
Reason: The MiniMax connector exposed only model listing, model
retrieval, text Responses, and token estimation, so it could not
generate video.

This extends the existing `minimax` provider with asynchronous video
generation actions:

- `text_to_video` and `image_to_video` create `POST
/v1/video_generation` tasks. `text_to_video` requires `model` and
`prompt`; `image_to_video` requires `model` and `first_frame_image`.
Both accept the optional `prompt_optimizer`, `fast_pretreatment`,
`duration`, `resolution`, and `callback_url` request fields.
- `query_video_generation` polls task progress via `GET
/v1/query/video_generation` and surfaces `task_id`, `status`, and
`file_id`.
- `download_video` retrieves the generated file metadata and its
download URL via `GET /v1/files/retrieve`.
- The `model` field is an enum that defaults to `MiniMax-Hailuo-2.3` and
covers the documented video models.
- An optional `region` field selects the global
(`https://api.minimax.io`) or China (`https://api.minimaxi.com`) host;
the existing text actions keep using the global host. All requests
continue to go through the shared SSRF-guarded fetcher.

Checks:
- `npm run fix-check` (oxlint + oxfmt + `src` typecheck)
- `npm run generate:catalog`
- `npm test` (vitest, 554 tests passing)

---------

Co-authored-by: octo-patch <266937838+octo-patch@users.noreply.github.com>
Co-authored-by: l1shen <648952316@qq.com>
## Summary

- add a locally executable `cloudflare_mcp` provider backed by
Cloudflare’s official unified Streamable HTTP MCP endpoint
- expose the current Code Mode tools: `docs`, `search`, and `execute`
- support both OAuth 2.0 and Cloudflare API Token / Bearer Token
credentials
- validate credentials through MCP `tools/list` and require the expected
official tools
- route all MCP traffic through the provider SSRF-guarded fetch
implementation

## Authentication

API tokens are sent as `Authorization: Bearer <token>`. Both user and
account API tokens are supported; account tokens should include
**Account Resources: Read** so the official MCP server can auto-detect
the account.

OAuth uses Cloudflare’s published authorization metadata:

- authorization: `https://mcp.cloudflare.com/authorize`
- token / refresh: `https://mcp.cloudflare.com/token`
- PKCE: S256
- required base scopes: `user:read account:read offline_access`

The open-source runtime uses bring-your-own OAuth clients. For
Cloudflare MCP, register a public client through
`https://mcp.cloudflare.com/register` using the callback URL shown by
Open Connector, then save the returned client ID in the OAuth Client
configuration. No client secret is required
(`token_endpoint_auth_method=none`).

## Implementation notes

- the endpoint is fixed to `https://mcp.cloudflare.com/mcp`
- `search` and `execute` preserve JSON results when possible and plain
text otherwise
- structured MCP results (including `docs`) are returned directly
- MCP authorization, transport, protocol, and tool errors are mapped to
stable provider errors
- no third-party logo asset is copied into the repository

## Verification

- `oxlint .`
- `oxfmt --check .`
- `node scripts/generate-catalog.ts`
- `node scripts/typecheck.ts src scripts-all examples`
- `vitest run` — 59 files, 562 tests passed

---------

Co-authored-by: l1shen <648952316@qq.com>
## Summary

- configure the Cloudflare MCP client to use the MCP SDK's
`CfWorkerJsonSchemaValidator`
- add a regression test that disables string-based code generation while
validating advertised MCP tool output schemas

## Root cause

The MCP SDK client defaults to AJV for JSON Schema validation. When
Cloudflare's MCP server advertises tool `outputSchema` values, AJV
compiles them with `new Function(...)`. Cloudflare Workers disallow
runtime code generation from strings, so credential validation and MCP
requests fail with:

```text
Cloudflare MCP request failed: Code generation from strings disallowed for this context
```

The SDK's Cloudflare-specific validator uses `@cfworker/json-schema` and
performs validation without `eval` or `new Function`.

## Impact

Cloudflare-hosted Open Connector deployments can validate Cloudflare MCP
credentials and invoke its tools. Node deployments retain the same
behavior.

Because the provider is currently broken on Cloudflare in v1.3.3, could
this be included in a patch release after review?

## Validation

- `node scripts/typecheck.ts src scripts-all examples`
- `vitest run` (61 files, 590 tests)
- dedicated regression test with `Function` disabled and MCP
`outputSchema` values present
## Summary

- stop generating full executable action ID arrays in the Node and
Cloudflare provider registries
- use the loaded executor module service keys to identify locally
executable providers
- resolve those services to the exact action IDs present in the loaded
catalog before creating `CatalogStore`
- preserve explicit `executableActionIds`, runtime execution metadata,
and public response shapes

This is the first of the two changes discussed in #235. Catalog asset
chunking will follow in a separate PR.

## Why

The generated Cloudflare registry repeated all 12,743 executable action
IDs even though each included provider already has a lazy executor
module entry. The existing generator marks every action from an included
provider executable, so the executor module service keys carry the same
information more compactly.

## Wrangler size comparison

Measured from `v1.3.3` and this branch with Wrangler 4.115.0, the same
Cloudflare bindings/configuration, `--dry-run`, and `--minify`:

| Build | Minified upload | Gzip |
| --- | ---: | ---: |
| `v1.3.3` baseline | 11,935.15 KiB | 2,854.70 KiB |
| This PR | 11,566.36 KiB | 2,777.99 KiB |
| Reduction | **368.79 KiB** | **76.71 KiB** |

The emitted `cloudflare.js` decreased from 12,221,596 to 11,843,955
bytes, a reduction of 377,641 bytes.

## Validation

- generated both provider registries for 1,194 total / 1,192 Cloudflare
providers
- `oxlint . --fix`
- `oxfmt .`
- `node scripts/typecheck.ts src scripts-all examples`
- `vitest run` — 61 test files and 591 tests passed
- Wrangler dry-run builds for both baseline and optimized revisions

Refs #235
… calendars (#239)

## Summary

- add automation, script, and scene config CRUD, reached over REST at
`/api/config/<component>/config/<key>`
- add history, logbook, calendar, and error log actions from Home
Assistant's published REST API
- fix `buildHomeAssistantUrl` dropping the instance base path

Fifteen actions in total, all REST. No WebSocket code is touched.

## Problem

Two separate gaps, both in the REST surface.

**The editable config store was missing entirely.** Home Assistant
serves automation, script, and scene configuration over REST, but at
`/api/config/<component>/config/<key>` rather than under the endpoints
listed in the published REST API reference. The provider could therefore
run an automation but never write one. That endpoint family is absent
from the reference page, which is the most likely reason it was never
covered — the eight actions the provider started with map exactly onto
that page and nothing outside it.

**There was no time dimension.** `/api/states` answers "what is the
temperature now"; nothing answered "how did it get there" or "what
tripped that switch overnight". `history`, `logbook`, `calendars`, and
`error_log` are all on the published reference page and were simply
never implemented.

## Config CRUD

`automation`, `script`, and `scene` differ only in three respects: the
path segment, the input field carrying the key, and whether Home
Assistant validates that key as a slug (`cv.slug` for script,
`cv.string` for automation and scene). One descriptor captures those
differences and parameterises shared read/write/delete handlers, rather
than nine near-identical copies.

The key travels in the path and Home Assistant injects it into the
stored entry itself, so the request body is the bare config object.
`POST` is create-or-update: writing to an unused key creates the entry,
which is how the UI creates automations.

Both endpoints require an admin token, and they only see entries Home
Assistant manages itself — an automation defined in a separate YAML file
or a package is not visible and returns not found. The action
descriptions say so, because a non-admin token fails at the provider
rather than at input validation.

This pairs with `validate_config` from #227 to close the loop an agent
needs for automation work: discover what a device can do, validate a
candidate config, write it, then read the logbook to confirm it fired.
`followUpActions` wires that sequence into the catalog so it is
discoverable rather than something the caller has to know.

## History and diagnostics

`get_history` exposes `minimalResponse` and `noAttributes` because a
full-attribute history across several entities is large enough to matter
for an agent's context budget. Against a real instance, one entity over
24 hours returned 7219 points; the compact form drops each row from the
full state object to `{"state", "last_changed"}`.

Home Assistant reads `minimal_response`, `no_attributes`, and
`skip_initial_state` by presence rather than by value, so `false` omits
the parameter instead of sending `0`. `significant_changes_only` is read
by value and defaults to true, so it is encoded normally.

`get_error_log` needs one caveat: Home Assistant registers that view
only when the instance runs with file logging, so it can report not
found on an otherwise healthy instance. Rather than surface a bare "Not
Found", the handler maps that case to a message explaining why, and the
action description repeats it.

## Base path fix

`normalizeBaseUrl` accepts an instance URL with a path, so a Home
Assistant behind a reverse proxy at `/ha` is a supported configuration.
`buildHomeAssistantUrl` assigned the endpoint path over `url.pathname`,
which dropped that prefix and sent every request to `/api/...` at the
origin instead of `/ha/api/...`.

It now appends to the base path. The WebSocket URL builder added in #227
already did this, so the two transports agree. This is a separate
commit; it predates both PRs, and the fifteen new paths here would have
multiplied it.

## Actions

| Action | Endpoint |
| --- | --- |
| `get_automation_config` / `save_automation_config` /
`delete_automation_config` | `GET`/`POST`/`DELETE
/api/config/automation/config/{id}` |
| `get_script_config` / `save_script_config` / `delete_script_config` |
`GET`/`POST`/`DELETE /api/config/script/config/{key}` |
| `get_scene_config` / `save_scene_config` / `delete_scene_config` |
`GET`/`POST`/`DELETE /api/config/scene/config/{id}` |
| `check_config` | `POST /api/config/core/check_config` |
| `get_history` | `GET /api/history/period/{timestamp}` |
| `get_logbook` | `GET /api/logbook/{timestamp}` |
| `list_calendars` | `GET /api/calendars` |
| `list_calendar_events` | `GET /api/calendars/{entity_id}` |
| `get_error_log` | `GET /api/error_log` |

Reference: https://developers.home-assistant.io/docs/api/rest

## Verification

- `npm run fix-check`, `npm test` (584 passing)
- read paths against a real instance: `check_config` returned valid;
`get_history` returned 7219 points for one entity over 24 hours with the
compact form visibly applied; `get_logbook` returned 4417 entries;
`list_calendars` returned an empty list on an instance with no calendar
integration; `get_error_log` returned the explanatory 404 described
above
- write paths against the same instance, creating and then removing an
`oc_smoke_test` entry in each domain: create returned `ok`, the
read-back showed the key injected into the stored entry, delete returned
`ok`, and each subsequent read reported not found. A non-slug script key
was rejected at the provider before any request. A following scan of
3830 entities found nothing left behind.
Adds four read-only alerting actions to the Grafana provider:

| Action | Endpoint |
|---|---|
| `list_alert_rules` | `GET /api/v1/provisioning/alert-rules` |
| `get_alert_rule` | `GET /api/v1/provisioning/alert-rules/:uid` |
| `list_alert_instances` | `GET /api/alertmanager/grafana/api/v2/alerts`
(supports `active`/`silenced`/`inhibited` filters) |
| `list_contact_points` | `GET /api/v1/provisioning/contact-points` |

These endpoints belong to Grafana's stable legacy REST API and are not
affected by App Platform API version discovery (#221). This exact code
has been running against Grafana 12.4.3 in a private deployment for two
weeks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary

- Add a searchable Weights & Biases provider backed by the official W&B
MCP server.
- Map the server’s 22 default tools to typed Open Connector actions and
discover the subset exposed by each deployment.
- Use the hosted `https://mcp.withwandb.com/mcp` endpoint by default,
with an optional full MCP endpoint for W&B Dedicated and self-hosted
deployments.
- Authenticate with the W&B API key as a Bearer token and route MCP
traffic through the guarded provider fetch path.
- Validate Streamable HTTP tool schemas with the Worker-safe validator
and normalize structured, JSON-text, and plain-text tool results.

## Validation

- `npm run generate:catalog`
- `npm run fix-check`
- `npm test` (60 files, 591 tests)
- `npm run build`

---------

Co-authored-by: l1shen <648952316@qq.com>
## Summary
- wire the n8n executor, proxy, credential verification, and runtime URL
validation to the existing `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK` opt-in
- preserve public-hostname/DNS validation and always-blocked targets
- add focused regression coverage for default rejection, explicit
opt-in, and blocked ranges

## Validation
- `npm run fix-check`
- `npx vitest run src/providers/n8n/executors.test.ts`
- `git diff --check`

The change was trial-verified against a self-hosted n8n instance
reachable over a trusted private network. No generated graph artifacts
are included.

---------

Co-authored-by: Kevin Cui <bh@bugs.cc>
## Summary

- replace the monolithic Cloudflare `/catalog/apps.json` build output
with a versioned `/catalog/index.json`
- write deterministic, sequential catalog chunks capped at 4 MiB by
their final UTF-8 byte size
- load all indexed chunks through the existing `ASSETS` binding while
preserving `CatalogStore` and public API response shapes
- retain a loader fallback to `/catalog/apps.json` for deployments using
the previous asset format
- request catalog assets as JSON so a missing index is not treated as an
SPA navigation

This is the catalog follow-up requested in #235 after the executable
registry reduction merged in #237.

## Why

On the current `main` catalog (1,210 providers and 12,894 actions), the
generated `apps.json` is 26,030,332 bytes (24.8245 MiB), leaving only
184,068 bytes before Cloudflare's 25 MiB per-asset limit. The provider
catalog already consists of individual source files, so combining
everything into one deployment asset creates an avoidable single-file
limit.

## Cloudflare build comparison

Measured on the same current `main` catalog with Wrangler 4.115.0,
`--dry-run`, and `--minify`:

| Measurement | Before | This PR |
| --- | ---: | ---: |
| Largest catalog asset | 26,030,332 bytes (24.8245 MiB) | 4,192,730
bytes (3.9985 MiB) |
| Catalog asset files | 1 | 8 (1 index + 7 chunks) |
| Total static assets | 11 | 18 |
| Worker minified upload | 11,672.94 KiB | 11,674.91 KiB |
| Worker gzip | 2,805.33 KiB | 2,806.08 KiB |

New builds do not emit the complete `apps.json`. The small Worker
increase is the index validation, compatibility fallback, and chunk
loading logic.

## Compatibility and validation

- provider files are sorted by filename before chunking for
deterministic output
- chunk limits include JSON brackets, commas, trailing newline, and
UTF-8 multibyte characters
- a provider too large for one chunk fails the build with its filename
and byte count
- index version, fields, chunk names, duplicate names, chunk shapes, and
provider count are validated before creating the catalog
- indexed chunks are loaded in parallel and flattened in index order
- a missing `index.json` falls back to the legacy `apps.json`; new
builds emit only the indexed format
- existing executable-service resolution from #237 remains unchanged

Checks:

- `node scripts/generate-catalog.ts` — 1,210 apps / 12,894 actions
- `oxlint . --fix`
- `oxfmt .`
- `node scripts/typecheck.ts src scripts-all examples`
- `vitest run` — 61 test files and 613 tests passed
- Wrangler dry-run builds for both the current baseline and this branch

Closes #235

---------

Co-authored-by: Kevin Cui <bh@bugs.cc>
## What

Adds a `generic_imap` provider so that any mailbox exposing IMAP over
implicit TLS (port 993) can be connected with an application password,
regardless of the mail host.

Open Connector currently ships provider-specific mail integrations
(`qq_mail`, `netease_mail`) whose IMAP/SMTP hosts are hardcoded. A
mailbox on any other host (OVH, Fastmail, self-hosted Dovecot, corporate
mail, ...) cannot be connected at all today.

## How

- The provider is built on the shared `MailRuntimeConfig` factory
(`src/mail/imap-smtp/`), so it exposes the same 12 actions as the
existing mail providers (`send_email`, `list_folders`, `search_emails`,
`get_email`, `download_attachment`, `mark_email_read`,
`mark_email_unread`, `move_email`, `delete_email`, `get_folder_status`,
`reply_email`, `forward_email`). No new mail plumbing.
- Credential fields: `email`, `password` (application password, secret),
`imapHost` (required), `smtpHost` (optional, defaults to the IMAP host
with the `imap.` prefix replaced by `smtp.`, port 465).
- Marked `nodeOnly` like the other mail providers: IMAP/SMTP sockets are
not reliable from Cloudflare Workers, and the description says so. The
generated Cloudflare registry excludes it.
- `testAction` is `list_folders`, so the connection test authenticates
against the user-supplied host before the credential is saved.

## SSRF hardening

This is the first mail provider whose hosts come from user input, so the
hosts are guarded in two places: when the credential is read, and again
when the socket is opened.

### At credential time

- Each host must be a bare dotted hostname (no scheme, userinfo, port,
or path), so nothing can smuggle a different target past the URL parser.
IP literals still match this syntax gate on purpose: they are then
classified by the shared guard.
- The host is then passed to the shared `assertPublicHttpUrl`
(`src/core/request.ts`), so the provider inherits the existing
cloud-metadata, loopback, link-local, reserved-range, and IPv6
blocklists rather than re-implementing them.
- Private targets (RFC 1918, CGNAT `100.64.0.0/10`, private-suffix
hostnames) stay blocked unless the deployment opts in through
`OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`, mirroring the existing Dokploy
behaviour. Loopback and metadata targets stay blocked even then. The
flag is read per call via `isPrivateNetworkAccessAllowed()`, so it
always reflects the current bootstrap configuration.
- `docs/configuration.md` is updated to list the IMAP Mailbox host next
to Dokploy as an opt-in consumer of the private-network flag.

### At connect time (DNS rebinding)

A hostname check alone is not sufficient: the DNS record can point at an
internal address by the time the socket is opened, and IMAP/SMTP connect
over raw TLS, so the guarded fetch never sees these hosts. `pinMailHost`
in `src/mail/imap-smtp/protocol.ts` closes that window:

- the host is resolved (`dns.lookup` with `all: true`), and **every**
returned address is screened with the shared `isBlockedIpAddress`. A
host answering with one public and one private address is rejected
outright, since the connection library is free to pick either one;
- the connection is then made to the resolved literal address, with the
hostname carried separately as `servername`. Both `imapflow` and
`nodemailer` skip their own lookup when the host is already an IP
(`net.isIP`), so no second, unchecked resolution can happen, while SNI
and certificate verification still run against the name the user typed;
- an IP-literal host skips resolution but is still screened;
- `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK` is honoured here too, and
loopback/metadata stay blocked even when it is set.

This is gated behind a new `enforceHostNetworkPolicy` flag on
`MailRuntimeConfig`, set only by `generic_imap`. Providers with
hardcoded hosts (`qq_mail`, `netease_mail`) keep their current behaviour
exactly: no resolution, no extra DNS round-trip.

## Tests

`src/providers/generic_imap/network-access.test.ts` (9 cases) covers the
credential-time policy:

- private/reserved IP literals rejected by default (incl.
`169.254.169.254`, `100.100.100.200`, `0.0.0.0`);
- public mailbox hosts accepted;
- loopback/link-local/metadata still blocked when
`OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK=true`, while RFC 1918/CGNAT/private
hostnames become allowed;
- non-bare-hostname inputs rejected (URL, `host:port`, userinfo, path,
trailing dot, IPv6 literal, whitespace);
- the guard applied to the SMTP host too, including the derived one;
- host normalization (lowercasing) and the derived SMTP host following
the normalized value;
- the offending field named in the rejection message.

`src/mail/imap-smtp/host-pinning.test.ts` (9 cases) covers the
connect-time policy:

- a hostname resolving to loopback or to the cloud-metadata address is
rejected, and no client is constructed;
- a host answering with one public and one private address is rejected
as a whole;
- the resolved address is pinned as `host` while the hostname is
preserved as `servername`, for IMAP and for SMTP;
- providers without the flag are untouched: the hostname is passed
through and no resolution happens;
- a private address is accepted when the deployment opted in, while
loopback stays blocked in that same mode;
- an IP-literal host is screened without being resolved.

## How to test

```sh
npm install
npm run typecheck   # regenerates the provider registries, then typechecks
npm test            # full suite
npx vitest run src/providers/generic_imap/network-access.test.ts src/mail/imap-smtp/host-pinning.test.ts
```

For a live check, connect an "IMAP Mailbox" credential from the catalog
UI with any IMAP-enabled mailbox and an application password; the
`list_folders` test action runs on save. Trying `127.0.0.1` or
`169.254.169.254` as the IMAP host is rejected with a 400 naming the
field, and a hostname that resolves to either is rejected when the
connection is attempted.

---------

Co-authored-by: Kevin Cui <bh@bugs.cc>
Co-authored-by: l1shen <648952316@qq.com>
hyrious and others added 30 commits August 3, 2026 09:23
The `groqcloud` provider currently exposes chat completions and model
listing only, so Groq's speech-to-text endpoints are unreachable — the
provider has no proxy fallback either.

| Action | Endpoint |
|---|---|
| `create_audio_transcription` | `POST /audio/transcriptions` |
| `create_audio_translation` | `POST /audio/translations` |

Audio is supplied inline as base64, or as a public URL that GroqCloud
fetches itself through its native `url` field. Caller URLs are checked
with `assertPublicHttpUrl` before being forwarded. The size cap applies
to inline uploads; GroqCloud enforces its own limit on URLs it fetches.

Translation is restricted to `whisper-large-v3`. Requesting
`whisper-large-v3-turbo` there returns `The model
'whisper-large-v3-turbo' does not support 'translate'`, so the
restriction is in the schema rather than surfacing as a runtime error.

Exercised against the live API with a real key: inline base64 upload,
forwarded URL, `verbose_json` with word-level
`timestamp_granularities[]`, `language`/`prompt`/`temperature`
passthrough, and both endpoints.

---------

Co-authored-by: l1shen <648952316@qq.com>
Adds a Z-Library provider with search, metadata, download-to-file, download limits, and recent books actions.

Closes #244
## Summary

- add a locally executable Komari provider using the stable JSON-RPC 2.0
endpoint in Komari 1.3.2
- expose all 71 user-facing Komari 1.3.2 JSON-RPC methods as Actions: 12
public and 59 admin
- support Bearer API key validation, reverse-proxy base paths, and
deployment-gated private-network instances
- declare each full JSON-RPC method name as the Action's provider-native
capability
- keep client enrollment tokens out of node/client results except for
the explicit `get_client_token` Action
- redact session tokens and IP addresses; expose stable SHA-256 session
IDs for safe session revocation
- add normalized handling for list results, mutation success responses,
load history, and ping history

## Compatibility

- targets the complete `public:*` and `admin:*` JSON-RPC surface in
Komari 1.3.2
- excludes agent ingestion, WebSocket terminal/reporting, login/OAuth
callbacks, multipart or binary endpoints, and unreleased Komari 1.4
plugin-only APIs
- uses `/api/rpc2`

## CodeRabbit follow-up

- explicitly thread the private-network policy through credential
validation and execution
- hoist node field allowlists and use a separate ping-task summary
schema
- replace conditional load-history spreads with an explicit typed result
- declare provider-native capabilities and tighten required input
schemas
- redact session secrets while preserving deletion through stable IDs
resolved server-side
- cover HTTP failures, invalid JSON, missing JSON-RPC results, aborts,
parameter conversion, capabilities, and safe session handling

## Validation

- `npm run generate:catalog`
- `npm run fix-check`
- `npm test` (71 files, 749 tests passed)

---------

Co-authored-by: l1shen <648952316@qq.com>
Adds a category dropdown to the provider browser with per-category
counts, so users exploring by capability can filter instead of scrolling
the flat list. Also renders each provider's short description in its
card when one exists.

Closes #219.

---------

Co-authored-by: l1shen <648952316@qq.com>
## Summary

- give the Cloudflare MCP provider an explicit Cloudflare Workers
favicon
- keep its GitHub repository as the provider homepage
- prevent the generic homepage fallback from rendering the GitHub icon

## Validation

- `npm run generate:catalog`
- `npm run fix-check`
`expires_in` is optional on a refresh response and plenty of providers
only send it on the initial grant. When it is missing the stored
credential ends up with no `expiresAt`, and a credential with no expiry
is never considered expired, so it is never refreshed again. There is no
retry on a 401 either, so once the access token actually lapses every
call through that connection fails until someone reconnects by hand. One
refresh is enough to get there.

The refreshed credential now falls back to the lifetime the provider
last reported, which is already kept in the credential metadata. If no
lifetime was ever reported the expiry stays unset, same as today.

Worth noting why the fallback is not the previous `expiresAt`: a refresh
only runs once that timestamp is in the past, so reusing it would mark
the new token expired immediately and trigger a refresh on every
request.

---------

Co-authored-by: l1shen <648952316@qq.com>
Upsales declares record IDs as positive integers, but the runtime ran
them through a string cast that returns undefined for numbers, so every
by-ID action failed on input its own schema accepts.

```
get_company { id: 42 }
before:  id is required.
after:   GET /accounts/42
```

Same file, two smaller mismatches: the list actions declare a `filters`
object that never reached the query string, and the contact actions
declare `usingFirstnameLastname` while the runtime read
`useFirstNameLastName`. Filters now go out as query parameters keyed by
field name. A filter key that would overwrite a parameter the runtime
sets is rejected, so it cannot replace the API token.

I left `offset` alone. It is still declared and unused, but I could not
reach the Upsales API docs to confirm the parameter name for skip based
paging.

---------

Co-authored-by: l1shen <648952316@qq.com>
Add Slack message search using Slack's user-scoped OAuth grant while preserving the existing bot grant for other actions.

Store and rotate the user and bot tokens together through the Slack-specific OAuth path.

Co-authored-by: suin <suinyeze@gmail.com>
… real cap (#271)

Fixes #269.

`GET /im/v1/messages` rejects `page_size > 50` with **Feishu 99992402
(field validation failed)**, so `list_messages`' declared maximum of 100
admits inputs the provider can never serve — a consumer that trusts the
schema's bound gets a hard runtime failure on every call.

`list_thread_messages` (the same endpoint through the thread container,
in `im-actions.ts`) already declares 50, so this brings `list_messages`
in line: a dedicated 50-capped `pageSize` for it, leaving the shared 100
for the endpoints that genuinely accept it (`list_chats`,
`list_chat_members`, `search_chats`).

Verified live against a real workspace (gateway from this repo's main):
`pageSize: 100` → 99992402 on every chat; `pageSize: 50` → success.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(feishu): clear terminal wiki page tokens

feat(feishu): add permissions export script

fixes #268, #270
Map the declared update date range to Monday GraphQL arguments, forward pagination, enforce the documented date formats and page-size limits, and remove unsupported filter fields.
A reply set `References` to the parent's Message-ID and nothing else.
RFC 5322 wants the parent's own chain with the parent appended, so
replying to anything deeper than the first message split the thread in
clients that follow the header.

```
parent References: <a> <b>,  parent Message-ID: <c>
before:  references: "<c>"
after:   references: "<a> <b> <c>"
```

The chain was not available to fix before now. `References` is not part
of the IMAP envelope, so it is fetched with the message and parsed onto
the fetched result. Folded header lines are joined first, since a long
chain always arrives wrapped.

`forward_email` is unchanged, as a forward starts its own thread.

---------

Co-authored-by: l1shen <648952316@qq.com>
`optionalIntegerOrNull("")` returned `0` rather than `null`, because
`Number("")` is `0` and passes `Number.isInteger`. A blank optional
field reached the provider as a real zero.

```
optionalIntegerOrNull("")     before: 0   after: null
optionalIntegerOrNull("   ")  before: 0   after: null
```

Blank strings are now reported as missing. That matches the sibling
helpers `integer` and `optionalIntegerLike`, which already exclude an
empty string before parsing. Eight providers read optional integers
through this helper.
)

`list_linear_issues` declared `original_cursor` and
`cursor_was_corrupted` as inputs. The runtime reads only `after`,
`first`, `project_id` and `assignee_id`, so both were accepted and then
ignored.

`cursor_was_corrupted` also reads as diagnostic output rather than
something a caller supplies, which invites an agent to set it and be
silently ignored. Neither name appears anywhere else in the repo.

Pagination through `after` and `first` is unchanged.
Allow deployments behind corporate VPNs or split DNS to trust narrowly scoped egress hostnames while retaining hard SSRF blocks for loopback, link-local, metadata, and other unsafe targets.\n\nDocument the deployment-wide security boundary and cover IPv4, IPv6, redirects, and hostname matching.
)

Improve OAuth token request diagnostics without exposing credentials or arbitrary token response bodies.\n\nPreserve safe transport cause codes, distinguish missing and unreadable HTTP responses, retain structured provider errors, and cover credential-leak and response-read failure cases.
test(providers): remove transferred private coverage
## Summary

Extend the TickTick provider with two capabilities that the official
TickTick Open API supports but the current catalog does not expose.

## Changes

### 1. Task tags (`create_task` / `update_task`)

Add a `tags: string[]` field to the `createTaskInput` /
`updateTaskInput` schemas and forward it in the request body. The
TickTick Open API Task object includes `tags`, but the field was
previously absent from the schema, so any attempt to create or update a
tagged task failed schema validation with `additionalProperties` errors.

### 2. Batch task creation (`batch_add_tasks`)

Add a `batch_add_tasks` action backed by `POST /open/v1/task/batch` with
an `{ "add": [...] }` body (up to 50 tasks per request). Each task
reuses `createTaskInput`, so tags are supported in batch mode too. The
handler tolerates the batch endpoint's varying response shapes (array,
`{ add }`, `{ tasks }`, or empty body) and returns a `createdCount` so
callers can confirm how many tasks were created even when the response
omits the created task list.

## Verification

- `npm run typecheck`, `npm run lint`, `npx oxfmt --check` — all pass
- `npm test` — 713 tests passed (68 files)
- Live-tested against the TickTick API:
- tasks created/updated with tags persist them (`filter_tasks` by tag
returns them; tags are lowercased by TickTick, which is expected)
  - `batch_add_tasks` creates all tasks with per-task tags

---------

Co-authored-by: CheerChen <meetcheerego@gmail.com>
## Summary

This PR expands the Mux Video provider beyond the existing on-demand
asset lifecycle. It adds asset metadata updates, Direct Upload
management, and Playback ID lookup based on the official Mux Video API.

## What changed

- Added `mux.update_asset` for updating asset `passthrough` and customer
metadata.
- Added `mux.create_direct_upload` for creating signed upload URLs
without proxying video bytes through OpenConnector.
- Added `mux.get_direct_upload` for checking upload status and the
resulting asset ID.
- Added `mux.list_direct_uploads` with page-based pagination.
- Added `mux.cancel_direct_upload` for cancelling uploads that are still
waiting.
- Added `mux.get_playback_id` for resolving a Playback ID to its
associated asset or live stream.
- Added runtime request mapping, input validation, response
normalization, and regression tests for the new actions.
- Preserved the existing Mux Basic Auth flow, guarded provider fetcher,
permission model, and upstream error handling.

## API research

The selected actions correspond to these official Mux Video API
endpoints:

- `PATCH /video/v1/assets/{ASSET_ID}`
- `POST /video/v1/uploads`
- `GET /video/v1/uploads/{UPLOAD_ID}`
- `GET /video/v1/uploads`
- `PUT /video/v1/uploads/{UPLOAD_ID}/cancel`
- `GET /video/v1/playback-ids/{PLAYBACK_ID}`

References:

- https://docs.mux.com/api-reference/video
-
https://github.com/muxinc/mux-node-sdk/blob/master/src/resources/video/api.md

## Validation

- `npm run generate:catalog`
- `npm run typecheck`
- `oxlint src/providers/mux/actions.ts src/providers/mux/runtime.ts
src/providers/mux/runtime.test.ts`
- `npm test` — 69 test files and 737 tests passed

Direct Upload responses expose the signed upload URL to the caller; the
connector does not handle or relay the media bytes.

---------

Co-authored-by: Valor <210239105+zrh805@users.noreply.github.com>
Co-authored-by: Kevin Cui <bh@bugs.cc>
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

⤵️ pull merge-conflict Resolve conflicts manually

Projects

None yet

Development

Successfully merging this pull request may close these issues.