Compare commits

..

46 Commits

Author SHA1 Message Date
Dallin Romney
48bdbad66a test: pin folded QA coverage ids 2026-06-19 13:29:26 -07:00
Dallin Romney
781e2ada63 test: avoid overclaiming gateway tool API coverage 2026-06-19 12:16:56 -07:00
Dallin Romney
fa78189458 test: preserve chat tools profile build guard 2026-06-19 12:16:56 -07:00
Dallin Romney
dafcdb901f test: update mirrored QA routing expectation 2026-06-19 12:16:56 -07:00
Dallin Romney
0b6eb3230c test: keep native QA evidence out of parity tiers 2026-06-19 12:16:56 -07:00
Dallin Romney
c1fe62ee83 test: align folded QA coverage ids 2026-06-19 12:16:56 -07:00
Dallin Romney
aa60716363 test: trim folded QA Lab script cruft 2026-06-19 12:16:56 -07:00
Dallin Romney
62dea06219 test: relax QA native scenario catalog inventory 2026-06-19 12:16:56 -07:00
Dallin Romney
e4270e7709 test: remove folded HTTP API script tests 2026-06-19 12:16:56 -07:00
Dallin Romney
f237f1da6d test: fold HTTP API script proof into QA Lab 2026-06-19 12:16:06 -07:00
Vincent Koc
6cfb025143 fix(e2e): reject unsafe chat tools body lengths
Reject unsafe numeric Content-Length values in the OpenAI chat tools E2E client before waiting on the response stream.

Also hardens Docker E2E heartbeat timing coverage after the exact-head release gate exposed a brittle zero-padded heartbeat assertion.

Verification: direct mock gateway repro, docker heartbeat shell proof, autoreview clean, and exact-head CI release gate https://github.com/openclaw/openclaw/actions/runs/27843455246.
2026-06-20 03:09:51 +08:00
Vincent Koc
061a3705db test(plugin-sdk): isolate runtime facade tests 2026-06-19 20:55:49 +02:00
Vincent Koc
9e5ac0cea4 refactor(extensions): drop stale internal declarations 2026-06-20 02:52:05 +08:00
Vincent Koc
aff6e221a7 fix(lmstudio): bound model load error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
5df5aa1640 fix(openai): bound batch error bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
59a93a817f fix(openai): bound device code auth bodies 2026-06-19 20:43:17 +02:00
Vincent Koc
23b8f5d037 refactor(discord): remove unused monitor hooks 2026-06-20 02:37:17 +08:00
Vincent Koc
17e2fbfa86 fix(test): harden script probe bounds (#95060)
Merged via squash.

Prepared head SHA: 3a51c3c2d7
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-20 02:31:40 +08:00
Vincent Koc
cbff4fa5bc refactor(extensions): drop unused internal type aliases 2026-06-20 02:22:31 +08:00
Vincent Koc
330545f3e9 refactor(voice-call): drop unused stream helpers 2026-06-20 02:07:08 +08:00
Vincent Koc
2b0a72bb48 fix(release): lazy-load sigstore verification 2026-06-19 20:02:21 +02:00
Lu Wang
583829a342 fix(ssh): scope tunnel port preflight to loopback (#94603) (#94607)
Merged via squash.

Prepared head SHA: 6798b718de
Co-authored-by: wangwllu <7668944+wangwllu@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
2026-06-19 18:59:58 +01:00
Vincent Koc
7b94ae9944 refactor(discord): drop unused internal wrapper methods 2026-06-20 01:52:02 +08:00
Vincent Koc
1609365b3e test(state): canonicalize sqlite volume assertions 2026-06-19 19:45:40 +02:00
Josh Lehman
d216f7c876 refactor: use canonical transcript reader identity (#89581)
* refactor: use canonical transcript reader identity

* refactor: keep transcript reader dependency storage-neutral
2026-06-19 10:40:18 -07:00
Vincent Koc
d41a3d28a0 refactor(oc-path): drop unused repack helper 2026-06-20 01:32:16 +08:00
Vincent Koc
8aa58c5fb0 fix(minimax): bound oauth token bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
e7e85f5436 fix(minimax): bound oauth error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
458904037f fix(parallel): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
1e53ee4fd5 fix(exa): bound search error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
6037d1a85c fix(ollama): bound stream error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
2c8d19d73e fix(ollama): bound embedding error bodies 2026-06-19 19:18:38 +02:00
Vincent Koc
70a48a680d fix(sdk): refresh plugin api baseline hash 2026-06-19 19:18:38 +02:00
Vincent Koc
0c210e5e52 fix(discord): deliver reasoning replies (#95029) 2026-06-20 01:18:14 +08:00
Vincent Koc
38807ffba4 test(plugins): isolate public surface runtime env 2026-06-19 19:08:32 +02:00
Vincent Koc
fb06df6cad refactor(voice-call): drop unused config type aliases 2026-06-20 01:07:03 +08:00
Vincent Koc
50614c51a8 test(ui): isolate chat browser layout fixtures 2026-06-19 18:54:19 +02:00
Vincent Koc
1f244f60ed test(secrets): load external plugin secret coverage 2026-06-19 18:35:29 +02:00
Vincent Koc
10b8b32380 refactor(codex): drop unused app-server helpers 2026-06-20 00:34:03 +08:00
Shakker
3b65f1d279 test: isolate sandbox registry state env 2026-06-19 17:32:09 +01:00
Yzx
1c711048f9 fix(agents): route plugin approvals through transport channel (#90918) 2026-06-19 12:31:06 -04:00
Vincent Koc
f69f81af9e fix(cli): use gateway skills status when available 2026-06-19 18:28:39 +02:00
Shakker
cdf4268540 fix: scope workspace default env 2026-06-19 17:24:03 +01:00
Vincent Koc
b4651f3781 refactor(codex): drop unused memory tool wrapper 2026-06-20 00:16:50 +08:00
Shakker
107c49e936 test: scope models config auth env 2026-06-19 17:10:24 +01:00
Shakker
ffd8c6e5d9 fix: scope model auth env helpers 2026-06-19 17:07:53 +01:00
146 changed files with 2315 additions and 6129 deletions

5
.github/labeler.yml vendored
View File

@@ -322,11 +322,6 @@
- any-glob-to-any-file:
- "extensions/policy/**"
- "docs/cli/policy.md"
"extensions: feeds":
- changed-files:
- any-glob-to-any-file:
- "extensions/feeds/**"
- "docs/plugins/reference/feeds.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,2 +1,2 @@
b29fdf14b8b6bd3f8f61699754bd3269e54a6452f0430784f0e42c0bbf6d2be3 plugin-sdk-api-baseline.json
d3a9400a6eb7b9e22ff7264dfe5afdda5bd694a6f8fa6427d146a4c4b1506d3e plugin-sdk-api-baseline.jsonl
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl

View File

@@ -38,8 +38,6 @@ openclaw plugins list --json
openclaw plugins search <query>
openclaw plugins search <query> --limit 20
openclaw plugins search <query> --json
openclaw plugins search <query> --catalog-feeds
openclaw plugins search <query> --catalog-feeds --feed-source approved
openclaw plugins install <path-or-spec>
openclaw plugins inspect <id>
openclaw plugins inspect <id> --runtime
@@ -105,7 +103,6 @@ rewriting files.
```bash
openclaw plugins search "calendar" # search ClawHub plugins
openclaw plugins search "calendar" --catalog-feeds # search configured feed plugins
openclaw plugins install <package> # source auto-detection
openclaw plugins install clawhub:<package> # ClawHub only
openclaw plugins install npm:<package> # npm only
@@ -129,13 +126,9 @@ sources with guarded environment variables. See
Bare package names install from npm by default during the launch cutover, unless they match an official plugin id. Raw `@openclaw/*` package specs that match bundled plugins use the bundled copy that shipped with the current OpenClaw build. Use `npm:<package>` when you deliberately want an external npm package instead. Use `clawhub:<package>` for ClawHub. Treat plugin installs like running code. Prefer pinned versions.
</Warning>
`plugins search` queries ClawHub for installable plugin packages by default,
or configured catalog feeds when you pass `--catalog-feeds`, pass
`--feed-source <id>`, or enable the Feeds plugin search default in config.
ClawHub search prints install-ready package names and searches code-plugin and
bundle-plugin packages, not skills. Feed search prints matching feed plugin
entries with source/feed provenance and install hints when the feed advertises
install metadata. Use `openclaw skills search` for skills.
`plugins search` queries ClawHub for installable plugin packages and prints
install-ready package names. It searches code-plugin and bundle-plugin packages,
not skills. Use `openclaw skills search` for ClawHub skills.
<Note>
ClawHub is the primary distribution and discovery surface for most plugins. Npm
@@ -312,12 +305,10 @@ does not import plugin runtime code, run a package manager, or repair missing
dependencies.
</Note>
`plugins search` is a remote ClawHub catalog lookup unless catalog-feed search
is explicitly requested or enabled as the Feeds plugin search default. It does not
mutate config, install packages, or load plugin runtime code. ClawHub results
include the package name, family, channel, version, summary, and an install hint
such as `openclaw plugins install clawhub:<package>`. Feed results include the
feed source id, feed id, entry metadata, and an install hint when advertised.
`plugins search` is a remote ClawHub catalog lookup. It does not inspect local
state, mutate config, install packages, or load plugin runtime code. Search
results include the ClawHub package name, family, channel, version, summary, and
an install hint such as `openclaw plugins install clawhub:<package>`.
For bundled plugin work inside a packaged Docker image, bind-mount the plugin
source directory over the matching packaged source path, such as

View File

@@ -18,9 +18,8 @@ report drift through `doctor --lint`. The final conformance signal is a clean
instead of creating a separate health gate.
Policy currently manages configured channels, MCP servers, model providers,
network SSRF posture, ingress/channel access posture, Gateway exposure posture,
feed catalog source posture, agent workspace posture, data-handling posture,
OpenClaw config secret provider/auth profile posture, and governed tool
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
declarations. For example, IT or a workspace operator can record that Telegram
is not an approved channel provider, restrict MCP servers and model refs to
approved entries, require private-network fetch/browser access to remain
@@ -116,17 +115,6 @@ file posture, and tool metadata looks like this:
"requireUrlAllowlists": true,
},
},
"feeds": {
"sources": {
"require": ["company-approved"],
"requirePinned": true,
"allowUnsigned": false,
},
"search": {
"requireDefault": true,
"requireSources": ["company-approved"],
},
},
"agents": {
"workspace": {
"allowedAccess": ["none", "ro"],
@@ -194,8 +182,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
settings, direct-message session scope, channel DM policy, channel group policy,
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
configured Feeds plugin source declarations, data-handling config posture, config
secret provider and SecretRef provenance, config auth profile metadata, configured
data-handling config posture, config secret
provider and SecretRef provenance, config auth profile metadata, configured
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
reports observed state that does not conform. If a policy denies non-loopback
Gateway binds, omit `gateway.bind` only when you
@@ -384,20 +372,6 @@ Every scope present in `policy.jsonc` must be valid and enforceable.
| `gateway.http.denyEndpoints` | Gateway HTTP API endpoints | Deny endpoint ids such as `chatCompletions` or `responses`. |
| `gateway.http.requireUrlAllowlists` | Gateway HTTP URL-fetch inputs | Set to `true` to require URL allowlists on URL-fetch inputs. |
#### Feed catalog sources
| Policy field | Observed state | Use when |
| ----------------------------- | ------------------------------------------------ | ------------------------------------------------------------------- |
| `feeds.sources.require` | `plugins.entries.feeds.config.sources[].id` | Require specific feed source ids to be configured and enabled. |
| `feeds.sources.requirePinned` | Feed source `trust` and `integrity` declarations | Set to `true` to require enabled feed sources to be pinned. |
| `feeds.sources.allowUnsigned` | Feed source `trust` declarations | Set to `false` to reject enabled sources using unsigned trust. |
| `feeds.search.requireDefault` | `plugins.entries.feeds.config.search.default` | Set to `true` to require native skills/plugins search to use feeds. |
| `feeds.search.requireSources` | `plugins.entries.feeds.config.search.sources[]` | Require default native feed search to use selected source ids. |
Feed policy observes only configured source declarations and native search
configuration. It does not fetch
feed documents, install entries, or enforce install decisions at runtime.
#### Agent workspace
| Policy field | Observed state | Use when |
@@ -692,16 +666,6 @@ Example JSON output:
"value": false
}
],
"feeds": [
{
"id": "company-approved",
"source": "oc://openclaw.config/plugins/entries/feeds/config/sources/#0",
"enabled": true,
"url": "https://feeds.example.com#0123456789ab",
"trust": "pinned",
"integrityPresent": true
}
],
"gatewayExposure": [
{
"id": "gateway-bind",
@@ -851,11 +815,6 @@ Policy currently verifies:
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/feeds-required-source-missing` | A required feed source id is not configured and enabled. |
| `policy/feeds-source-unpinned` | An enabled feed source is not pinned when policy requires pinned feeds. |
| `policy/feeds-source-unsigned` | An enabled feed source uses unsigned trust when policy denies unsigned feeds. |
| `policy/feeds-search-default-missing` | Native skills/plugins search is not configured to use feeds by default. |
| `policy/feeds-search-source-missing` | Native feed search does not require a policy-required source id. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |

View File

@@ -2,7 +2,7 @@
summary: "CLI reference for `openclaw skills` (search/install/update/verify/list/info/check/workshop)"
read_when:
- You want to see which skills are available and ready to run
- You want to search ClawHub or configured catalog feeds, or install skills from ClawHub, Git, or local directories
- You want to search ClawHub or install skills from ClawHub, Git, or local directories
- You want to verify a ClawHub skill with ClawHub
- You want to debug missing binaries/env/config for skills
title: "Skills"
@@ -10,9 +10,8 @@ title: "Skills"
# `openclaw skills`
Inspect local skills, search ClawHub or configured catalog feeds, install skills
from ClawHub/Git/local directories, verify ClawHub skills, and update
ClawHub-tracked installs.
Inspect local skills, search ClawHub, install skills from ClawHub/Git/local
directories, verify ClawHub skills, and update ClawHub-tracked installs.
Related:
@@ -26,8 +25,6 @@ Related:
```bash
openclaw skills search "calendar"
openclaw skills search --limit 20 --json
openclaw skills search "calendar" --catalog-feeds
openclaw skills search "calendar" --catalog-feeds --feed-source approved
openclaw skills install <slug>
openclaw skills install <slug> --version <version>
openclaw skills install git:owner/repo
@@ -67,14 +64,12 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`search` uses ClawHub by default, or configured catalog feeds when you pass
`--catalog-feeds`, pass `--feed-source <id>`, or enable the Feeds plugin search
default in config. `update` and `verify` use ClawHub directly. `install <slug>`
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill,
and `install ./path` copies a local skill directory. By default, `install`,
`update`, and `verify` target the active workspace `skills/` directory; with
`--global`, they target the shared managed skills directory. `list`/`info`/`check`
still inspect the local skills visible to the current workspace and config.
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
`install ./path` copies a local skill directory. By default, `install`, `update`,
and `verify` target the active workspace `skills/` directory; with `--global`,
they target the shared managed skills directory. `list`/`info`/`check` still
inspect the local skills visible to the current workspace and config.
Workspace-backed commands resolve the target workspace from `--agent <id>`, then
the current working directory when it is inside a configured agent workspace,
then the default agent.
@@ -91,11 +86,8 @@ settings use the separate `skills.install` request path instead.
Notes:
- `search [query...]` accepts an optional query; omit it to browse the default
ClawHub search feed, or the configured feed default when Feeds search is enabled.
ClawHub search feed.
- `search --limit <n>` caps returned results.
- `search --catalog-feeds` searches configured feed entries instead of ClawHub.
- `search --feed-source <id>` searches one configured feed source id; repeat it or
pass comma-separated ids to search multiple sources.
- `install git:owner/repo[@ref]` installs a Git skill. Branch refs may contain
slashes, such as `git:owner/repo@feature/foo`.
- `install ./path/to/skill` installs a local directory whose root contains

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
73 plugins
72 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -89,8 +89,6 @@ Each entry lists the package, distribution route, and description.
- **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw.
- **[feeds](/plugins/reference/feeds)** (`@openclaw/feeds`) - included in OpenClaw. Adds configured catalog feed source validation for skills and plugins.
- **[file-transfer](/plugins/reference/file-transfer)** (`@openclaw/file-transfer`) - included in OpenClaw. Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.
- **[fireworks](/plugins/reference/fireworks)** (`@openclaw/fireworks-provider`) - included in OpenClaw. Adds Fireworks model provider support to OpenClaw.

View File

@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 129
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
generated plugin reference pages by distribution, package, and description.

View File

@@ -1,149 +0,0 @@
---
summary: "Adds configured catalog feed source validation for skills and plugins."
read_when:
- You are installing, configuring, or auditing the feeds plugin
title: "Feeds plugin"
---
# Feeds plugin
Adds configured catalog feed source validation, search, install handoff,
lifecycle tooling, and optional native `skills search` / `plugins search` feed
integration.
## Distribution
- Package: `@openclaw/feeds`
- Install route: included in OpenClaw
## Surface
plugin
## Configure feed sources
Feed sources live under the bundled `feeds` plugin config. A source can point at
an `https://` or `file://` feed document and can optionally be pinned by
integrity.
```jsonc
{
"plugins": {
"entries": {
"feeds": {
"enabled": true,
"config": {
"sources": [
{
"id": "company-approved",
"url": "https://feeds.example.com/openclaw/feed.json",
"trust": "pinned",
"integrity": "sha256:...",
},
],
},
},
},
},
}
```
## Discover entries
```bash
openclaw feeds sources
openclaw feeds list --source company-approved
openclaw feeds search calendar --type plugin
```
## Install from a feed
`openclaw feeds install` resolves exactly one feed entry, checks the configured
feed install policy, and then hands off to the existing OpenClaw skill or plugin
install command. The feeds plugin does not introduce a second installer.
```bash
openclaw feeds install calendar-helper --source company-approved --type plugin --dry-run
openclaw feeds install calendar-helper --source company-approved --type plugin
openclaw feeds install calendar-helper --source company-approved --type plugin --force
```
Use `--dry-run` to print the underlying install command without running it. Use
`--force` to forward force behavior to the existing installer.
## Install policy
`installPolicy` controls approval checks for explicit feed-backed installs.
```jsonc
{
"plugins": {
"entries": {
"feeds": {
"enabled": true,
"config": {
"installPolicy": {
"mode": "enforce",
"requireApproval": true,
},
"sources": [
{
"id": "company-approved",
"url": "file:///opt/openclaw/feeds/company.json",
},
],
},
},
},
},
}
```
- `mode: "off"` performs no approval check.
- `mode: "warn"` reports unapproved entries and continues.
- `mode: "enforce"` blocks unapproved entries.
- `requireApproval: true` requires `approval.status: "approved"` on feed entries.
If `requireApproval` is `true` and `mode` is omitted, OpenClaw treats the policy
as enforce. If `mode` is `enforce` and `requireApproval` is omitted, approval is
required.
## Native search
`openclaw skills search` and `openclaw plugins search` continue to use ClawHub by
default. Operators can opt into configured feeds explicitly:
```bash
openclaw skills search calendar --catalog-feeds
openclaw plugins search calendar --feed-source company-approved
```
To make native search use feeds by default, configure the bundled Feeds plugin:
```jsonc
{
"plugins": {
"entries": {
"feeds": {
"enabled": true,
"config": {
"search": {
"default": true,
"sources": ["company-approved"],
},
"sources": [
{
"id": "company-approved",
"url": "https://feeds.example.com/openclaw/feed.json",
"trust": "pinned",
"integrity": "sha256:...",
},
],
},
},
},
},
}
```
Omit `search.sources` to search all enabled configured feed sources.

View File

@@ -299,25 +299,6 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Runtime.evaluate remote-object subset used by CDP helpers. */
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
/** Exception details surfaced from CDP Runtime.evaluate. */
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -854,11 +854,6 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
}
/** Lists available memory tool names understood by Codex workspace memory routing. */
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
const availableToolNames = new Set(

View File

@@ -29,26 +29,6 @@ const loadSharedClientModule = async () => {
return await sharedClientModulePromise;
};
/** Returns the process-shared app-server client for normal attempt reuse. */
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,

View File

@@ -80,10 +80,6 @@ class CodexThreadStartRequestError extends Error {
}
}
export function isCodexThreadStartRequestError(error: unknown): boolean {
return error instanceof CodexThreadStartRequestError;
}
export type CodexThreadFinalConfigPatchDecision =
| { action: "resume"; binding: CodexAppServerThreadBinding }
| { action: "start" };

View File

@@ -13,7 +13,6 @@ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements client behavior.
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
import type { APIInteraction } from "discord-api-types/v10";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
import type { BaseCommand } from "./commands.js";
@@ -272,18 +272,10 @@ export class Client {
return await this.entityCache.fetchMember(guildId, userId);
}
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
return await this.commandDeployer.getCommands();
}
async deployCommands(options: DeployCommandOptions = {}) {
return await this.commandDeployer.deploy(options);
}
async reconcileCommands() {
return await this.deployCommands({ mode: "reconcile" });
}
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
await dispatchInteraction(this, rawData);
}

View File

@@ -144,9 +144,6 @@ export abstract class Command extends BaseCommand {
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
);
}
async preCheck(interaction: unknown): Promise<unknown> {
return Boolean(interaction) || true;
}
serializeOptions() {
return this.options?.map((option) => {
if (typeof option.autocomplete === "function") {

View File

@@ -138,12 +138,6 @@ export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponen
addComponent(component: T): void {
this.components.push(component);
}
removeComponent(component: T): void {
this.components = this.components.filter((entry) => entry !== component);
}
removeAllComponents(): void {
this.components = [];
}
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
return {
type: this.type,

View File

@@ -462,18 +462,6 @@ export class GatewayPlugin extends Plugin {
return this.outboundLimiter.getStatus();
}
getIntentsInfo() {
const intents = this.options.intents ?? 0;
return {
intents,
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
};
}
hasIntent(intent: number): boolean {
return Boolean((this.options.intents ?? 0) & intent);
}

View File

@@ -16,7 +16,6 @@ import {
import {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.js";
@@ -209,15 +208,6 @@ export class BaseInteraction {
return result;
}
async deleteReply(): Promise<unknown> {
return await deleteWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async fetchReply(): Promise<unknown> {
return await getWebhookMessage(
this.client.rest,
@@ -293,18 +283,6 @@ export class BaseComponentInteraction extends BaseInteraction {
async showModal(modal: Modal): Promise<unknown> {
return await this.callback(InteractionResponseType.Modal, modal.serialize());
}
async editAndWaitForComponent(
payload: MessagePayload,
message: Message | null = this.message,
timeoutMs = 300_000,
) {
if (!message) {
return null;
}
const editedMessage = await message.edit(payload);
return await this.client.componentHandler.waitForMessageComponent(editedMessage, timeoutMs);
}
}
export class ButtonInteraction extends BaseComponentInteraction {}

View File

@@ -148,12 +148,6 @@ export function createDiscordDraftPreviewController(params: {
finalizedViaPreviewMessage = true;
},
disableBlockStreamingForDraft: draftStream ? true : undefined,
async startProgressDraft() {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },

View File

@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
);
});
it("renders the reasoning-payload reason with the same shape", () => {
it("renders the internal-only-payload reason with the same shape", () => {
expect(
formatDiscordReplySkip({
kind: "block",
reason: "reasoning payload",
reason: "internal-only payload",
target: "channel:456",
sessionKey: "agent:friday:discord:channel:456",
}),
).toBe(
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
);
});
@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
expect(
formatDiscordReplySkip({
kind: "tool",
reason: "reasoning payload",
reason: "internal-only payload",
target: "channel:c1",
sessionKey: "",
}),
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
});
it("preserves the kind discriminant in the message prefix", () => {

View File

@@ -2639,17 +2639,20 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("suppresses reasoning payload delivery to Discord", async () => {
it("delivers reasoning block payloads to Discord", async () => {
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "thinking...", isReasoning: true }],
});
});
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
it("delivers reasoning-tagged final payload to Discord", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "Reasoning:\nthis should stay internal",
text: "Reasoning:\nthis should be visible",
isReasoning: true,
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2661,8 +2664,10 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "this should be visible", isReasoning: true }],
});
});
it("delivers non-reasoning block payloads to Discord", async () => {

View File

@@ -113,10 +113,7 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -609,18 +606,6 @@ async function processDiscordMessageInner(
);
return null;
}
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return null;
}
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasMedia && !payload.isError) {
@@ -652,18 +637,6 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
if (
isFinal &&
!options?.allowFallbackOnlyToolWarning &&

View File

@@ -90,8 +90,6 @@ let discordProviderSessionRuntimePromise: Promise<DiscordProviderSessionRuntimeM
let fetchDiscordApplicationIdForTesting: typeof fetchDiscordApplicationId | undefined;
let createDiscordNativeCommandForTesting: typeof createDiscordNativeCommand | undefined;
let runDiscordGatewayLifecycleForTesting: typeof runDiscordGatewayLifecycle | undefined;
let createDiscordGatewayPluginForTesting: typeof createDiscordGatewayPlugin | undefined;
let createDiscordGatewaySupervisorForTesting: typeof createDiscordGatewaySupervisor | undefined;
let loadDiscordVoiceRuntimeForTesting: (() => Promise<DiscordVoiceRuntimeModule>) | undefined;
let loadDiscordProviderSessionRuntimeForTesting:
| (() => Promise<DiscordProviderSessionRuntimeModule>)
@@ -437,9 +435,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
discordConfig: discordCfg,
runtime,
createClient: createClientForTesting ?? ((...args) => new Client(...args)),
createGatewayPlugin: createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin,
createGatewaySupervisor:
createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor,
createGatewayPlugin: createDiscordGatewayPlugin,
createGatewaySupervisor: createDiscordGatewaySupervisor,
createAutoPresenceController: createDiscordAutoPresenceController,
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
});
@@ -643,12 +640,6 @@ export const testing = {
setRunDiscordGatewayLifecycle(mock?: typeof runDiscordGatewayLifecycle) {
runDiscordGatewayLifecycleForTesting = mock;
},
setCreateDiscordGatewayPlugin(mock?: typeof createDiscordGatewayPlugin) {
createDiscordGatewayPluginForTesting = mock;
},
setCreateDiscordGatewaySupervisor(mock?: typeof createDiscordGatewaySupervisor) {
createDiscordGatewaySupervisorForTesting = mock;
},
setLoadDiscordVoiceRuntime(mock?: () => Promise<DiscordVoiceRuntimeModule>) {
loadDiscordVoiceRuntimeForTesting = mock;
},

View File

@@ -141,6 +141,21 @@ describe("deliverDiscordReply", () => {
expect(sendOptions.rest).toBe(rest);
});
it("formats reasoning replies as visible Discord payloads before shared outbound", async () => {
await deliverDiscordReply({
replies: [{ text: "Because it helps", isReasoning: true }],
target: "channel:101",
token: "token",
accountId: "default",
runtime,
cfg,
textLimit: 2000,
kind: "block",
});
expect(firstDeliverParams().payloads).toEqual([{ text: "Thinking\n\n_Because it helps_" }]);
});
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
sendDurableMessageBatchMock.mockResolvedValueOnce({ status: "sent", results: [] });

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements reply delivery behavior.
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import {
buildOutboundSessionContext,
sendDurableMessageBatch,
@@ -156,6 +156,19 @@ function resolveDiscordDeliveryOptions(params: {
};
}
function formatDiscordReasoningPayload(payload: ReplyPayload): ReplyPayload {
if (payload.isReasoning !== true) {
return payload;
}
const text = typeof payload.text === "string" ? payload.text.trim() : "";
const nextPayload: ReplyPayload = {
...payload,
text: formatReasoningMessage(text),
};
delete nextPayload.isReasoning;
return nextPayload;
}
export async function deliverDiscordReply(params: {
cfg: OpenClawConfig;
replies: ReplyPayload[];
@@ -178,7 +191,9 @@ export async function deliverDiscordReply(params: {
void params.runtime;
const delivery = resolveDiscordDeliveryOptions(params);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
kind: params.kind,
}).map(formatDiscordReasoningPayload);
if (payloads.length === 0) {
return;
}

View File

@@ -27,11 +27,6 @@ export type PersistedThreadBindingRecord = ThreadBindingRecord & {
expiresAt?: number;
};
export type PersistedThreadBindingsPayload = {
version: 1;
bindings: Record<string, PersistedThreadBindingRecord>;
};
export type ThreadBindingManager = {
accountId: string;
getIdleTimeoutMs: () => number;

View File

@@ -1,5 +1,6 @@
// Exa provider module implements model/runtime integration.
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
buildSearchCacheKey,
DEFAULT_SEARCH_COUNT,
@@ -28,6 +29,7 @@ const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search";
const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const;
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -76,6 +78,10 @@ async function readExaSearchResults(response: Response): Promise<ExaSearchResult
}
}
async function readExaErrorDetail(response: Response): Promise<string> {
return await readResponseTextLimited(response, EXA_ERROR_BODY_LIMIT_BYTES);
}
function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined {
const trimmed = normalizeOptionalLowercaseString(value);
if (!trimmed) {
@@ -407,7 +413,7 @@ async function runExaSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text();
const detail = await readExaErrorDetail(res);
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
return readExaSearchResults(res);
@@ -607,6 +613,7 @@ export const testing = {
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;
export { testing as __testing };

View File

@@ -1,9 +1,31 @@
// Exa tests cover exa web search provider plugin behavior.
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { testing } from "../test-api.js";
import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js";
import { createExaWebSearchProvider } from "./exa-web-search-provider.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -242,4 +264,20 @@ describe("exa web search provider", () => {
"Exa API returned malformed JSON",
);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const detail = await testing.readExaErrorDetail(tracked.response);
expect(detail).toContain("exa upstream unavailable");
expect(detail).not.toContain("tail");
expect(await testing.readExaErrorDetail(new Response("short"))).toBe("short");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +0,0 @@
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";
export {
formatFeedInstallCommand,
searchConfiguredFeedEntries,
type FeedEntryResult,
} from "./src/cli.js";
export { type FeedEntryType } from "./src/feed-document.js";

View File

@@ -1,28 +0,0 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { registerFeedsCli } from "./src/cli.js";
import { registerFeedsDoctorChecks } from "./src/doctor/register.js";
export default definePluginEntry({
id: "feeds",
name: "Feeds",
description: "Adds configured catalog feed source validation for skills and plugins.",
register(api) {
api.registerCli(
async ({ program }) => {
registerFeedsCli(program);
},
{
descriptors: [
{
name: "feeds",
description: "Inspect configured skill and plugin catalog feeds",
hasSubcommands: true,
},
],
},
);
registerFeedsDoctorChecks();
},
});
export { registerFeedsCli } from "./src/cli.js";
export { registerFeedsDoctorChecks } from "./src/doctor/register.js";

View File

@@ -1,104 +0,0 @@
{
"id": "feeds",
"name": "Feeds",
"description": "Adds configured catalog feed source validation for skills and plugins.",
"activation": {
"onStartup": false,
"onCommands": [
"doctor",
"feeds"
]
},
"commandAliases": [
{
"name": "feeds",
"kind": "cli"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable feeds doctor checks."
},
"installPolicy": {
"type": "object",
"additionalProperties": false,
"description": "Optional install-time policy for entries discovered through feeds.",
"properties": {
"mode": {
"type": "string",
"enum": [
"off",
"warn",
"enforce"
],
"description": "Install policy mode. Defaults to off."
},
"requireApproval": {
"type": "boolean",
"description": "Require feed entries to declare approval.status=approved before install."
}
}
},
"search": {
"type": "object",
"additionalProperties": false,
"description": "Optional native skills/plugins search integration for configured feeds.",
"properties": {
"default": {
"type": "boolean",
"description": "Use configured feeds for native skills/plugins search when no CLI feed option is supplied."
},
"sources": {
"type": "array",
"description": "Optional source ids used by default native search. Omit to search all enabled sources.",
"items": {
"type": "string"
}
}
}
},
"sources": {
"type": "array",
"description": "Catalog feed sources used for curated skill and plugin discovery.",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"url"
],
"properties": {
"id": {
"type": "string",
"description": "Stable local feed identifier."
},
"url": {
"type": "string",
"description": "Absolute https:// or file:// feed document URL."
},
"enabled": {
"type": "boolean",
"description": "Enable this feed source. Defaults to true."
},
"trust": {
"type": "string",
"enum": [
"unsigned",
"pinned"
],
"description": "Whether this source is accepted unsigned or pinned by integrity hash."
},
"integrity": {
"type": "string",
"description": "Optional sha256:<hex> hash for pinned feed documents."
}
}
}
}
}
}
}

View File

@@ -1,24 +0,0 @@
{
"name": "@openclaw/feeds",
"version": "2026.5.28",
"private": true,
"description": "OpenClaw feed source configuration and doctor checks",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.5.28"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,139 +0,0 @@
import { describe, expect, it } from "vitest";
import {
evaluateFeedsConfig,
FEEDS_CHECK_IDS,
registerFeedsDoctorChecks,
resetFeedsDoctorChecksForTest,
} from "./register.js";
describe("Feeds doctor checks", () => {
it("registers each feeds health check once", () => {
const registered: string[] = [];
resetFeedsDoctorChecksForTest();
registerFeedsDoctorChecks({
registerHealthCheck(check) {
registered.push(check.id);
},
});
expect(registered).toEqual(FEEDS_CHECK_IDS);
});
it("accepts configured https and file feed sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/openclaw/feed.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
{
id: "local-review",
url: "file:///opt/openclaw/feeds/review.json",
},
],
},
},
},
},
},
});
expect(findings).toEqual([]);
});
it("reports invalid install policy config", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
installPolicy: { mode: "block", requireApproval: "yes" },
sources: [{ id: "approved", url: "https://feeds.example.com/root.json" }],
},
},
},
},
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "feeds/config-invalid",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/installPolicy/mode",
}),
]);
});
it("reports duplicate ids, unsupported urls, and missing pinned integrity", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{ id: "company", url: "https://feeds.example.com/openclaw/feed.json" },
{ id: "company", url: "http://feeds.example.com/feed.json" },
{ id: "pinned", url: "https://feeds.example.com/pinned.json", trust: "pinned" },
],
},
},
},
},
},
});
expect(findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "feeds/source-duplicate-id",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/id",
}),
expect.objectContaining({
checkId: "feeds/source-url-invalid",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#1/url",
}),
expect.objectContaining({
checkId: "feeds/source-integrity-missing",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#2/integrity",
}),
]),
);
});
it("warns when the enabled feeds plugin has no sources", () => {
const findings = evaluateFeedsConfig({
cfg: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {},
},
},
},
},
});
expect(findings).toEqual([
expect.objectContaining({
checkId: "feeds/source-missing",
severity: "warning",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources",
}),
]);
});
});

View File

@@ -1,337 +0,0 @@
import {
registerHealthCheck as registerPluginHealthCheck,
type HealthCheck,
type HealthCheckContext,
type HealthFinding,
} from "openclaw/plugin-sdk/health";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHECK_IDS = {
configInvalid: "feeds/config-invalid",
sourceMissing: "feeds/source-missing",
sourceDuplicateId: "feeds/source-duplicate-id",
sourceUrlInvalid: "feeds/source-url-invalid",
sourceIntegrityInvalid: "feeds/source-integrity-invalid",
sourceIntegrityMissing: "feeds/source-integrity-missing",
} as const;
export const FEEDS_CHECK_IDS = [
CHECK_IDS.configInvalid,
CHECK_IDS.sourceMissing,
CHECK_IDS.sourceDuplicateId,
CHECK_IDS.sourceUrlInvalid,
CHECK_IDS.sourceIntegrityInvalid,
CHECK_IDS.sourceIntegrityMissing,
] as const;
type FeedsCheckId = (typeof FEEDS_CHECK_IDS)[number];
export type FeedsDoctorRegistrationHost = {
readonly registerHealthCheck: (check: HealthCheck) => void;
};
let registered = false;
export function registerFeedsDoctorChecks(host?: FeedsDoctorRegistrationHost): void {
if (registered) {
return;
}
const registerHealthCheck = host?.registerHealthCheck ?? registerPluginHealthCheck;
for (const check of feedsHealthChecks) {
registerHealthCheck(check);
}
registered = true;
}
export function resetFeedsDoctorChecksForTest(): void {
registered = false;
}
const feedsHealthChecks: readonly HealthCheck[] = FEEDS_CHECK_IDS.map((id) => ({
id,
kind: "plugin",
description: feedsCheckDescription(id),
source: "feeds",
async detect(ctx) {
return evaluateFeedsConfig(ctx).filter((finding) => finding.checkId === id);
},
}));
function feedsCheckDescription(id: FeedsCheckId): string {
switch (id) {
case CHECK_IDS.configInvalid:
return "The Feeds plugin configuration is well-formed.";
case CHECK_IDS.sourceMissing:
return "The enabled Feeds plugin has at least one configured source.";
case CHECK_IDS.sourceDuplicateId:
return "Feed source ids are unique.";
case CHECK_IDS.sourceUrlInvalid:
return "Feed source URLs are supported absolute URLs.";
case CHECK_IDS.sourceIntegrityInvalid:
return "Feed source integrity hashes use sha256:<hex> syntax.";
case CHECK_IDS.sourceIntegrityMissing:
return "Pinned feed sources declare an integrity hash.";
}
const exhaustive: never = id;
return exhaustive;
}
export function evaluateFeedsConfig(
ctx: Pick<HealthCheckContext, "cfg">,
): readonly HealthFinding[] {
const config = ctx.cfg.plugins?.entries?.feeds?.config;
const configPath = "plugins.entries.feeds.config";
const configOcPath = "oc://openclaw.config/plugins/entries/feeds/config";
if (config === undefined) {
return [
{
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: configPath,
ocPath: configOcPath,
fixHint: "Add plugins.entries.feeds.config.sources with at least one feed source.",
},
];
}
if (!isRecord(config)) {
return [
invalidConfigFinding({
propertyPath: configPath,
target: configOcPath,
message: "plugins.entries.feeds.config must be an object.",
fixHint: "Set plugins.entries.feeds.config to an object with a sources array.",
}),
];
}
const findings: HealthFinding[] = [];
const installPolicyFinding = evaluateInstallPolicyConfig(config.installPolicy);
if (installPolicyFinding !== undefined) {
findings.push(installPolicyFinding);
}
const sources = config.sources;
if (sources === undefined) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has no configured feed sources.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source.",
});
return findings;
}
if (!Array.isArray(sources)) {
findings.push(
invalidConfigFinding({
propertyPath: `${configPath}.sources`,
target: `${configOcPath}/sources`,
message: "plugins.entries.feeds.config.sources must be an array.",
fixHint: "Set sources to an array of feed source objects.",
}),
);
return findings;
}
if (sources.length === 0) {
findings.push({
checkId: CHECK_IDS.sourceMissing,
severity: "warning",
message: "The enabled Feeds plugin has an empty feed source list.",
source: "feeds",
path: `${configPath}.sources`,
ocPath: `${configOcPath}/sources`,
fixHint: "Add at least one feed source or disable the Feeds plugin.",
});
return findings;
}
const seenIds = new Map<string, number>();
sources.forEach((source, index) => {
findings.push(...evaluateFeedSource(source, index, seenIds));
});
return findings;
}
function evaluateInstallPolicyConfig(value: unknown): HealthFinding | undefined {
const basePath = "plugins.entries.feeds.config.installPolicy";
const baseOcPath = "oc://openclaw.config/plugins/entries/feeds/config/installPolicy";
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return invalidConfigFinding({
propertyPath: basePath,
target: baseOcPath,
message: "plugins.entries.feeds.config.installPolicy must be an object.",
fixHint: 'Use installPolicy: { mode: "warn", requireApproval: true } or remove it.',
});
}
if (
value.mode !== undefined &&
value.mode !== "off" &&
value.mode !== "warn" &&
value.mode !== "enforce"
) {
return invalidConfigFinding({
propertyPath: `${basePath}.mode`,
target: `${baseOcPath}/mode`,
message: "plugins.entries.feeds.config.installPolicy.mode must be off, warn, or enforce.",
fixHint: 'Use mode "off", "warn", or "enforce".',
});
}
if (value.requireApproval !== undefined && typeof value.requireApproval !== "boolean") {
return invalidConfigFinding({
propertyPath: `${basePath}.requireApproval`,
target: `${baseOcPath}/requireApproval`,
message: "plugins.entries.feeds.config.installPolicy.requireApproval must be a boolean.",
fixHint: "Set requireApproval to true or false.",
});
}
return undefined;
}
function evaluateFeedSource(
source: unknown,
index: number,
seenIds: Map<string, number>,
): readonly HealthFinding[] {
const sourcePath = `plugins.entries.feeds.config.sources[${index}]`;
const sourceOcPath = `oc://openclaw.config/plugins/entries/feeds/config/sources/#${index}`;
if (!isRecord(source)) {
return [
invalidConfigFinding({
propertyPath: sourcePath,
target: sourceOcPath,
message: `Feed source ${index} must be an object.`,
fixHint: "Replace this source with an object containing id and url.",
}),
];
}
const findings: HealthFinding[] = [];
const id = typeof source.id === "string" ? source.id.trim() : "";
if (!/^[a-z0-9][a-z0-9._-]{0,63}$/u.test(id)) {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.id`,
target: `${sourceOcPath}/id`,
message: `Feed source ${index} must have a stable lowercase id.`,
fixHint: "Use a lowercase id such as company-approved or clawhub-public.",
}),
);
} else {
const previous = seenIds.get(id);
if (previous !== undefined) {
findings.push({
checkId: CHECK_IDS.sourceDuplicateId,
severity: "error",
message: `Feed source id '${id}' duplicates sources[${previous}].`,
source: "feeds",
path: `${sourcePath}.id`,
ocPath: `${sourceOcPath}/id`,
fixHint: "Give each feed source a unique id.",
});
} else {
seenIds.set(id, index);
}
}
const url = typeof source.url === "string" ? source.url.trim() : "";
if (!isSupportedFeedUrl(url)) {
findings.push({
checkId: CHECK_IDS.sourceUrlInvalid,
severity: "error",
message: `Feed source ${id || index} must use an absolute https:// or file:// URL.`,
source: "feeds",
path: `${sourcePath}.url`,
ocPath: `${sourceOcPath}/url`,
fixHint: "Use an absolute https:// URL for hosted feeds or file:// URL for local feeds.",
});
}
const trust = source.trust;
if (trust !== undefined && trust !== "unsigned" && trust !== "pinned") {
findings.push(
invalidConfigFinding({
propertyPath: `${sourcePath}.trust`,
target: `${sourceOcPath}/trust`,
message: `Feed source ${id || index} has unsupported trust value '${formatUnknown(trust)}'.`,
fixHint: 'Use trust "unsigned" or "pinned".',
}),
);
}
const integrity = source.integrity;
if (integrity !== undefined && !isSha256Integrity(integrity)) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityInvalid,
severity: "error",
message: `Feed source ${id || index} has an invalid integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: "Use sha256:<64 lowercase or uppercase hexadecimal characters>.",
});
}
if (trust === "pinned" && integrity === undefined) {
findings.push({
checkId: CHECK_IDS.sourceIntegrityMissing,
severity: "error",
message: `Pinned feed source ${id || index} must declare an integrity hash.`,
source: "feeds",
path: `${sourcePath}.integrity`,
ocPath: `${sourceOcPath}/integrity`,
fixHint: 'Add integrity: "sha256:<hex>" or change trust to "unsigned".',
});
}
return findings;
}
function invalidConfigFinding(params: {
readonly propertyPath: string;
readonly target: string;
readonly message: string;
readonly fixHint: string;
}): HealthFinding {
return {
checkId: CHECK_IDS.configInvalid,
severity: "error",
message: params.message,
source: "feeds",
path: params.propertyPath,
ocPath: params.target,
fixHint: params.fixHint,
};
}
function isSupportedFeedUrl(value: string): boolean {
if (value === "") {
return false;
}
try {
const parsed = new URL(value);
return parsed.protocol === "https:" || parsed.protocol === "file:";
} catch {
return false;
}
}
function isSha256Integrity(value: unknown): boolean {
return typeof value === "string" && /^sha256:[a-f0-9]{64}$/iu.test(value);
}
function formatUnknown(value: unknown): string {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return "<unprintable>";
}
}

View File

@@ -1,170 +0,0 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export const MAX_FEED_DOCUMENT_BYTES = 1024 * 1024;
export type FeedSourceConfig = {
readonly id: string;
readonly url: string;
readonly enabled: boolean;
readonly trust?: "unsigned" | "pinned";
readonly integrity?: string;
};
export type FeedEntryType = "skill" | "plugin";
export type FeedEntry = {
readonly type: FeedEntryType;
readonly id: string;
readonly version?: string;
readonly name?: string;
readonly description?: string;
readonly tags?: readonly string[];
readonly sourceUrl?: string;
readonly sha256?: string;
readonly install?: Record<string, unknown>;
readonly approval?: Record<string, unknown>;
};
export type FeedDocument = {
readonly schemaVersion: 1;
readonly id: string;
readonly generatedAt?: string;
readonly entries: readonly FeedEntry[];
};
export type LoadedFeedDocument = {
readonly source: FeedSourceConfig;
readonly document: FeedDocument;
readonly sha256: string;
};
export type FeedFetch = (url: string) => Promise<{ readonly ok: boolean; readonly text: string }>;
export type FeedDocumentRuntime = {
readonly fetch?: FeedFetch;
readonly readFile?: (path: string) => Promise<Buffer | string>;
};
export async function loadFeedDocument(
source: FeedSourceConfig,
runtime: FeedDocumentRuntime = {},
): Promise<LoadedFeedDocument> {
const raw = await readFeedBytes(source.url, runtime);
const sha256 = createHash("sha256").update(raw).digest("hex");
if (source.trust === "pinned" && source.integrity === undefined) {
throw new Error(`Feed source ${source.id} requires integrity for pinned trust.`);
}
if (source.integrity !== undefined && source.integrity.toLowerCase() !== `sha256:${sha256}`) {
throw new Error(`Feed source ${source.id} integrity mismatch.`);
}
const parsed = parseFeedDocument(JSON.parse(raw.toString("utf8")), source.id);
return { source, document: parsed, sha256 };
}
export function parseFeedDocument(value: unknown, sourceId = "feed"): FeedDocument {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} must contain a JSON object.`);
}
if (value.schemaVersion !== 1) {
throw new Error(`Feed source ${sourceId} must use schemaVersion 1.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} must declare a feed id.`);
}
if (value.generatedAt !== undefined && typeof value.generatedAt !== "string") {
throw new Error(`Feed source ${sourceId} generatedAt must be a string when present.`);
}
if (!Array.isArray(value.entries)) {
throw new Error(`Feed source ${sourceId} entries must be an array.`);
}
return {
schemaVersion: 1,
id: value.id,
...(typeof value.generatedAt === "string" ? { generatedAt: value.generatedAt } : {}),
entries: value.entries.map((entry, index) => parseFeedEntry(entry, sourceId, index)),
};
}
export function feedEntryMatchesQuery(entry: FeedEntry, query: string): boolean {
const normalized = query.trim().toLowerCase();
if (normalized === "") {
return true;
}
const haystack = [
entry.type,
entry.id,
entry.version,
entry.name,
entry.description,
...(entry.tags ?? []),
]
.filter((value): value is string => typeof value === "string")
.join("\n")
.toLowerCase();
return haystack.includes(normalized);
}
export async function readFeedBytes(url: string, runtime: FeedDocumentRuntime): Promise<Buffer> {
const parsed = new URL(url);
if (parsed.protocol === "file:") {
const read = runtime.readFile ?? readFile;
const value = await read(fileURLToPath(parsed));
return Buffer.isBuffer(value) ? value : Buffer.from(value);
}
if (parsed.protocol === "https:") {
const fetcher = runtime.fetch ?? defaultFetch;
const response = await fetcher(url);
if (!response.ok) {
throw new Error(`Feed URL ${url} did not return a successful response.`);
}
return Buffer.from(response.text, "utf8");
}
throw new Error(`Unsupported feed URL protocol for ${url}.`);
}
async function defaultFetch(url: string): Promise<{ readonly ok: boolean; readonly text: string }> {
const { response, release } = await fetchWithSsrFGuard({
url,
auditContext: "feeds.feed-document",
});
try {
const body = await readResponseWithLimit(response, MAX_FEED_DOCUMENT_BYTES, {
onOverflow: ({ maxBytes }) =>
new Error("Feed URL " + url + " response exceeds " + maxBytes + " bytes."),
});
return { ok: response.ok, text: body.toString("utf8") };
} finally {
await release();
}
}
function parseFeedEntry(value: unknown, sourceId: string, index: number): FeedEntry {
if (!isRecord(value)) {
throw new Error(`Feed source ${sourceId} entry ${index} must be an object.`);
}
if (value.type !== "skill" && value.type !== "plugin") {
throw new Error(`Feed source ${sourceId} entry ${index} must be a skill or plugin.`);
}
if (typeof value.id !== "string" || value.id.trim() === "") {
throw new Error(`Feed source ${sourceId} entry ${index} must declare an id.`);
}
return {
type: value.type,
id: value.id,
...(typeof value.version === "string" ? { version: value.version } : {}),
...(typeof value.name === "string" ? { name: value.name } : {}),
...(typeof value.description === "string" ? { description: value.description } : {}),
...(Array.isArray(value.tags) && value.tags.every((tag) => typeof tag === "string")
? { tags: value.tags }
: {}),
...(typeof value.sourceUrl === "string" ? { sourceUrl: value.sourceUrl } : {}),
...(typeof value.sha256 === "string" ? { sha256: value.sha256 } : {}),
...(isRecord(value.install) ? { install: value.install } : {}),
...(isRecord(value.approval) ? { approval: value.approval } : {}),
};
}

View File

@@ -1,7 +1,10 @@
// Lmstudio plugin module implements models.fetch behavior.
import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonArrayFieldResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup";
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -17,6 +20,7 @@ import {
import { buildLmstudioAuthHeaders } from "./runtime.js";
const log = createSubsystemLogger("extensions/lmstudio/models");
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type LmstudioLoadResponse = {
status?: string;
@@ -253,7 +257,7 @@ export async function ensureLmstudioModelLoaded(params: {
});
try {
if (!response.ok) {
const body = await response.text();
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`);
}
let payload: LmstudioLoadResponse;

View File

@@ -44,6 +44,27 @@ describe("lmstudio-models", () => {
}
return JSON.parse(init.body) as unknown;
};
const cancelTrackedResponse = (
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} => {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
};
const createModelLoadFetchMock = (params?: {
loadedContextLength?: number;
maxContextLength?: number;
@@ -486,6 +507,39 @@ describe("lmstudio-models", () => {
).rejects.toThrow("LM Studio model load returned malformed JSON");
});
it("bounds model load error bodies", async () => {
const body = `${"lmstudio load unavailable ".repeat(512)}tail`;
const tracked = cancelTrackedResponse(body, { status: 503 });
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
vi.stubGlobal("fetch", asFetch(fetchMock));
const error = await ensureLmstudioModelLoaded({
baseUrl: "http://localhost:1234/v1",
modelKey: "qwen3-8b-instruct",
}).catch((caught: unknown) => caught);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/LM Studio model load failed \(503\): lmstudio load unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reloads model to the clamped default target when already loaded below the default window", async () => {
const fetchMock = createModelLoadFetchMock({
loadedContextLength: 4096,

View File

@@ -3,6 +3,28 @@ import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loginMiniMaxPortalOAuth, normalizeOAuthExpires } from "./oauth.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -30,6 +52,116 @@ describe("normalizeOAuthExpires", () => {
});
describe("loginMiniMaxPortalOAuth", () => {
it("bounds authorization error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(
`${"minimax authorization unavailable ".repeat(1024)}tail`,
{
status: 503,
headers: { "Content-Type": "text/plain" },
},
);
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/MiniMax OAuth authorization failed: minimax authorization unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds token error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"minimax token unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain("minimax token unavailable");
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds HTTP 200 token bodies before app-level parsing", async () => {
const tracked = cancelTrackedResponse(`${'{"status":"error","detail":"'.repeat(512)}tail`, {
status: 200,
headers: { "Content-Type": "application/json" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let callCount = 0;
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
callCount += 1;
const body =
init?.body instanceof URLSearchParams
? init.body
: new URLSearchParams(typeof init?.body === "string" ? init.body : "");
if (callCount === 1) {
return new Response(
JSON.stringify({
user_code: "CODE",
verification_uri: "https://example.com/device",
expired_in: Date.now() + 10_000,
state: body.get("state"),
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return tracked.response;
});
vi.stubGlobal("fetch", fetchMock);
const error = await loginMiniMaxPortalOAuth({
openUrl: vi.fn(async () => undefined),
note: vi.fn(async () => undefined),
progress: { update: vi.fn(), stop: vi.fn() },
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("MiniMax OAuth failed to parse response.");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("uses MiniMax account OAuth endpoints directly for global and CN login", async () => {
for (const [region, expectedHosts] of [
[

View File

@@ -7,6 +7,7 @@ import {
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -29,6 +30,7 @@ const MINIMAX_OAUTH_SCOPE = "group_id profile model.completion";
const MINIMAX_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:user_code";
const MINIMAX_RELATIVE_EXPIRY_SECONDS_THRESHOLD = 1_000_000_000;
const MINIMAX_ABSOLUTE_EXPIRY_MS_THRESHOLD = 1_000_000_000_000;
const MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
function getOAuthEndpoints(region: MiniMaxRegion) {
const config = MINIMAX_OAUTH_CONFIG[region];
@@ -115,7 +117,7 @@ async function requestOAuthCode(params: {
});
try {
if (!response.ok) {
const text = await response.text();
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
@@ -171,7 +173,7 @@ async function pollOAuthToken(params: {
}
async function parseMiniMaxOAuthTokenResponse(response: Response): Promise<TokenResult> {
const text = await response.text();
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
let payload:
| {
status?: string;

View File

@@ -65,7 +65,7 @@ export {
// `evaluatePredicate`, `getPathLayout`, `parseOrdinalSeg`,
// `parsePredicateSeg`, `parseUnionSeg`, `quoteSeg`, `unquoteSeg`,
// `repackPath`, `resolvePositionalSeg`, `splitRespectingBrackets`
// `resolvePositionalSeg`, `splitRespectingBrackets`
// were exported from earlier prototypes. They're substrate-internal
// helpers — used by `find.ts`, the per-kind resolvers, and the parser
// itself, but not part of the upstream-portable public surface.

View File

@@ -546,7 +546,7 @@ export interface PathSegmentLayout {
export function getPathLayout(path: OcPath): PathSegmentLayout {
// Quote-aware split — `.split('.')` would shred a quoted segment
// containing a literal `.` (e.g. `"a.b"`) and break repackPath.
// containing a literal `.` (e.g. `"a.b"`).
const sectionSubs = path.section === undefined ? [] : splitRespectingBrackets(path.section, ".");
const itemSubs = path.item === undefined ? [] : splitRespectingBrackets(path.item, ".");
const fieldSubs = path.field === undefined ? [] : splitRespectingBrackets(path.field, ".");
@@ -558,31 +558,6 @@ export function getPathLayout(path: OcPath): PathSegmentLayout {
};
}
/**
* Re-pack a concrete sub-segment list into an `OcPath` preserving the
* pattern's slot boundaries. Throws on length mismatch.
*/
export function repackPath(pattern: OcPath, subs: readonly string[]): OcPath {
const layout = getPathLayout(pattern);
if (subs.length !== layout.subs.length) {
fail(
`repack length mismatch: pattern has ${layout.subs.length} sub-segments, got ${subs.length}`,
formatOcPath(pattern),
"OC_PATH_REPACK_LENGTH",
);
}
const sectionSubs = subs.slice(0, layout.sectionLen);
const itemSubs = subs.slice(layout.sectionLen, layout.sectionLen + layout.itemLen);
const fieldSubs = subs.slice(layout.sectionLen + layout.itemLen);
return {
file: pattern.file,
...(sectionSubs.length > 0 ? { section: sectionSubs.join(".") } : {}),
...(itemSubs.length > 0 ? { item: itemSubs.join(".") } : {}),
...(fieldSubs.length > 0 ? { field: fieldSubs.join(".") } : {}),
...(pattern.session !== undefined ? { session: pattern.session } : {}),
};
}
function extractSession(queryPart: string, input: string): string | undefined {
if (queryPart.length === 0) {
return undefined;

View File

@@ -83,6 +83,28 @@ function firstGuardedFetchCall(): Record<string, unknown> {
return call as Record<string, unknown>;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function expectEmbeddingFetch(
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
url: string,
@@ -317,6 +339,39 @@ describe("ollama embedding provider", () => {
});
});
it("bounds embed error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
let error: unknown;
try {
await provider.embedQuery("hello");
} catch (err) {
error = err;
}
expect(String(error)).toContain("Ollama embed HTTP 503");
expect(String(error)).toContain("ollama embed unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,6 +6,7 @@ import {
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
hasConfiguredSecretInput,
@@ -57,6 +58,7 @@ export type OllamaEmbeddingClient = {
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
@@ -340,7 +342,11 @@ export async function createOllamaEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},

View File

@@ -1534,6 +1534,28 @@ function getGuardedFetchCall(fetchMock: typeof fetchWithSsrFGuardMock): GuardedF
return (fetchMock.mock.calls.at(0)?.[0] as GuardedFetchCall | undefined) ?? { url: "" };
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
async function createOllamaTestStream(params: {
baseUrl: string;
defaultHeaders?: Record<string, string>;
@@ -2684,12 +2706,14 @@ describe("createOllamaStreamFn", () => {
);
});
it("surfaces non-2xx HTTP response as status-prefixed error", async () => {
it("surfaces bounded non-2xx HTTP response text as a status-prefixed error", async () => {
const tracked = cancelTrackedResponse(`${"Service Unavailable ".repeat(1024)}tail`, {
status: 503,
statusText: "Service Unavailable",
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
}),
response: tracked.response,
release: vi.fn(async () => undefined),
});
try {
@@ -2705,6 +2729,10 @@ describe("createOllamaStreamFn", () => {
// The error message must start with the HTTP status code so that
// extractLeadingHttpStatus can parse it for failover/retry logic.
expect(errorEvent.error.errorMessage).toMatch(/^503\b/);
expect(errorEvent.error.errorMessage).toContain("Service Unavailable");
expect(errorEvent.error.errorMessage).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
} finally {
fetchWithSsrFGuardMock.mockReset();
}

View File

@@ -18,6 +18,7 @@ import type {
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
@@ -54,6 +55,7 @@ export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
const OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const GARBLED_VISIBLE_TEXT_MODEL_RE = /\b(?:glm|kimi)\b/i;
const GARBLED_VISIBLE_TEXT_MIN_CHARS = 80;
const GARBLED_VISIBLE_TEXT_SYMBOL_RE = /[$#%&="'_~`^|\\/*+\-[\]{}()<>:;,.!?]/gu;
@@ -1211,7 +1213,10 @@ function createRawOllamaStreamFn(
try {
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
const errorText = await readResponseTextLimited(
response,
OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`${response.status} ${errorText}`);
}
if (!response.body) {

View File

@@ -15,6 +15,28 @@ function jsonlBytes(value: string): number {
return jsonlEncoder.encode(value).byteLength;
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function fetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
@@ -243,4 +265,56 @@ describe("OpenAI embedding batch output", () => {
["3", [4]],
]);
});
it("bounds batch resource error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"batch status unavailable ".repeat(1024)}tail`, {
status: 400,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
let batchStatusReturned = false;
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = fetchInputUrl(input);
if (url.endsWith("/files") && init?.method === "POST") {
return jsonResponse({ id: "file-0" });
}
if (url.endsWith("/batches") && init?.method === "POST") {
return jsonResponse({ id: "batch-0", status: "in_progress" });
}
if (url.endsWith("/batches/batch-0") && !batchStatusReturned) {
batchStatusReturned = true;
return tracked.response;
}
return new Response("unexpected request", { status: 500 });
});
await expect(
runOpenAiEmbeddingBatches({
openAi: {
baseUrl: "https://openai-compatible.example/v1",
headers: { Authorization: "Bearer test" },
model: "text-embedding-3-small",
fetchImpl,
},
agentId: "main",
requests: [
{
custom_id: "0",
method: "POST",
url: "/v1/embeddings",
body: {
model: "text-embedding-3-small",
input: "payload",
},
},
],
wait: true,
concurrency: 1,
pollIntervalMs: 1000,
timeoutMs: 60_000,
}),
).rejects.toThrow(/openai batch status failed: 400 batch status unavailable/);
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -18,6 +18,7 @@ import {
uploadBatchJsonlFile,
withRemoteHttpResponse,
} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { OpenAiEmbeddingClient } from "./embedding-provider.js";
@@ -55,6 +56,7 @@ const OPENAI_BATCH_MAX_REQUESTS = 50000;
// splitter avoids boundary-size uploads while preserving source-wide batching.
const OPENAI_BATCH_MAX_JSONL_BYTES = 190 * 1024 * 1024;
const OPENAI_BATCH_MAX_POLL_BACKOFF_MS = 5 * 60_000;
const OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
async function submitOpenAiBatch(params: {
openAi: OpenAiEmbeddingClient;
@@ -126,7 +128,7 @@ async function fetchOpenAiBatchResource<T>(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await res.text();
const text = await readResponseTextLimited(res, OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES);
throw new Error(`${params.errorPrefix} failed: ${res.status} ${text}`);
}
return await params.parse(res);

View File

@@ -18,6 +18,28 @@ function createJsonResponse(body: unknown, init?: { status?: number }) {
});
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function fetchCall(fetchMock: ReturnType<typeof vi.fn<typeof fetch>>, index: number) {
const call = fetchMock.mock.calls[index];
if (!call) {
@@ -172,6 +194,44 @@ describe("loginOpenAICodexDeviceCode", () => {
expect(credentials.expires).toBe(expectedExpiry);
});
it("accepts token exchange JSON above the diagnostic preview limit", async () => {
const accessToken = createJwt({
exp: Math.floor(Date.now() / 1000) + 600,
"https://api.openai.com/auth": {
chatgpt_account_id: "acct_123",
},
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createJsonResponse({
device_auth_id: "device-auth-123",
user_code: "CODE-12345",
interval: "0",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
authorization_code: "authorization-code-123",
code_verifier: "code-verifier-123",
}),
)
.mockResolvedValueOnce(
createJsonResponse({
access_token: accessToken,
refresh_token: "refresh-token-123",
id_token: "x".repeat(10_000),
}),
);
const credentials = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
});
expect(credentials.refresh).toBe("refresh-token-123");
});
it("falls back when device-code intervals and token lifetimes overflow safe milliseconds", async () => {
vi.useFakeTimers();
try {
@@ -241,6 +301,28 @@ describe("loginOpenAICodexDeviceCode", () => {
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
});
it("bounds user-code error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"device code unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn().mockResolvedValueOnce(tracked.response);
const error = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/OpenAI device code request failed: HTTP 503 device code unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("surfaces device authorization failures with sanitized payload details", async () => {
const fetchMock = vi
.fn()

View File

@@ -3,6 +3,7 @@ import {
positiveSecondsToSafeMilliseconds,
resolveExpiresAtMsFromDurationSeconds,
} from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { resolveCodexAccessTokenExpiry } from "./openai-chatgpt-auth-identity.js";
import { trimNonEmptyString } from "./openai-chatgpt-shared.js";
@@ -12,6 +13,8 @@ const OPENAI_CODEX_DEVICE_CODE_TIMEOUT_MS = 15 * 60_000;
const OPENAI_CODEX_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5_000;
const OPENAI_CODEX_DEVICE_CODE_MIN_INTERVAL_MS = 1_000;
const OPENAI_CODEX_DEVICE_CALLBACK_URL = `${OPENAI_AUTH_BASE_URL}/deviceauth/callback`;
const OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES = 256 * 1024;
function resolveOpenAICodexDeviceCodeHeaders(contentType: string): Record<string, string> {
const version = process.env.OPENCLAW_VERSION?.trim();
@@ -120,6 +123,15 @@ function formatDeviceCodeError(params: {
: `${params.prefix}: HTTP ${params.status}`;
}
async function readOpenAICodexDeviceBody(response: Response): Promise<string> {
return await readResponseTextLimited(
response,
response.ok
? OPENAI_CODEX_DEVICE_JSON_BODY_LIMIT_BYTES
: OPENAI_CODEX_DEVICE_ERROR_BODY_LIMIT_BYTES,
);
}
async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<RequestedDeviceCode> {
const response = await fetchFn(`${OPENAI_AUTH_BASE_URL}/api/accounts/deviceauth/usercode`, {
method: "POST",
@@ -129,7 +141,7 @@ async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<Requ
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
if (response.status === 404) {
throw new Error(
@@ -180,7 +192,7 @@ async function pollOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (response.ok) {
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
const authorizationCode = trimNonEmptyString(body?.authorization_code);
@@ -230,7 +242,7 @@ async function exchangeOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await response.text();
const bodyText = await readOpenAICodexDeviceBody(response);
if (!response.ok) {
throw new Error(
formatDeviceCodeError({

View File

@@ -44,6 +44,28 @@ function jsonResponse(body: unknown, headers?: Record<string, string>): Response
});
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function readBody(call: EndpointCall): Record<string, unknown> {
if (typeof call.init.body !== "string") {
throw new Error("Expected a JSON string body.");
@@ -270,4 +292,23 @@ describe("runParallelMcpSearch", () => {
/initialize failed \(500\)/,
);
});
it("bounds initialize error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { withTrustedWebSearchEndpoint } from "openclaw/plugin-sdk/provider-web-search";
// Free hosted Search MCP. This keyless transport is used only after the user
@@ -11,6 +12,7 @@ export const PARALLEL_MCP_SEARCH_URL = "https://search.parallel.ai/mcp";
// the server negotiates back on every follow-up request.
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MCP_TIMEOUT_SECONDS = 30;
const PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -215,7 +217,9 @@ async function postMcp(params: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
text: await response.text(),
text: response.ok
? await response.text()
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
sessionIdHeader: response.headers.get("mcp-session-id"),
}),
);

View File

@@ -1,5 +1,6 @@
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
@@ -34,6 +35,7 @@ import {
const PARALLEL_BASE_URL = "https://api.parallel.ai";
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -144,7 +146,9 @@ async function runParallelSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await res.text().catch(() => "");
const detail = await readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
}
try {
@@ -277,6 +281,7 @@ export const testing = {
resolveParallelConfig,
resolveParallelSearchCount,
resolveParallelSearchEndpoint,
PARALLEL_ERROR_BODY_LIMIT_BYTES,
USER_AGENT,
} as const;

View File

@@ -37,6 +37,28 @@ function readMockedBody(call: EndpointCall | undefined): unknown {
return JSON.parse(call.init.body);
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
import { testing } from "../test-api.js";
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
@@ -529,6 +551,38 @@ describe("parallel web search provider", () => {
expect(body.advanced_settings?.max_results).toBe(5);
});
it("bounds Parallel API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool
.execute({
objective: `parallel-error-body-${Date.now()}`,
search_queries: ["openclaw"],
})
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/Parallel API error \(503\): parallel upstream unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
// Unique objective so this test does not collide with the SDK's
// module-level web-search cache across other cases.

View File

@@ -581,11 +581,6 @@ describe("registerPolicyDoctorChecks", () => {
"policy/gateway-remote-enabled",
"policy/gateway-http-endpoint-enabled",
"policy/gateway-http-url-fetch-unrestricted",
"policy/feeds-required-source-missing",
"policy/feeds-source-unpinned",
"policy/feeds-source-unsigned",
"policy/feeds-search-default-missing",
"policy/feeds-search-source-missing",
"policy/agents-workspace-access-denied",
"policy/agents-tool-not-denied",
"policy/tools-profile-unapproved",
@@ -631,869 +626,6 @@ describe("registerPolicyDoctorChecks", () => {
expect(duplicateChecks).toEqual([]);
});
it("reports feed source policy conformance findings", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
requirePinned: true,
allowUnsigned: false,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(
configPath,
cfgWithPolicy({
path: "policy.jsonc",
}),
),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-required-source-missing",
requirement: "oc://policy.jsonc/feeds/sources/require",
}),
]),
);
});
it("reports native feed search policy conformance findings", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireDefault: true,
requireSources: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
search: {
default: false,
sources: ["partner"],
},
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-default-missing",
requirement: "oc://policy.jsonc/feeds/search/requireDefault",
}),
expect.objectContaining({
checkId: "policy/feeds-search-source-missing",
requirement: "oc://policy.jsonc/feeds/search/requireSources",
}),
]),
);
});
it("accepts omitted native feed search sources as all enabled sources", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireDefault: true,
requireSources: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
search: {
default: true,
},
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
},
],
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-source-missing",
}),
]),
);
});
it("does not accept feed search sources when native feed search is not default", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireSources: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
search: {
default: false,
sources: ["company-approved"],
},
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-source-missing",
}),
]),
);
});
it("does not accept disabled feed search sources as policy evidence", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireSources: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
search: {
sources: ["company-approved"],
},
sources: [
{
id: "company-approved",
enabled: false,
url: "https://feeds.example.com/company.json",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-source-missing",
}),
]),
);
});
it("accepts default feed search config without an explicit Feeds plugin enabled flag", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireDefault: true,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
config: {
search: {
default: true,
},
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-default-missing",
}),
]),
);
});
it("does not accept disabled feed search config as policy evidence", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
search: {
requireDefault: true,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: false,
config: {
search: {
default: true,
},
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-search-default-missing",
}),
]),
);
});
it("satisfies mixed-case required feed source ids", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["Company-Approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
enabled: true,
sources: [
{
id: "Company-Approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-required-source-missing" }),
]),
);
});
it("checks configured feed source trust posture against policy", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
requirePinned: true,
allowUnsigned: false,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "unsigned",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-source-unpinned",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#0",
}),
expect.objectContaining({
checkId: "policy/feeds-source-unsigned",
ocPath: "oc://openclaw.config/plugins/entries/feeds/config/sources/#0",
}),
]),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-required-source-missing" }),
]),
);
});
it("accepts pinned configured feed sources required by policy", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
requirePinned: true,
allowUnsigned: false,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-required-source-missing" }),
expect.objectContaining({ checkId: "policy/feeds-source-unpinned" }),
expect.objectContaining({ checkId: "policy/feeds-source-unsigned" }),
]),
);
});
it("satisfies required feed sources from a default-enabled Feeds config", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-required-source-missing",
}),
]),
);
});
it("does not satisfy required feed sources from a disabled Feeds plugin", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: false,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-required-source-missing",
}),
]),
);
});
it("satisfies required feed sources when feeds is explicitly enabled and allowlisted", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
allow: ["feeds", "policy"],
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-required-source-missing" }),
]),
);
});
const disabledFeedSourceCases: readonly [
string,
{ readonly plugins?: Record<string, unknown> },
][] = [
["global plugins disabled", { plugins: { enabled: false } }],
["feeds denied", { plugins: { deny: ["feeds"] } }],
["feeds missing from allowlist", { plugins: { allow: ["other-plugin"] } }],
];
it.each(disabledFeedSourceCases)(
"does not satisfy required feed sources when %s",
async (_name, pluginSettings) => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
require: ["company-approved"],
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
...pluginSettings.plugins,
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({
checkId: "policy/feeds-required-source-missing",
}),
]),
);
},
);
it("does not accept malformed pinned feed integrity", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
requirePinned: true,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity: "not-a-hash",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-source-unpinned" }),
]),
);
});
it("redacts feed source URLs in policy evidence", () => {
const evidence = collectPolicyEvidence(
{
plugins: {
entries: {
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://user:token@feeds.example.com/company.json?sig=secret",
trust: "pinned",
integrity:
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
},
],
},
},
},
},
},
{ includeFeeds: true },
);
expect(evidence.feeds).toEqual([
expect.objectContaining({
id: "company-approved",
url: expect.stringMatching(/^https:\/\/feeds\.example\.com#[0-9a-f]{12}$/u),
}),
]);
});
it("does not accept padded pinned feed integrity", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
await fs.writeFile(
join(workspaceDir, "policy.jsonc"),
JSON.stringify({
feeds: {
sources: {
requirePinned: true,
},
},
}),
"utf-8",
);
const result = await runPolicyChecks(
ctx(configPath, {
...cfgWithPolicy(),
plugins: {
entries: {
policy: {
enabled: true,
config: { enabled: true },
},
feeds: {
enabled: true,
config: {
sources: [
{
id: "company-approved",
url: "https://feeds.example.com/company.json",
trust: "pinned",
integrity:
" sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ",
},
],
},
},
},
},
}),
);
expect(result.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ checkId: "policy/feeds-source-unpinned" }),
]),
);
});
it("reports a missing policy file when the Policy plugin is enabled", async () => {
const configPath = join(workspaceDir, "openclaw.jsonc");
await fs.writeFile(configPath, "{}", "utf-8");
@@ -1618,23 +750,6 @@ describe("registerPolicyDoctorChecks", () => {
{ tools: { denyTools: ["exec", " "] } },
"oc://policy.jsonc/tools/denyTools/#1",
],
["feeds array", { feeds: [] }, "oc://policy.jsonc/feeds"],
["feeds sources array", { feeds: { sources: [] } }, "oc://policy.jsonc/feeds/sources"],
[
"feeds sources require string",
{ feeds: { sources: { require: "company" } } },
"oc://policy.jsonc/feeds/sources/require",
],
[
"feeds sources requirePinned string",
{ feeds: { sources: { requirePinned: "true" } } },
"oc://policy.jsonc/feeds/sources/requirePinned",
],
[
"feeds sources allowUnsigned string",
{ feeds: { sources: { allowUnsigned: "false" } } },
"oc://policy.jsonc/feeds/sources/allowUnsigned",
],
["scopes array", { scopes: [] }, "oc://policy.jsonc/scopes"],
[
"scopes unsupported section for agentIds selector",
@@ -2248,7 +1363,6 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -2283,7 +1397,6 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -2330,7 +1443,6 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -2363,7 +1475,6 @@ describe("registerPolicyDoctorChecks", () => {
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>, {
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,

View File

@@ -20,8 +20,6 @@ import {
type PolicyDataHandlingEvidence,
type PolicyEvidence,
type PolicyExecApprovalEvidence,
type PolicyFeedSourceEvidence,
type PolicyFeedSearchEvidence,
type PolicyIngressEvidence,
type PolicySandboxPostureEvidence,
type PolicyToolPostureEvidence,
@@ -58,11 +56,6 @@ const CHECK_IDS = {
policyGatewayRemoteEnabled: "policy/gateway-remote-enabled",
policyGatewayHttpEndpointEnabled: "policy/gateway-http-endpoint-enabled",
policyGatewayHttpUrlFetchUnrestricted: "policy/gateway-http-url-fetch-unrestricted",
policyFeedsRequiredSourceMissing: "policy/feeds-required-source-missing",
policyFeedsSourceUnpinned: "policy/feeds-source-unpinned",
policyFeedsSourceUnsigned: "policy/feeds-source-unsigned",
policyFeedsSearchDefaultMissing: "policy/feeds-search-default-missing",
policyFeedsSearchSourceMissing: "policy/feeds-search-source-missing",
policyAgentsWorkspaceAccessDenied: "policy/agents-workspace-access-denied",
policyAgentsToolNotDenied: "policy/agents-tool-not-denied",
policyToolsElevatedEnabled: "policy/tools-elevated-enabled",
@@ -131,11 +124,6 @@ export const POLICY_CHECK_IDS = [
CHECK_IDS.policyGatewayRemoteEnabled,
CHECK_IDS.policyGatewayHttpEndpointEnabled,
CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted,
CHECK_IDS.policyFeedsRequiredSourceMissing,
CHECK_IDS.policyFeedsSourceUnpinned,
CHECK_IDS.policyFeedsSourceUnsigned,
CHECK_IDS.policyFeedsSearchDefaultMissing,
CHECK_IDS.policyFeedsSearchSourceMissing,
CHECK_IDS.policyAgentsWorkspaceAccessDenied,
CHECK_IDS.policyAgentsToolNotDenied,
CHECK_IDS.policyToolsProfileUnapproved,
@@ -601,7 +589,6 @@ const SUPPORTED_POLICY_SECTIONS = [
"channels",
"dataHandling",
"execApprovals",
"feeds",
"gateway",
"ingress",
"mcp",
@@ -686,11 +673,6 @@ export function registerPolicyDoctorChecks(host?: PolicyDoctorRegistrationHost):
registerHealthCheck(policyGatewayRemoteEnabledCheck);
registerHealthCheck(policyGatewayHttpEndpointEnabledCheck);
registerHealthCheck(policyGatewayHttpUrlFetchUnrestrictedCheck);
registerHealthCheck(policyFeedsRequiredSourceMissingCheck);
registerHealthCheck(policyFeedsSourceUnpinnedCheck);
registerHealthCheck(policyFeedsSourceUnsignedCheck);
registerHealthCheck(policyFeedsSearchDefaultMissingCheck);
registerHealthCheck(policyFeedsSearchSourceMissingCheck);
registerHealthCheck(policyAgentsWorkspaceAccessDeniedCheck);
registerHealthCheck(policyAgentsToolNotDeniedCheck);
registerHealthCheck(policyToolsProfileUnapprovedCheck);
@@ -997,56 +979,6 @@ const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = {
},
};
const policyFeedsRequiredSourceMissingCheck: HealthCheck = {
id: CHECK_IDS.policyFeedsRequiredSourceMissing,
kind: "plugin",
description: "Required catalog feed sources are configured and enabled.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyFeedsRequiredSourceMissing);
},
};
const policyFeedsSourceUnpinnedCheck: HealthCheck = {
id: CHECK_IDS.policyFeedsSourceUnpinned,
kind: "plugin",
description: "Enabled catalog feed sources use pinned trust when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyFeedsSourceUnpinned);
},
};
const policyFeedsSourceUnsignedCheck: HealthCheck = {
id: CHECK_IDS.policyFeedsSourceUnsigned,
kind: "plugin",
description: "Enabled catalog feed sources do not use unsigned trust when policy denies it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyFeedsSourceUnsigned);
},
};
const policyFeedsSearchDefaultMissingCheck: HealthCheck = {
id: CHECK_IDS.policyFeedsSearchDefaultMissing,
kind: "plugin",
description: "Native skills/plugins search uses feeds by default when policy requires it.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyFeedsSearchDefaultMissing);
},
};
const policyFeedsSearchSourceMissingCheck: HealthCheck = {
id: CHECK_IDS.policyFeedsSearchSourceMissing,
kind: "plugin",
description: "Native skills/plugins search includes required feed sources.",
source: "policy",
async detect(ctx) {
return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyFeedsSearchSourceMissing);
},
};
const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = {
id: CHECK_IDS.policyAgentsWorkspaceAccessDenied,
kind: "plugin",
@@ -1518,7 +1450,6 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
let evidence: PolicyEvidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeToolPosture: false,
includeSandboxPosture: false,
@@ -1613,7 +1544,6 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
const includeAuthProfiles = policyHasAuthProfileRules(policy);
const includeIngress = policyHasIngressRules(policy);
const includeGatewayExposure = policyHasGatewayRules(policy);
const includeFeeds = policyHasFeedRules(policy);
const includeAgentWorkspace = policyHasAgentWorkspaceRules(policy);
const includeDataHandling = policyHasDataHandlingRules(policy);
const includeSandboxPosture = policyHasSandboxPostureRules(policy);
@@ -1625,7 +1555,6 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
toolsRaw: toolsFile?.raw ?? "",
includeIngress,
includeGatewayExposure,
includeFeeds,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
@@ -1639,7 +1568,6 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
evidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
includeIngress,
includeGatewayExposure,
includeFeeds,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
@@ -1658,7 +1586,6 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
...networkFindings(policy, policyFile.ocDocName, evidence),
...ingressFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...gatewayExposureFindings(policy, policyFile.ocDocName, evidence),
...feedSourceFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...agentWorkspaceFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...toolPostureFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
...sandboxPostureFindings(policy, policyFile.displayName, policyFile.ocDocName, evidence),
@@ -2083,13 +2010,6 @@ export function policyContainerShapeFindings(
];
}
}
const feedsFinding = feedsPolicyShapeFinding(policy.feeds, {
policyDocName,
policyPath,
});
if (feedsFinding !== undefined) {
return [feedsFinding];
}
if (policy.secrets !== undefined && !isRecord(policy.secrets)) {
return [
policyShapeFinding(
@@ -2217,83 +2137,6 @@ export function policyContainerShapeFindings(
return [];
}
function feedsPolicyShapeFinding(
value: unknown,
params: {
readonly policyDocName: string;
readonly policyPath: string;
},
): HealthFinding | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/feeds`,
`${params.policyPath} feeds must be an object.`,
`Fix ${params.policyPath} so feeds is an object.`,
);
}
if (value.search !== undefined && !isRecord(value.search)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/feeds/search`,
`${params.policyPath} feeds.search must be an object.`,
`Fix ${params.policyPath} so feeds.search is an object.`,
);
}
const search = isRecord(value.search) ? value.search : {};
if (search.requireDefault !== undefined && typeof search.requireDefault !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/feeds/search/requireDefault`,
`${params.policyPath} feeds.search.requireDefault must be a boolean.`,
`Set feeds.search.requireDefault to true or false.`,
);
}
const requireSourcesFinding = policyStringArrayPropertyShapeFinding(search.requireSources, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: "feeds.search.requireSources",
target: "feeds/search/requireSources",
valueName: "feed source id",
});
if (requireSourcesFinding !== undefined) {
return requireSourcesFinding;
}
if (value.sources !== undefined && !isRecord(value.sources)) {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/feeds/sources`,
`${params.policyPath} feeds.sources must be an object.`,
`Fix ${params.policyPath} so feeds.sources is an object.`,
);
}
const sources = isRecord(value.sources) ? value.sources : {};
const requireFinding = policyStringArrayPropertyShapeFinding(sources.require, {
policyDocName: params.policyDocName,
policyPath: params.policyPath,
property: "feeds.sources.require",
target: "feeds/sources/require",
valueName: "feed source id",
});
if (requireFinding !== undefined) {
return requireFinding;
}
for (const key of ["requirePinned", "allowUnsigned"] as const) {
if (sources[key] !== undefined && typeof sources[key] !== "boolean") {
return policyShapeFinding(
params.policyPath,
`oc://${params.policyDocName}/feeds/sources/${key}`,
`${params.policyPath} feeds.sources.${key} must be a boolean.`,
`Set feeds.sources.${key} to true or false.`,
);
}
}
return undefined;
}
function ingressPolicyShapeFinding(
value: unknown,
params: {
@@ -4360,166 +4203,6 @@ function gatewayHttpUrlFetchFindings(
});
}
function feedSourceFindings(
policy: unknown,
policyPath: string,
policyDocName: string,
evidence: PolicyEvidence,
): readonly HealthFinding[] {
const shapeFinding = feedsPolicyShapeFinding(isRecord(policy) ? policy.feeds : undefined, {
policyDocName,
policyPath,
});
if (shapeFinding !== undefined) {
return [];
}
const required = readStringList(policy, ["feeds", "sources", "require"], { lowercase: false });
const requirePinned = readPolicyBoolean(policy, ["feeds", "sources", "requirePinned"]) === true;
const allowUnsigned = readPolicyBoolean(policy, ["feeds", "sources", "allowUnsigned"]) !== false;
const requireSearchDefault =
readPolicyBoolean(policy, ["feeds", "search", "requireDefault"]) === true;
const requiredSearchSources = readStringList(policy, ["feeds", "search", "requireSources"]);
if (
required.length === 0 &&
!requirePinned &&
allowUnsigned &&
!requireSearchDefault &&
requiredSearchSources.length === 0
) {
return [];
}
const enabledSources = (evidence.feeds ?? []).filter((source) => source.enabled !== false);
const enabledById = new Map(enabledSources.map((source) => [source.id, source]));
const findings: HealthFinding[] = [];
findings.push(
...feedSearchFindings({
policyDocName,
requireDefault: requireSearchDefault,
requireSources: requiredSearchSources,
evidence: evidence.feedSearch,
enabledSourceIds: [...enabledById.keys()],
}),
);
for (const sourceId of required) {
const source = enabledById.get(sourceId);
if (source !== undefined) {
continue;
}
findings.push({
checkId: CHECK_IDS.policyFeedsRequiredSourceMissing,
severity: "error",
message: `Required feed source '${sourceId}' is not configured and enabled.`,
source: "policy",
path: "openclaw config",
target: "oc://openclaw.config/plugins/entries/feeds/config/sources",
requirement: `oc://${policyDocName}/feeds/sources/require`,
fixHint:
"Add the required feed source under plugins.entries.feeds.config.sources or update policy after review.",
});
}
for (const source of enabledSources) {
if (requirePinned && !feedSourceIsPinned(source)) {
findings.push(
feedSourceFinding(source, {
checkId: CHECK_IDS.policyFeedsSourceUnpinned,
message: `Feed source '${source.id}' is not pinned with an integrity hash.`,
requirement: `oc://${policyDocName}/feeds/sources/requirePinned`,
fixHint:
"Set trust to pinned and add integrity: sha256:<hex>, or update policy after review.",
}),
);
}
if (!allowUnsigned && (source.trust ?? "unsigned") === "unsigned") {
findings.push(
feedSourceFinding(source, {
checkId: CHECK_IDS.policyFeedsSourceUnsigned,
message: `Feed source '${source.id}' uses unsigned trust.`,
requirement: `oc://${policyDocName}/feeds/sources/allowUnsigned`,
fixHint: "Set trust to pinned with an integrity hash, or update policy after review.",
}),
);
}
}
return findings;
}
function feedSearchFindings(params: {
readonly policyDocName: string;
readonly requireDefault: boolean;
readonly requireSources: readonly string[];
readonly evidence?: PolicyFeedSearchEvidence;
readonly enabledSourceIds: readonly string[];
}): readonly HealthFinding[] {
const findings: HealthFinding[] = [];
if (params.requireDefault && params.evidence?.default !== true) {
findings.push({
checkId: CHECK_IDS.policyFeedsSearchDefaultMissing,
severity: "error",
message: "Native skills/plugins search is not configured to use feeds by default.",
source: "policy",
path: "openclaw config",
target: "oc://openclaw.config/plugins/entries/feeds/config/search/default",
requirement: `oc://${params.policyDocName}/feeds/search/requireDefault`,
fixHint:
"Set plugins.entries.feeds.config.search.default to true or update policy after review.",
});
}
const enabled = new Set(params.enabledSourceIds);
const configured = new Set(
params.evidence?.default !== true
? []
: params.evidence.sourceIds === undefined
? params.enabledSourceIds
: params.evidence.sourceIds.filter((sourceId) => enabled.has(sourceId)),
);
for (const sourceId of params.requireSources) {
if (configured.has(sourceId)) {
continue;
}
findings.push({
checkId: CHECK_IDS.policyFeedsSearchSourceMissing,
severity: "error",
message: `Native feed search does not require source '${sourceId}'.`,
source: "policy",
path: "openclaw config",
target: "oc://openclaw.config/plugins/entries/feeds/config/search/sources",
requirement: `oc://${params.policyDocName}/feeds/search/requireSources`,
fixHint:
"Add the source id to plugins.entries.feeds.config.search.sources or update policy after review.",
});
}
return findings;
}
function feedSourceFinding(
source: PolicyFeedSourceEvidence,
params: {
readonly checkId: (typeof POLICY_CHECK_IDS)[number];
readonly message: string;
readonly requirement: string;
readonly fixHint: string;
},
): HealthFinding {
return {
checkId: params.checkId,
severity: "error",
message: params.message,
source: "policy",
path: "openclaw config",
ocPath: source.source,
target: source.source,
requirement: params.requirement,
fixHint: params.fixHint,
};
}
function feedSourceIsPinned(source: PolicyFeedSourceEvidence): boolean {
return source.trust === "pinned" && source.integrityPresent === true;
}
function agentWorkspaceFindings(
policy: unknown,
policyPath: string,
@@ -6500,22 +6183,6 @@ function policyHasGatewayRules(policy: unknown): boolean {
);
}
function policyHasFeedRules(policy: unknown): boolean {
if (!isRecord(policy) || !isRecord(policy.feeds)) {
return false;
}
return (
(isRecord(policy.feeds.sources) &&
(policy.feeds.sources.require !== undefined ||
policy.feeds.sources.requirePinned !== undefined ||
policy.feeds.sources.allowUnsigned !== undefined)) ||
(isRecord(policy.feeds.search) &&
(policy.feeds.search.requireDefault !== undefined ||
policy.feeds.search.requireSources !== undefined))
);
}
function policyHasAgentWorkspaceRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;

View File

@@ -50,8 +50,6 @@ export type PolicyEvidence = {
readonly network: readonly PolicyNetworkEvidence[];
readonly ingress?: readonly PolicyIngressEvidence[];
readonly gatewayExposure?: readonly PolicyGatewayExposureEvidence[];
readonly feeds?: readonly PolicyFeedSourceEvidence[];
readonly feedSearch?: PolicyFeedSearchEvidence;
readonly agentWorkspace?: readonly PolicyAgentWorkspaceEvidence[];
readonly dataHandling?: readonly PolicyDataHandlingEvidence[];
readonly secrets?: readonly PolicySecretEvidence[];
@@ -160,21 +158,6 @@ export type PolicyIngressEvidence = {
readonly explicit?: boolean;
};
export type PolicyFeedSourceEvidence = {
readonly id: string;
readonly source: string;
readonly enabled?: boolean;
readonly url?: string;
readonly trust?: string;
readonly integrityPresent?: boolean;
};
export type PolicyFeedSearchEvidence = {
readonly source: string;
readonly default?: boolean;
readonly sourceIds?: readonly string[];
};
export type PolicyGatewayExposureEvidence = {
readonly id: string;
readonly kind:
@@ -330,7 +313,6 @@ export function collectPolicyEvidence(
readonly toolsRaw?: undefined;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -347,7 +329,6 @@ export function collectPolicyEvidence(
readonly toolsRaw: string;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -364,7 +345,6 @@ export function collectPolicyEvidence(
readonly toolsRaw?: string;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -385,9 +365,6 @@ export function collectPolicyEvidence(
...(options.includeGatewayExposure === false
? {}
: { gatewayExposure: scanPolicyGatewayExposure(cfg) }),
...(options.includeFeeds === false
? {}
: { feeds: scanPolicyFeeds(cfg), feedSearch: scanPolicyFeedSearch(cfg) }),
...(options.includeAgentWorkspace === false
? {}
: { agentWorkspace: scanPolicyAgentWorkspace(cfg) }),
@@ -855,86 +832,6 @@ export function scanPolicyIngress(cfg: Record<string, unknown>): readonly Policy
return entries.toSorted((a, b) => a.source.localeCompare(b.source) || a.id.localeCompare(b.id));
}
export function scanPolicyFeeds(cfg: Record<string, unknown>): readonly PolicyFeedSourceEvidence[] {
const plugins = isRecord(cfg.plugins) ? cfg.plugins : {};
const entries = isRecord(plugins.entries) ? plugins.entries : {};
const feeds = isRecord(entries.feeds) ? entries.feeds : {};
const config = isRecord(feeds.config) ? feeds.config : {};
if (!feedPluginEnabledForPolicy(plugins, feeds)) {
return [];
}
const sources = Array.isArray(config.sources) ? config.sources : [];
return sources
.map((source, index): PolicyFeedSourceEvidence | undefined => {
if (!isRecord(source)) {
return undefined;
}
const id =
typeof source.id === "string" && source.id.trim() !== ""
? source.id.trim()
: `source-${index}`;
const entry: {
id: string;
source: string;
enabled?: boolean;
url?: string;
trust?: string;
integrityPresent?: boolean;
} = {
id,
source: `oc://openclaw.config/plugins/entries/feeds/config/sources/#${index}`,
};
if (typeof source.enabled === "boolean") {
entry.enabled = source.enabled;
}
if (typeof source.url === "string") {
entry.url = redactFeedUrlForEvidence(source.url);
}
if (typeof source.trust === "string") {
entry.trust = source.trust;
}
if (
typeof source.integrity === "string" &&
/^sha256:[0-9a-f]{64}$/iu.test(source.integrity)
) {
entry.integrityPresent = true;
}
return entry;
})
.filter((entry): entry is PolicyFeedSourceEvidence => entry !== undefined)
.toSorted((a, b) => a.id.localeCompare(b.id) || a.source.localeCompare(b.source));
}
export function scanPolicyFeedSearch(
cfg: Record<string, unknown>,
): PolicyFeedSearchEvidence | undefined {
const plugins = isRecord(cfg.plugins) ? cfg.plugins : {};
const entries = isRecord(plugins.entries) ? plugins.entries : {};
const feeds = isRecord(entries.feeds) ? entries.feeds : {};
const config = isRecord(feeds.config) ? feeds.config : {};
if (!feedPluginEnabledForPolicy(plugins, feeds)) {
return undefined;
}
const search = isRecord(config.search) ? config.search : undefined;
if (search === undefined) {
return undefined;
}
const evidence: {
source: string;
default?: boolean;
sourceIds?: readonly string[];
} = {
source: "oc://openclaw.config/plugins/entries/feeds/config/search",
};
if (typeof search.default === "boolean") {
evidence.default = search.default;
}
if (Array.isArray(search.sources)) {
evidence.sourceIds = search.sources.filter((id): id is string => typeof id === "string");
}
return evidence;
}
export function scanPolicyGatewayExposure(
cfg: Record<string, unknown>,
): readonly PolicyGatewayExposureEvidence[] {
@@ -2521,22 +2418,6 @@ function redactMcpUrlForEvidence(raw: string): string {
}
}
function redactFeedUrlForEvidence(raw: string): string {
try {
const url = new URL(raw);
if (url.protocol === "file:") {
return `file://[redacted-path]#${shortEvidenceHash(raw)}`;
}
return `${url.protocol}//${url.host}#${shortEvidenceHash(raw)}`;
} catch {
return `[redacted-url]#${shortEvidenceHash(raw)}`;
}
}
function shortEvidenceHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
function configuredModelProviders(cfg: Record<string, unknown>): Record<string, unknown> {
return isRecord(cfg.models) && isRecord(cfg.models.providers) ? cfg.models.providers : {};
}
@@ -3145,17 +3026,3 @@ function stableJson(value: unknown): string {
}
return JSON.stringify(value);
}
function feedPluginEnabledForPolicy(
plugins: Record<string, unknown>,
feeds: Record<string, unknown>,
): boolean {
const allow = readStringArray(plugins.allow);
const deny = readStringArray(plugins.deny);
return (
plugins.enabled !== false &&
feeds.enabled !== false &&
!deny.includes("feeds") &&
(allow.length === 0 || allow.includes("feeds"))
);
}

View File

@@ -50,22 +50,19 @@ describe("qa scenario catalog", () => {
expect(
scenarioIds.filter((scenarioId) => requiredScenarioIds.includes(scenarioId)).toSorted(),
).toEqual(requiredScenarioIds);
expect(
pack.scenarios
.filter((scenario) => scenario.execution?.kind !== "flow")
.map((scenario) => scenario.id)
.toSorted(),
).toStrictEqual(
[
"channel-message-flows",
"control-ui-chat-flow-playwright",
"gateway-smoke",
"package-openclaw-for-docker",
"plugin-lifecycle-probe",
"qa-otel-smoke",
"ux-matrix-evidence-dashboard",
].toSorted(),
const nativeExecutionScenarios = pack.scenarios.filter(
(scenario) => scenario.execution.kind !== "flow",
);
expect(nativeExecutionScenarios.length).toBeGreaterThan(0);
for (const scenario of nativeExecutionScenarios) {
const execution = scenario.execution;
if (execution.kind === "flow") {
throw new Error(`expected native execution scenario: ${scenario.id}`);
}
expect(["playwright", "script", "vitest"]).toContain(execution.kind);
expect(fs.existsSync(execution.path), `${scenario.id} execution.path exists`).toBe(true);
expect(execution.flow).toBeUndefined();
}
expect(
pack.scenarios
.filter((scenario) => scenario.execution.kind === "flow")
@@ -176,6 +173,21 @@ describe("qa scenario catalog", () => {
expect(uxMatrix.coverage?.primary).toContain("qa.artifact-safety");
});
it("loads folded HTTP API script scenarios with primary taxonomy coverage", () => {
expect(readQaScenarioById("openai-compatible-chat-tools").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
expect(readQaScenarioById("openai-web-search-minimal").coverage?.primary).toStrictEqual([
"runtime.reasoning-and-cache-controls",
]);
expect(
readQaScenarioById("openai-web-search-native-assertions").coverage?.primary,
).toStrictEqual(["web-search.openai-native-web-search", "plugins.web-search-and-fetch"]);
expect(readQaScenarioById("openwebui-openai-compatible").coverage?.primary).toStrictEqual([
"gateway.openai-compatible-apis",
]);
});
it("loads runtime parity tier metadata for first-hour and soak lanes", () => {
const firstHour = readQaScenarioById("runtime-first-hour-20-turn");
const soak = readQaScenarioById("runtime-soak-100-turn");

View File

@@ -234,14 +234,6 @@ export class TokenManager {
}
}
/** Check whether background refresh is running. */
isBackgroundRefreshRunning(appId?: string): boolean {
if (appId) {
return this.refreshControllers.has(appId);
}
return this.refreshControllers.size > 0;
}
// ---- Internal ----
private async doFetchToken(appId: string, clientSecret: string): Promise<string> {

View File

@@ -132,11 +132,6 @@ export class SlashCommandRegistry {
}));
}
/** Return all pre-dispatch commands. */
getPreDispatchCommands(): Map<string, SlashCommand> {
return this.commands;
}
/** Return all registered commands (both maps) for help listing. */
getAllCommands(): Map<string, SlashCommand> {
const all = new Map<string, SlashCommand>();

View File

@@ -13,8 +13,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export type GatewayCfg = OpenClawConfig;
export type GatewayCfgLoader = () => OpenClawConfig;
export interface ActiveCfgProvider {

View File

@@ -193,7 +193,6 @@ const CallModeSchema = z.enum(["notify", "conversation"]);
export type CallMode = z.infer<typeof CallModeSchema>;
const VoiceCallSessionScopeSchema = z.enum(["per-phone", "per-call"]);
export type VoiceCallSessionScope = z.infer<typeof VoiceCallSessionScopeSchema>;
const OutboundConfigSchema = z
.object({
@@ -281,9 +280,6 @@ const VoiceCallRealtimeAgentContextConfigSchema = z
includeWorkspaceFiles: true,
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
});
export type VoiceCallRealtimeAgentContextConfig = z.infer<
typeof VoiceCallRealtimeAgentContextConfigSchema
>;
export const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"off",
@@ -295,9 +291,6 @@ export const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"adaptive",
"max",
]);
export type VoiceCallRealtimeConsultThinkingLevel = z.infer<
typeof VoiceCallRealtimeConsultThinkingLevelSchema
>;
const VoiceCallStreamingProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))

View File

@@ -709,25 +709,6 @@ export class MediaStreamHandler {
this.clearAudio(streamSid);
}
/**
* Get active session by call ID.
*/
getSessionByCallId(callId: string): StreamSession | undefined {
return [...this.sessions.values()].find((session) => session.callId === callId);
}
/**
* Close all sessions.
*/
closeAll(): void {
for (const session of this.sessions.values()) {
this.clearTtsState(session.streamSid);
session.sttSession.close();
session.ws.close();
}
this.sessions.clear();
}
private getTtsQueue(streamSid: string): TtsQueueEntry[] {
const existing = this.ttsQueues.get(streamSid);
if (existing) {

View File

@@ -156,10 +156,6 @@ export class TwilioProvider implements VoiceCallProvider {
this.currentPublicUrl = url;
}
getPublicUrl(): string | null {
return this.currentPublicUrl;
}
setTTSProvider(provider: TelephonyTtsProvider): void {
this.ttsProvider = provider;
}

View File

@@ -250,7 +250,6 @@ describe("VoiceCallWebhookServer realtime transcription provider selection", ()
}
expect(mediaStreamHandler["handleUpgrade"]).toBeTypeOf("function");
expect(mediaStreamHandler["sendAudio"]).toBeTypeOf("function");
expect(mediaStreamHandler["closeAll"]).toBeTypeOf("function");
} finally {
await server.stop();
}

9
pnpm-lock.yaml generated
View File

@@ -736,15 +736,6 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/feeds:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/feishu:
dependencies:
'@larksuiteoapi/node-sdk':

View File

@@ -0,0 +1,29 @@
title: OpenAI-compatible chat tools HTTP API
scenario:
id: openai-compatible-chat-tools
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-tool-use
objective: Verify the OpenAI-compatible chat-completions client and Docker lane preserve strict tool-call API behavior.
successCriteria:
- The Docker lane fails missing or placeholder OpenAI auth before Docker build work starts.
- The generated config preserves strict positive gateway port and timeout values.
- The chat-completions client posts to `/v1/chat/completions` with the expected gateway token and model header.
- Tool-call-only responses are accepted, visible content beside a tool call is rejected, and response bodies remain bounded.
docsRefs:
- docs/gateway/protocol.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-chat-tools/client.mjs
- scripts/e2e/lib/openai-chat-tools/write-config.mjs
- scripts/e2e/openai-chat-tools-docker.sh
- test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts
summary: Vitest coverage for OpenAI-compatible chat-completions tool-call API behavior.

View File

@@ -0,0 +1,29 @@
title: OpenAI web_search minimal reasoning gate
scenario:
id: openai-web-search-minimal
surface: model-provider
coverage:
primary:
- runtime.reasoning-and-cache-controls
secondary:
- web-search.openai-native-web-search
- tools.web-search
objective: Verify the OpenAI web_search minimal-reasoning E2E client distinguishes successful grounded turns from provider schema rejection.
successCriteria:
- Reject mode accepts the expected raw OpenAI schema rejection and the gateway schema wrapper.
- Reject mode fails if the agent run unexpectedly succeeds or fails for unrelated transport reasons.
- Success mode requires an `ok` agent result with the expected marker in visible reply payloads.
- Gateway ports are parsed strictly before connecting.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/client.mjs
- scripts/e2e/openai-web-search-minimal-docker.sh
- test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts
summary: Vitest coverage for OpenAI web_search minimal-reasoning success and rejection validation.

View File

@@ -0,0 +1,30 @@
title: OpenAI native web_search request assertions
scenario:
id: openai-web-search-native-assertions
surface: model-provider
coverage:
primary:
- web-search.openai-native-web-search
- plugins.web-search-and-fetch
secondary:
- web-search.model-and-filter-routing
- tools.web-search
objective: Verify the OpenAI web_search Docker lane assertions require native Responses web_search evidence with bounded diagnostics.
successCriteria:
- A successful request must hit `/v1/responses` with native `web_search` and non-minimal reasoning.
- Large request logs are scanned without missing later success requests.
- Failure diagnostics are bounded and do not dump stale or oversized request bodies.
- Function-shaped `web_search` is rejected as native Responses proof.
docsRefs:
- docs/tools/web.md
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/lib/openai-web-search-minimal/assertions.mjs
- scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs
- test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts
summary: Vitest coverage for native OpenAI web_search request-log assertions.

View File

@@ -0,0 +1,28 @@
title: OpenWebUI OpenAI-compatible API probe
scenario:
id: openwebui-openai-compatible
surface: runtime
coverage:
primary:
- gateway.openai-compatible-apis
secondary:
- runtime.hosted-provider-turns
- runtime.provider-specific-model-options
objective: Verify the OpenWebUI E2E probe exercises OpenClaw through OpenWebUI's OpenAI-compatible model and chat APIs.
successCriteria:
- Probe environment limits are parsed strictly and control-plane requests time out quickly.
- Sign-in and model-list error bodies are bounded before diagnostics are emitted.
- Models mode authenticates and finds the OpenClaw model exposed by OpenWebUI.
- Chat mode posts to `/api/chat/completions`, validates the expected nonce, and fails when the reply omits it.
docsRefs:
- docs/help/testing.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- scripts/e2e/openwebui-probe.mjs
- scripts/e2e/openwebui-docker.sh
- test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
execution:
kind: vitest
path: test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts
summary: Vitest coverage for OpenWebUI model and chat-completions probe behavior.

View File

@@ -95,10 +95,20 @@ export const migratedSessionAccessorFiles = new Set([
"src/cron/isolated-agent/delivery-target.ts",
"src/cron/service/timer.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/gateway/session-history-state.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions.ts",
"src/gateway/server-session-events.ts",
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.ts",
]);

View File

@@ -49,6 +49,8 @@ const transcriptReaderNames = new Set([
"visitSessionMessagesAsync",
]);
const storageSpecificTranscriptReaderAliasNames = new Set(["readSessionMessagesFromFileAsync"]);
export const migratedSessionTranscriptReaderFiles = new Set([
"src/agents/main-session-restart-recovery.ts",
"src/agents/subagent-announce-output.test.ts",
@@ -126,6 +128,13 @@ export function findSessionTranscriptReaderBoundaryViolations(content, fileName
const legacyNamespaces = new Set();
const visit = (node) => {
if (ts.isIdentifier(node) && storageSpecificTranscriptReaderAliasNames.has(node.text)) {
violations.push({
line: toLine(sourceFile, node),
reason: `uses storage-specific transcript reader alias "${node.text}"`,
});
}
if (ts.isImportDeclaration(node)) {
const moduleName = importedModuleName(node);
const namedBindings = node.importClause?.namedBindings;

View File

@@ -980,7 +980,7 @@ export async function readBoundedResponseText(response, byteLimit, timeoutPromis
const contentLength = response.headers?.get?.("content-length");
if (contentLength && /^\d+$/u.test(contentLength)) {
const parsedContentLength = Number(contentLength);
if (Number.isSafeInteger(parsedContentLength) && parsedContentLength > resolvedByteLimit) {
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > resolvedByteLimit) {
await response.body?.cancel?.().catch(() => undefined);
throw createFetchBodyTooLargeError(resolvedByteLimit);
}
@@ -1873,12 +1873,14 @@ async function samplePosixProcessTree(pid, run, commandLineNeedles) {
if (hasMalformedProcessTreeRows(malformedRows, rootTreeRows)) {
return null;
}
const rootRow = rootTreeRows.find((row) => row.processId === safePid) ?? null;
const descendants = rootTreeRows.filter((row) => row.processId !== safePid);
const commandMatches = descendants.filter((row) =>
const matchesCommandNeedles = (row) =>
commandLineNeedles.every((needle) =>
row.command.toLowerCase().includes(needle.toLowerCase()),
),
);
);
const commandMatches = descendants.filter(matchesCommandNeedles);
const rootCommandMatches = rootRow && matchesCommandNeedles(rootRow) ? [rootRow] : [];
const gatewayTitleMatches = descendants.filter((row) =>
row.command.toLowerCase().includes("openclaw-gateway"),
);
@@ -1887,7 +1889,9 @@ async function samplePosixProcessTree(pid, run, commandLineNeedles) {
? commandMatches
: gatewayTitleMatches.length > 0
? gatewayTitleMatches
: descendants,
: descendants.length > 0
? descendants
: rootCommandMatches,
);
if (!selected) {
return null;

View File

@@ -51,7 +51,7 @@ async function readBoundedResponseText(response, byteLimit, timeoutPromise) {
const contentLength = response.headers?.get?.("content-length");
if (contentLength && /^\d+$/u.test(contentLength)) {
const parsedContentLength = Number(contentLength);
if (Number.isSafeInteger(parsedContentLength) && parsedContentLength > byteLimit) {
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > byteLimit) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`chat completions response body exceeded ${byteLimit} bytes`);
}

View File

@@ -24,7 +24,6 @@ import {
win32 as pathWin32,
} from "node:path";
import { pathToFileURL } from "node:url";
import { verify as verifySigstoreBundle } from "sigstore";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../src/plugins/runtime-sidecar-paths.ts";
import { readBoundedResponseText } from "./lib/bounded-response.ts";
@@ -278,7 +277,8 @@ async function verifySigstoreNpmProvenanceBundle(
bundle: unknown,
policy: NpmProvenanceVerificationPolicy,
): Promise<void> {
await verifySigstoreBundle(bundle as Parameters<typeof verifySigstoreBundle>[0], policy);
const sigstore = require("sigstore") as { verify: VerifyNpmProvenanceBundle };
await sigstore.verify(bundle, policy);
}
export async function verifyNpmProvenanceAttestation(params: {

View File

@@ -1067,28 +1067,70 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
"src/image-generation/openai-compatible-image-provider.test.ts",
],
],
[
"scripts/e2e/lib/openai-chat-tools/client.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/scenario.sh",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-chat-tools/write-config.mjs",
["test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts"],
],
[
"scripts/e2e/openai-chat-tools-docker.sh",
["test/scripts/openai-chat-tools-client.test.ts", "test/scripts/docker-e2e-plan.test.ts"],
[
"test/e2e/qa-lab/runtime/openai-compatible-chat-tools.e2e.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/assertions.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/client.mjs",
["test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts"],
],
[
"scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
],
[
"scripts/e2e/lib/openai-web-search-minimal/scenario.sh",
[
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
],
[
"scripts/e2e/openai-web-search-minimal-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/scripts/openai-web-search-minimal-client.test.ts",
"test/scripts/openai-web-search-minimal-assertions.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal.e2e.test.ts",
"test/e2e/qa-lab/runtime/openai-web-search-minimal-assertions.e2e.test.ts",
],
],
[
"scripts/e2e/lib/openwebui/http-probe.mjs",
["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"],
],
[
"scripts/e2e/openwebui-docker.sh",
[
"test/scripts/docker-build-helper.test.ts",
"test/scripts/docker-e2e-plan.test.ts",
"test/scripts/openwebui-probe.test.ts",
"test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts",
"test/scripts/fixture-config.test.ts",
],
],
["scripts/e2e/openwebui-probe.mjs", ["test/e2e/qa-lab/runtime/openwebui-probe.e2e.test.ts"]],
[
"scripts/e2e/plugin-binding-command-escape-docker.sh",
[

View File

@@ -570,10 +570,13 @@ async function readAvatarProbeBuffer(
response: Response,
timeoutPromise?: Promise<never>,
): Promise<Buffer> {
const contentLength = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(contentLength) && contentLength > AVATAR_PROBE_MAX_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`avatar probe exceeded ${AVATAR_PROBE_MAX_BYTES} bytes`);
const contentLengthRaw = response.headers.get("content-length");
if (contentLengthRaw && /^\d+$/u.test(contentLengthRaw)) {
const contentLength = Number(contentLengthRaw);
if (!Number.isSafeInteger(contentLength) || contentLength > AVATAR_PROBE_MAX_BYTES) {
await response.body?.cancel().catch(() => undefined);
throw new Error(`avatar probe exceeded ${AVATAR_PROBE_MAX_BYTES} bytes`);
}
}
const reader = response.body?.getReader?.();

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
/**
* Integration-style tests for before_tool_call behavior.
* Covers loop detection, diagnostics, plugin approval, and skill telemetry
@@ -27,6 +28,7 @@ import {
runBeforeToolCallHook,
wrapToolWithBeforeToolCallHook,
} from "./agent-tools.before-tool-call.js";
import { createOpenClawCodingTools } from "./agent-tools.js";
import { CRITICAL_THRESHOLD } from "./tool-loop-detection.js";
import type { AnyAgentTool } from "./tools/common.js";
import { callGatewayTool } from "./tools/gateway.js";
@@ -1844,6 +1846,106 @@ describe("before_tool_call requireApproval handling", () => {
expect(onResolution).toHaveBeenCalledWith("cancelled");
});
it("forwards turn source routing fields from ctx to plugin.approval.request", async () => {
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
title: "Channel-routed approval",
description: "Must route to telegram",
pluginId: "my-plugin",
},
});
mockCallGateway.mockResolvedValueOnce({ id: "route-id-1", status: "accepted" });
mockCallGateway.mockResolvedValueOnce({ id: "route-id-1", decision: "allow-once" });
await runBeforeToolCallHook({
toolName: "fetch",
params: { url: "https://example.com" },
ctx: {
agentId: "main",
sessionKey: "main",
turnSourceChannel: "telegram",
turnSourceTo: "-100123456789",
turnSourceAccountId: "acct-42",
turnSourceThreadId: 9001,
},
});
const requestCall = requireGatewayCall(0);
expect(requestCall[0]).toBe("plugin.approval.request");
const requestParams = requireRecord(requestCall[2], "approval request params");
expect(requestParams.turnSourceChannel).toBe("telegram");
expect(requestParams.turnSourceTo).toBe("-100123456789");
expect(requestParams.turnSourceAccountId).toBe("acct-42");
expect(requestParams.turnSourceThreadId).toBe(9001);
});
it("uses the transport channel when tool policy provider differs", async () => {
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
title: "Transport routed approval",
description: "Must use the transport channel",
pluginId: "my-plugin",
},
});
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hook-route-"));
await fs.writeFile(path.join(tempDir, "note.txt"), "hello");
mockCallGateway.mockResolvedValueOnce({ id: "transport-route-id", status: "accepted" });
mockCallGateway.mockResolvedValueOnce({
id: "transport-route-id",
decision: "allow-once",
});
const tools = createOpenClawCodingTools({
workspaceDir: tempDir,
messageProvider: "discord-voice",
messageChannel: "discord",
currentChannelId: "native-channel-1",
currentMessagingTarget: "channel:deliverable-1",
agentAccountId: "acct-1",
currentThreadTs: "thread-1",
});
const readTool = tools.find((tool) => tool.name === "read");
if (!readTool) {
throw new Error("missing read tool");
}
await readTool.execute("tool-hook-route", { path: "note.txt" }, undefined, undefined);
const requestCall = requireGatewayCall(0);
expect(requestCall[0]).toBe("plugin.approval.request");
const requestParams = requireRecord(requestCall[2], "approval request params");
expect(requestParams.turnSourceChannel).toBe("discord");
expect(requestParams.turnSourceTo).toBe("channel:deliverable-1");
expect(requestParams.turnSourceAccountId).toBe("acct-1");
expect(requestParams.turnSourceThreadId).toBe("thread-1");
});
it("omits turn source routing fields when ctx does not carry them", async () => {
hookRunner.runBeforeToolCall.mockResolvedValue({
requireApproval: {
title: "No route ctx",
description: "Local-only approval",
},
});
mockCallGateway.mockResolvedValueOnce({ id: "no-route-id", status: "accepted" });
mockCallGateway.mockResolvedValueOnce({ id: "no-route-id", decision: "allow-once" });
await runBeforeToolCallHook({
toolName: "bash",
params: {},
ctx: { agentId: "main", sessionKey: "main" },
});
const requestCall = requireGatewayCall(0);
const requestParams = requireRecord(requestCall[2], "approval request params");
expect(requestParams.turnSourceChannel).toBeUndefined();
expect(requestParams.turnSourceTo).toBeUndefined();
expect(requestParams.turnSourceAccountId).toBeUndefined();
expect(requestParams.turnSourceThreadId).toBeUndefined();
});
});
describe("before_tool_call tool content private-data capture", () => {

View File

@@ -125,6 +125,11 @@ export type HookContext = {
runId?: string;
trace?: DiagnosticTraceContext;
channelId?: string;
/** Originating channel for approval delivery routing; mirrors exec approval turn-source fields. */
turnSourceChannel?: string;
turnSourceTo?: string;
turnSourceAccountId?: string;
turnSourceThreadId?: string | number;
loopDetection?: ToolLoopDetectionConfig;
onToolOutcome?: ToolOutcomeObserver;
allocateToolOutcomeOrdinal?: (toolCallId?: string) => number;
@@ -673,6 +678,10 @@ async function requestPluginToolApproval(params: {
toolCallId: params.toolCallId,
agentId: params.ctx?.agentId,
sessionKey: params.ctx?.sessionKey,
turnSourceChannel: params.ctx?.turnSourceChannel,
turnSourceTo: params.ctx?.turnSourceTo,
turnSourceAccountId: params.ctx?.turnSourceAccountId,
turnSourceThreadId: params.ctx?.turnSourceThreadId,
timeoutMs,
twoPhase: true,
},

View File

@@ -429,6 +429,8 @@ export function createOpenClawCodingTools(options?: {
agentId?: string;
exec?: ExecToolDefaults & ProcessToolDefaults;
messageProvider?: string;
/** Canonical transport channel when tool-policy provider differs from delivery channel. */
messageChannel?: string;
/** Specific ingress provider used only for transport tool availability. */
toolPolicyMessageProvider?: string;
agentAccountId?: string;
@@ -1212,6 +1214,8 @@ export function createOpenClawCodingTools(options?: {
}),
);
options?.recordToolPrepStage?.("schema-normalization");
const turnSourceChannel = options?.messageChannel ?? options?.messageProvider;
const turnSourceTo = options?.currentMessagingTarget ?? options?.currentChannelId;
const hookContext = {
agentId,
...(options?.config ? { config: options.config } : {}),
@@ -1225,6 +1229,10 @@ export function createOpenClawCodingTools(options?: {
sessionId: options?.sessionId,
runId: options?.runId,
channelId: options?.hookChannelId ?? options?.currentChannelId,
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),
...(options?.currentThreadTs ? { turnSourceThreadId: options.currentThreadTs } : {}),
...(options?.trace ? { trace: options.trace } : {}),
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
onToolOutcome: options?.onToolOutcome,

View File

@@ -1264,6 +1264,7 @@ export async function runEmbeddedAttempt(
const allTools = createOpenClawCodingTools({
agentId: sessionAgentId,
...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }),
messageChannel: params.messageChannel,
exec: {
...params.execOverrides,
config: params.config,

View File

@@ -805,8 +805,9 @@ async function recoverStore(params: {
messages = await readSessionMessagesAsync(
{
agentId: resolveAgentIdFromSessionKey(sessionKey),
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey,
storePath: params.storePath,
},
{

View File

@@ -2,6 +2,7 @@
import type { Model } from "openclaw/plugin-sdk/llm";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ModelProviderConfig } from "../config/config.js";
import { captureEnv, deleteTestEnvValue, setTestEnvValue } from "../test-utils/env.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import {
CUSTOM_LOCAL_AUTH_MARKER,
@@ -211,30 +212,22 @@ describe("createRuntimeProviderAuthLookup", () => {
});
async function withoutEnv<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env[key];
delete process.env[key];
const snapshot = captureEnv([key]);
deleteTestEnvValue(key);
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
snapshot.restore();
}
}
async function withEnv<T>(key: string, value: string, fn: () => Promise<T>): Promise<T> {
const previous = process.env[key];
process.env[key] = value;
const snapshot = captureEnv([key]);
setTestEnvValue(key, value);
try {
return await fn();
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
snapshot.restore();
}
}

View File

@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { resolveDefaultAgentDir } from "./agent-scope.js";
import {
CUSTOM_PROXY_MODELS_CONFIG,
@@ -146,19 +147,15 @@ async function runEnvProviderCase(params: {
expectedApiKeyRef: string;
}) {
// Mutate one env var at a time so auth-gated provider generation stays isolated.
const previousValue = process.env[params.envVar];
process.env[params.envVar] = params.envValue;
const envSnapshot = captureEnv([params.envVar]);
setTestEnvValue(params.envVar, params.envValue);
try {
await ensureOpenClawModelsJson({});
const provider = (await readGeneratedProviders(resolveDefaultAgentDir({})))[params.providerKey];
expect(provider?.apiKey).toBe(params.expectedApiKeyRef);
} finally {
if (previousValue === undefined) {
delete process.env[params.envVar];
} else {
process.env[params.envVar] = previousValue;
}
envSnapshot.restore();
}
}
@@ -192,7 +189,7 @@ describe("models-config", () => {
const agentDir = path.join(home, "agent-empty");
// ensureAuthProfileStore merges the main auth store into non-main dirs; point main at our temp dir.
process.env.OPENCLAW_AGENT_DIR = agentDir;
setTestEnvValue("OPENCLAW_AGENT_DIR", agentDir);
const result = await ensureOpenClawModelsJson(
{

View File

@@ -17,7 +17,7 @@ const {
const { tmpdir } = require("node:os");
const baseDir = mkdtempSync(nodePath.join(tmpdir(), "openclaw-sandbox-registry-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = baseDir;
Reflect.set(process.env, "OPENCLAW_STATE_DIR", baseDir);
return {
TEST_STATE_DIR: baseDir,
@@ -38,6 +38,7 @@ vi.mock("./constants.js", () => ({
}));
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { deleteTestEnvValue, setTestEnvValue } from "../../test-utils/env.js";
import { hashTextSha256 } from "./hash.js";
import {
migrateLegacySandboxRegistryFiles,
@@ -77,9 +78,9 @@ afterAll(async () => {
closeOpenClawStateDatabaseForTest();
await fs.rm(TEST_STATE_DIR, { recursive: true, force: true });
if (PREVIOUS_OPENCLAW_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
deleteTestEnvValue("OPENCLAW_STATE_DIR");
} else {
process.env.OPENCLAW_STATE_DIR = PREVIOUS_OPENCLAW_STATE_DIR;
setTestEnvValue("OPENCLAW_STATE_DIR", PREVIOUS_OPENCLAW_STATE_DIR);
}
});

View File

@@ -296,8 +296,9 @@ export async function recoverOrphanedSubagentSessions(params: {
const messages = await readSessionMessagesAsync(
{
agentId: resolveAgentIdFromSessionKey(childSessionKey),
sessionFile: entry.sessionFile,
sessionEntry: entry,
sessionId: entry.sessionId,
sessionKey: childSessionKey,
storePath,
},
{

View File

@@ -69,4 +69,16 @@ vi.mock("../../channels/plugins/session-conversation.js", () => ({
threadId: match.groups.threadId,
};
},
resolveSessionThreadInfo: (sessionKey: string | undefined | null) => {
const trimmed = sessionKey?.trim();
const topicMarker = ":topic:";
const topicIndex = trimmed?.lastIndexOf(topicMarker) ?? -1;
if (!trimmed || topicIndex < 0) {
return { baseSessionKey: trimmed, threadId: undefined };
}
return {
baseSessionKey: trimmed.slice(0, topicIndex),
threadId: trimmed.slice(topicIndex + topicMarker.length) || undefined,
};
},
}));

View File

@@ -126,8 +126,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
@@ -192,8 +193,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{
@@ -225,8 +227,9 @@ describe("embedded gateway stub", () => {
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
{
agentId: "main",
sessionFile: undefined,
sessionEntry: { sessionId: "sess-main" },
sessionId: "sess-main",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-sessions.json",
},
{

View File

@@ -155,14 +155,22 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
const sessionEntry =
typeof entry?.sessionId === "string"
? {
sessionId: entry.sessionId,
...(typeof entry.sessionFile === "string" ? { sessionFile: entry.sessionFile } : {}),
}
: undefined;
const localMessages =
sessionId && storePath
? await rt.readSessionMessagesAsync(
{
agentId: sessionAgentId,
sessionFile: entry?.sessionFile as string | undefined,
sessionEntry,
sessionId,
sessionKey,
storePath,
},
{

View File

@@ -156,8 +156,9 @@ export function createSessionsListTool(opts?: {
const titleTargets: Array<{
row: SessionListRow;
titleEntry: SessionEntry;
sessionEntry: { sessionFile?: string; sessionId: string };
sessionId: string;
sessionFile?: string;
sessionKey: string;
agentId: string;
}> = [];
@@ -356,8 +357,16 @@ export function createSessionsListTool(opts?: {
subject: readStringValue((entry as { subject?: unknown }).subject),
updatedAt: typeof row.updatedAt === "number" ? row.updatedAt : 0,
},
sessionEntry: {
sessionId,
...(sessionFile ? { sessionFile } : {}),
},
sessionId,
...(sessionFile ? { sessionFile } : {}),
sessionKey: resolveInternalSessionKey({
key,
alias,
mainKey,
}),
agentId: resolvedAgentId,
});
}
@@ -385,8 +394,9 @@ export function createSessionsListTool(opts?: {
const target = titleTargets[next];
const fields = await readSessionTitleFieldsFromTranscriptAsync({
agentId: target.agentId,
sessionFile: target.sessionFile,
sessionEntry: target.sessionEntry,
sessionId: target.sessionId,
sessionKey: target.sessionKey,
storePath,
});
if (includeDerivedTitles && !target.row.derivedTitle) {

View File

@@ -1,29 +1,38 @@
// Workspace default tests cover environment-variable precedence for the
// built-in agent workspace location.
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { resolveDefaultAgentWorkspaceDir } from "./workspace.js";
afterEach(() => {
vi.unstubAllEnvs();
});
describe("DEFAULT_AGENT_WORKSPACE_DIR", () => {
it("uses OPENCLAW_HOME when resolving the default workspace dir", () => {
const home = path.join(path.sep, "srv", "openclaw-home");
vi.stubEnv("OPENCLAW_HOME", home);
vi.stubEnv("HOME", path.join(path.sep, "home", "other"));
expect(resolveDefaultAgentWorkspaceDir()).toBe(
path.join(path.resolve(home), ".openclaw", "workspace"),
const resolved = withEnv(
{
OPENCLAW_WORKSPACE_DIR: undefined,
OPENCLAW_PROFILE: undefined,
OPENCLAW_HOME: home,
HOME: path.join(path.sep, "home", "other"),
},
() => resolveDefaultAgentWorkspaceDir(),
);
expect(resolved).toBe(path.join(path.resolve(home), ".openclaw", "workspace"));
});
it("uses OPENCLAW_WORKSPACE_DIR before OPENCLAW_HOME", () => {
const workspaceDir = path.join(path.sep, "srv", "openclaw-workspace");
vi.stubEnv("OPENCLAW_WORKSPACE_DIR", workspaceDir);
vi.stubEnv("OPENCLAW_HOME", path.join(path.sep, "srv", "openclaw-home"));
expect(resolveDefaultAgentWorkspaceDir()).toBe(path.resolve(workspaceDir));
const resolved = withEnv(
{
OPENCLAW_WORKSPACE_DIR: workspaceDir,
OPENCLAW_HOME: path.join(path.sep, "srv", "openclaw-home"),
},
() => resolveDefaultAgentWorkspaceDir(),
);
expect(resolved).toBe(path.resolve(workspaceDir));
});
});

View File

@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(),
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
}));
const { formatFeedSearchEntry, resolveCatalogFeedSearchOptions } =
await import("./feed-search-options.js");
describe("feed search CLI options", () => {
beforeEach(() => {
mocks.readConfigFileSnapshot.mockReset();
});
it("quotes feed install specs in native search hints", () => {
const formatted = formatFeedSearchEntry({
id: "calendar-helper",
type: "plugin",
sourceId: "company-approved",
feedId: "company-feed",
install: {
source: "clawhub",
spec: "safe-package && curl example.invalid",
},
});
expect(formatted).toContain(
"Install: openclaw plugins install 'clawhub:safe-package && curl example.invalid'",
);
});
it("uses npm install specs in native search hints", () => {
const formatted = formatFeedSearchEntry({
id: "calendar-helper",
type: "plugin",
sourceId: "company-approved",
feedId: "company-feed",
install: {
source: "npm",
npmSpec: "@company/calendar-helper@1.2.3",
},
});
expect(formatted).toContain("Install: openclaw plugins install @company/calendar-helper@1.2.3");
});
it("keeps default search disabled when feed search config cannot be read", async () => {
mocks.readConfigFileSnapshot.mockRejectedValueOnce(new Error("bad feeds config"));
await expect(resolveCatalogFeedSearchOptions({})).resolves.toEqual({ enabled: false });
});
it("preserves an explicit empty default feed source list", async () => {
mocks.readConfigFileSnapshot.mockResolvedValueOnce({
valid: true,
config: {
plugins: {
entries: {
feeds: {
enabled: true,
config: {
search: { default: true, sources: [] },
},
},
},
},
},
});
await expect(resolveCatalogFeedSearchOptions({})).resolves.toEqual({
enabled: true,
sourceIds: [],
});
});
it("fails explicit feed search when the Feeds plugin is disabled", async () => {
mocks.readConfigFileSnapshot.mockResolvedValueOnce({
valid: true,
config: {
plugins: {
entries: {
feeds: {
enabled: false,
config: {
search: { default: true },
},
},
},
},
},
});
await expect(resolveCatalogFeedSearchOptions({ catalogFeeds: true })).rejects.toThrow(
"Catalog feed search requires the Feeds plugin to be enabled and allowed in config.",
);
});
it("fails explicit feed search when feed search config cannot be read", async () => {
mocks.readConfigFileSnapshot.mockRejectedValueOnce(new Error("bad feeds config"));
await expect(resolveCatalogFeedSearchOptions({ catalogFeeds: true })).rejects.toThrow(
"Catalog feed search requires the Feeds plugin to be enabled and allowed in config.",
);
});
it("keeps default search disabled when the Feeds plugin is disabled", async () => {
mocks.readConfigFileSnapshot.mockResolvedValueOnce({
valid: true,
config: {
plugins: {
entries: {
feeds: {
enabled: false,
config: {
search: { default: true },
},
},
},
},
},
});
await expect(resolveCatalogFeedSearchOptions({})).resolves.toEqual({ enabled: false });
});
});

View File

@@ -1,216 +0,0 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { theme } from "../../packages/terminal-core/src/theme.js";
import { readConfigFileSnapshot } from "../config/config.js";
type FeedEntryType = "skill" | "plugin";
type FeedEntryResult = {
readonly type: FeedEntryType;
readonly id: string;
readonly sourceId: string;
readonly feedId: string;
readonly name?: string;
readonly description?: string;
readonly version?: string;
readonly install?: unknown;
};
type FeedSearchApi = {
readonly searchConfiguredFeedEntries: (options: {
readonly query?: string;
readonly type?: FeedEntryType;
readonly sourceIds?: readonly string[];
readonly limit?: number;
}) => Promise<readonly FeedEntryResult[]>;
};
export type CatalogFeedSearchCliOptions = {
catalogFeeds?: boolean;
feedSource?: string[];
};
export function splitFeedSourceIds(values: string[] | undefined): string[] | undefined {
if (values === undefined) {
return undefined;
}
const ids = values.flatMap((value) =>
value
.split(",")
.map((part) => part.trim())
.filter(Boolean),
);
return ids.length === 0 ? undefined : [...new Set(ids)];
}
export async function shouldSearchCatalogFeeds(
opts: CatalogFeedSearchCliOptions,
): Promise<boolean> {
return (await resolveCatalogFeedSearchOptions(opts)).enabled;
}
export async function resolveCatalogFeedSearchOptions(opts: CatalogFeedSearchCliOptions): Promise<{
enabled: boolean;
sourceIds?: string[];
}> {
const cliSourceIds = splitFeedSourceIds(opts.feedSource);
if (opts.catalogFeeds === true || cliSourceIds !== undefined) {
if (!(await readFeedPluginEnabledForSearch())) {
throw new Error(
"Catalog feed search requires the Feeds plugin to be enabled and allowed in config.",
);
}
return { enabled: true, sourceIds: cliSourceIds };
}
return readDefaultCatalogFeedSearchOptions();
}
export async function searchCatalogFeedEntriesForCli(params: {
queryParts: string[] | string;
type: FeedEntryType;
limit?: number;
opts: CatalogFeedSearchCliOptions;
}): Promise<readonly FeedEntryResult[]> {
const searchOptions = await resolveCatalogFeedSearchOptions(params.opts);
if (!searchOptions.enabled) {
return [];
}
const { loadBundledPluginPublicArtifactModuleSync } =
await import("../plugins/public-surface-loader.js");
const { searchConfiguredFeedEntries } = loadBundledPluginPublicArtifactModuleSync<FeedSearchApi>({
dirName: "feeds",
artifactBasename: "api.js",
});
return searchConfiguredFeedEntries({
query: normalizeOptionalString(
Array.isArray(params.queryParts) ? params.queryParts.join(" ") : params.queryParts,
),
type: params.type,
sourceIds: searchOptions.sourceIds,
limit: params.limit,
});
}
export function formatFeedSearchEntry(entry: FeedEntryResult): string {
const version = entry.version === undefined ? "" : ` v${entry.version}`;
const name = entry.name === undefined ? entry.id : entry.name;
const summary = entry.description === undefined ? "" : theme.muted(` - ${entry.description}`);
const install = feedInstallHint(entry);
return `${entry.id}${version} ${name}${summary}\n ${theme.muted(`Feed: ${entry.sourceId}/${entry.feedId}`)}${install}`;
}
function feedInstallHint(entry: FeedEntryResult): string {
const command = formatFeedInstallCommandForSearch(entry);
return command === undefined ? "" : `\n ${theme.muted(`Install: ${command}`)}`;
}
function formatFeedInstallCommandForSearch(entry: FeedEntryResult): string | undefined {
const install = isRecord(entry.install) ? entry.install : {};
const source = typeof install.source === "string" ? install.source : undefined;
const spec = typeof install.spec === "string" ? install.spec.trim() : "";
const clawhubSpec = typeof install.clawhubSpec === "string" ? install.clawhubSpec.trim() : "";
const npmSpec = typeof install.npmSpec === "string" ? install.npmSpec.trim() : "";
const slug = typeof install.slug === "string" ? install.slug.trim() : "";
if (entry.type === "plugin") {
const resolvedSpec =
clawhubSpec ||
(source === "clawhub" && spec ? normalizeClawHubSpec(spec) : "") ||
npmSpec ||
((source === "npm" || source === "path" || source === "git") && spec ? spec : "");
return resolvedSpec ? formatOpenClawCommand(["plugins", "install", resolvedSpec]) : undefined;
}
if (entry.type === "skill") {
const resolvedSpec =
slug ||
(source === "clawhub" && spec ? spec.replace(/^clawhub:/u, "") : "") ||
((source === "git" || source === "path" || source === "local") && spec ? spec : "");
return resolvedSpec ? formatOpenClawCommand(["skills", "install", resolvedSpec]) : undefined;
}
return undefined;
}
function normalizeClawHubSpec(value: string): string {
return value.startsWith("clawhub:") ? value : `clawhub:${value}`;
}
function formatOpenClawCommand(argv: readonly string[]): string {
return ["openclaw", ...argv].map(quoteCliArg).join(" ");
}
function quoteCliArg(value: string): string {
return /^[A-Za-z0-9_/:=.,@%+-]+$/u.test(value)
? value
: "'" + value.replaceAll("'", "'\\''") + "'";
}
async function readFeedPluginEnabledForSearch(): Promise<boolean> {
try {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
return false;
}
const plugins = isRecord(snapshot.config.plugins) ? snapshot.config.plugins : {};
const entries = isRecord(plugins.entries) ? plugins.entries : {};
const feeds = isRecord(entries.feeds) ? entries.feeds : {};
const config = isRecord(feeds.config) ? feeds.config : {};
return feedPluginEnabledForDefaultSearch(plugins, feeds, config);
} catch {
return false;
}
}
async function readDefaultCatalogFeedSearchOptions(): Promise<{
enabled: boolean;
sourceIds?: string[];
}> {
try {
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
return { enabled: false };
}
const plugins = isRecord(snapshot.config.plugins) ? snapshot.config.plugins : {};
const entries = isRecord(plugins.entries) ? plugins.entries : {};
const feeds = isRecord(entries.feeds) ? entries.feeds : {};
const config = isRecord(feeds.config) ? feeds.config : {};
if (!feedPluginEnabledForDefaultSearch(plugins, feeds, config)) {
return { enabled: false };
}
const search = isRecord(config.search) ? config.search : {};
if (search.default !== true) {
return { enabled: false };
}
if (search.sources !== undefined && !Array.isArray(search.sources)) {
return { enabled: false };
}
return {
enabled: true,
sourceIds: Array.isArray(search.sources)
? search.sources.filter((id): id is string => typeof id === "string")
: undefined,
};
} catch {
return { enabled: false };
}
}
function feedPluginEnabledForDefaultSearch(
plugins: Record<string, unknown>,
feeds: Record<string, unknown>,
config: Record<string, unknown>,
): boolean {
const allow = readStringArray(plugins.allow);
const deny = readStringArray(plugins.deny);
return (
plugins.enabled !== false &&
feeds.enabled !== false &&
config.enabled !== false &&
!deny.includes("feeds") &&
(allow.length === 0 || allow.includes("feeds"))
);
}
function readStringArray(value: unknown): readonly string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: [];
}

View File

@@ -20,8 +20,6 @@ export type PluginMarketplaceListOptions = {
export type PluginSearchOptions = {
json?: boolean;
limit?: number;
catalogFeeds?: boolean;
feedSource?: string[];
};
export type PluginUninstallOptions = {
@@ -65,10 +63,6 @@ const loadPluginsAuthoringCommands = createModuleLoader(
() => import("./plugins-authoring-command.js"),
);
function collectFeedSource(value: string, previous: string[]): string[] {
return [...previous, value];
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -96,8 +90,6 @@ export function registerPluginsCli(program: Command) {
.argument("[query...]", "Search query")
.option("--limit <n>", "Max results", (value) => parseStrictPositiveIntOption(value, "--limit"))
.option("--json", "Print JSON", false)
.option("--catalog-feeds", "Search configured catalog feeds instead of ClawHub", false)
.option("--feed-source <id>", "Search one configured feed source id", collectFeedSource, [])
.action(async (queryParts: string[], opts: PluginSearchOptions) => {
const { runPluginsSearchCommand } = await import("./plugins-search-command.js");
await runPluginsSearchCommand(queryParts, opts);

View File

@@ -23,9 +23,6 @@ const mocks = vi.hoisted(() => {
errors,
runtime,
searchClawHubPackages: vi.fn(),
shouldSearchCatalogFeeds: vi.fn(() => false),
searchCatalogFeedEntriesForCli: vi.fn(),
formatFeedSearchEntry: vi.fn((entry: { id: string }) => `feed:${entry.id}`),
};
});
@@ -39,12 +36,6 @@ vi.mock("../infra/clawhub.js", () => ({
searchClawHubPackages: mocks.searchClawHubPackages,
}));
vi.mock("./feed-search-options.js", () => ({
shouldSearchCatalogFeeds: mocks.shouldSearchCatalogFeeds,
searchCatalogFeedEntriesForCli: mocks.searchCatalogFeedEntriesForCli,
formatFeedSearchEntry: mocks.formatFeedSearchEntry,
}));
const { runPluginsSearchCommand } = await import("./plugins-search-command.js");
const { registerPluginsCli } = await import("./plugins-cli.js");
@@ -57,10 +48,6 @@ describe("plugins search command", () => {
mocks.runtime.writeJson.mockClear();
mocks.runtime.exit.mockClear();
mocks.searchClawHubPackages.mockReset();
mocks.shouldSearchCatalogFeeds.mockReset();
mocks.shouldSearchCatalogFeeds.mockReturnValue(false);
mocks.searchCatalogFeedEntriesForCli.mockReset();
mocks.formatFeedSearchEntry.mockClear();
});
it("searches ClawHub code and bundle plugin families", async () => {
@@ -116,22 +103,6 @@ describe("plugins search command", () => {
);
});
it("searches catalog feed plugins when requested", async () => {
mocks.shouldSearchCatalogFeeds.mockResolvedValueOnce(true);
mocks.searchCatalogFeedEntriesForCli.mockResolvedValueOnce([{ id: "calendar-helper" }]);
await runPluginsSearchCommand(["calendar"], { catalogFeeds: true, limit: 5 }, mocks.runtime);
expect(mocks.searchCatalogFeedEntriesForCli).toHaveBeenCalledWith({
queryParts: ["calendar"],
type: "plugin",
limit: 5,
opts: { catalogFeeds: true, limit: 5 },
});
expect(mocks.searchClawHubPackages).not.toHaveBeenCalled();
expect(mocks.logs.join("\n")).toContain("feed:calendar-helper");
});
it("writes JSON results when requested", async () => {
mocks.searchClawHubPackages.mockResolvedValueOnce([]).mockResolvedValueOnce([]);

View File

@@ -8,18 +8,11 @@ import {
} from "../infra/clawhub.js";
import { formatErrorMessage } from "../infra/errors.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import {
formatFeedSearchEntry,
searchCatalogFeedEntriesForCli,
shouldSearchCatalogFeeds,
} from "./feed-search-options.js";
/** Options accepted by `openclaw plugins search`. */
export type PluginsSearchOptions = {
json?: boolean;
limit?: number;
catalogFeeds?: boolean;
feedSource?: string[];
};
const INSTALLABLE_PLUGIN_FAMILIES: ClawHubPackageFamily[] = ["code-plugin", "bundle-plugin"];
@@ -91,25 +84,6 @@ export async function runPluginsSearchCommand(
const limit = clampSearchLimit(opts.limit);
try {
if (await shouldSearchCatalogFeeds(opts)) {
const results = await searchCatalogFeedEntriesForCli({
queryParts,
type: "plugin",
limit,
opts,
});
if (opts.json) {
writeRuntimeJson(runtime, { results });
return;
}
if (results.length === 0) {
runtime.log("No catalog feed plugins found.");
return;
}
runtime.log(`${theme.heading("Catalog feed plugins")} ${theme.muted(`(${results.length})`)}`);
runtime.log(results.map(formatFeedSearchEntry).join("\n"));
return;
}
const groups = await Promise.all(
INSTALLABLE_PLUGIN_FAMILIES.map((family) =>
searchClawHubPackages({

View File

@@ -72,6 +72,7 @@ const mocks = vi.hoisted(() => {
return skillStatusReportFixture;
});
return {
callGatewayMock: vi.fn(),
loadConfigMock: vi.fn(() => ({})),
resolveDefaultAgentIdMock: vi.fn((_configForTest: unknown) => "main"),
resolveAgentIdByWorkspacePathMock: vi.fn(
@@ -81,9 +82,6 @@ const mocks = vi.hoisted(() => {
(_configForTest: unknown, _agentId: string) => "/tmp/workspace",
),
searchSkillsFromClawHubMock: vi.fn(),
shouldSearchCatalogFeedsMock: vi.fn(() => false),
searchCatalogFeedEntriesForCliMock: vi.fn(),
formatFeedSearchEntryMock: vi.fn((entry: { id: string }) => `feed:${entry.id}`),
installSkillFromClawHubMock: vi.fn(),
installSkillFromSourceMock: vi.fn(),
updateSkillsFromClawHubMock: vi.fn(),
@@ -105,14 +103,12 @@ const mocks = vi.hoisted(() => {
});
const {
callGatewayMock,
loadConfigMock,
resolveDefaultAgentIdMock,
resolveAgentIdByWorkspacePathMock,
resolveAgentWorkspaceDirMock,
searchSkillsFromClawHubMock,
shouldSearchCatalogFeedsMock,
searchCatalogFeedEntriesForCliMock,
formatFeedSearchEntryMock,
installSkillFromClawHubMock,
installSkillFromSourceMock,
updateSkillsFromClawHubMock,
@@ -175,6 +171,10 @@ vi.mock("../runtime.js", () => ({
defaultRuntime: mocks.defaultRuntime,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (...args: unknown[]) => mocks.callGatewayMock(...args),
}));
vi.mock("../utils.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../utils.js")>()),
CONFIG_DIR: "/tmp/openclaw-config",
@@ -193,12 +193,6 @@ vi.mock("../agents/agent-scope.js", () => ({
mocks.resolveAgentWorkspaceDirMock(config, agentId),
}));
vi.mock("./feed-search-options.js", () => ({
shouldSearchCatalogFeeds: mocks.shouldSearchCatalogFeedsMock,
searchCatalogFeedEntriesForCli: mocks.searchCatalogFeedEntriesForCliMock,
formatFeedSearchEntry: mocks.formatFeedSearchEntryMock,
}));
vi.mock("../skills/lifecycle/clawhub.js", () => ({
searchSkillsFromClawHub: (...args: unknown[]) => mocks.searchSkillsFromClawHubMock(...args),
installSkillFromClawHub: (...args: unknown[]) => mocks.installSkillFromClawHubMock(...args),
@@ -262,14 +256,12 @@ describe("skills cli commands", () => {
runtimeLogs.length = 0;
runtimeStdout.length = 0;
runtimeErrors.length = 0;
callGatewayMock.mockReset();
loadConfigMock.mockReset();
resolveDefaultAgentIdMock.mockReset();
resolveAgentIdByWorkspacePathMock.mockReset();
resolveAgentWorkspaceDirMock.mockReset();
searchSkillsFromClawHubMock.mockReset();
shouldSearchCatalogFeedsMock.mockReset();
searchCatalogFeedEntriesForCliMock.mockReset();
formatFeedSearchEntryMock.mockClear();
installSkillFromClawHubMock.mockReset();
installSkillFromSourceMock.mockReset();
updateSkillsFromClawHubMock.mockReset();
@@ -283,12 +275,12 @@ describe("skills cli commands", () => {
fetchClawHubSkillCardMock.mockReset();
buildWorkspaceSkillStatusMock.mockReset();
callGatewayMock.mockRejectedValue(new Error("gateway unavailable"));
loadConfigMock.mockReturnValue({});
resolveDefaultAgentIdMock.mockReturnValue("main");
resolveAgentIdByWorkspacePathMock.mockReturnValue(undefined);
resolveAgentWorkspaceDirMock.mockReturnValue("/tmp/workspace");
searchSkillsFromClawHubMock.mockResolvedValue([]);
shouldSearchCatalogFeedsMock.mockResolvedValue(false);
installSkillFromClawHubMock.mockResolvedValue({
ok: false,
error: "install disabled in test",
@@ -383,22 +375,6 @@ describe("skills cli commands", () => {
).toBe(true);
});
it("searches catalog feed skills when requested", async () => {
mocks.shouldSearchCatalogFeedsMock.mockResolvedValueOnce(true);
mocks.searchCatalogFeedEntriesForCliMock.mockResolvedValueOnce([{ id: "excel-review" }]);
await runCommand(["skills", "search", "excel", "--catalog-feeds"]);
expect(mocks.searchCatalogFeedEntriesForCliMock).toHaveBeenCalledWith({
queryParts: ["excel"],
type: "skill",
limit: undefined,
opts: { catalogFeeds: true, feedSource: [], json: false },
});
expect(searchSkillsFromClawHubMock).not.toHaveBeenCalled();
expect(runtimeLogs.join("\n")).toContain("feed:excel-review");
});
it("rejects partial numeric search limits", async () => {
await expect(runCommand(["skills", "search", "calendar", "--limit", "10ms"])).rejects.toThrow(
"--limit must be a positive integer.",
@@ -1227,6 +1203,60 @@ describe("skills cli commands", () => {
expectStatusWorkspaceCall("/tmp/workspace-writer");
});
it("uses gateway skills.status for read-only status commands when reachable", async () => {
routeWorkspaceByAgent();
const gatewayReport = {
...skillStatusReportFixture,
agentId: "writer",
workspaceDir: "/gateway/workspace-writer",
skills: [
{
...skillStatusReportFixture.skills[0],
name: "apple-notes",
description: "Notes helpers",
eligible: true,
modelVisible: true,
commandVisible: true,
requirements: {
bins: ["memo"],
anyBins: [],
env: [],
config: [],
os: ["darwin"],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
},
],
};
callGatewayMock.mockResolvedValue(gatewayReport);
await runCommand(["skills", "check", "--agent", "writer", "--json"]);
expect(callGatewayMock).toHaveBeenCalledWith({
config: {},
method: "skills.status",
params: { agentId: "writer" },
timeoutMs: 1_500,
clientName: "cli",
mode: "cli",
});
expect(buildWorkspaceSkillStatusMock).not.toHaveBeenCalled();
const output = JSON.parse(runtimeStdout.at(-1) ?? "{}") as {
workspaceDir?: string;
eligible?: string[];
missingRequirements?: Array<{ name: string }>;
};
expect(output.workspaceDir).toBe("/gateway/workspace-writer");
expect(output.eligible).toEqual(["apple-notes"]);
expect(output.missingRequirements).toEqual([]);
});
it.each([
["list", ["skills", "list", "--agent", "writer", "--json"]],
["info", ["skills", "info", "calendar", "--agent", "writer", "--json"]],

View File

@@ -1,6 +1,10 @@
// Skills CLI for workspace status, install/update, ClawHub verification, and workshop proposals.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import {
@@ -46,11 +50,6 @@ import type {
} from "../skills/workshop/types.js";
import { CONFIG_DIR } from "../utils.js";
import { resolveOptionFromCommand } from "./cli-utils.js";
import {
formatFeedSearchEntry,
searchCatalogFeedEntriesForCli,
shouldSearchCatalogFeeds,
} from "./feed-search-options.js";
import { parseStrictPositiveIntOption } from "./program/helpers.js";
import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js";
@@ -74,6 +73,10 @@ type ResolveSkillsWorkspaceOptions = {
cwd?: string;
};
type ResolvedSkillsWorkspace = ReturnType<typeof resolveSkillsWorkspace>;
const GATEWAY_SKILLS_STATUS_TIMEOUT_MS = 1_500;
function resolveSkillsWorkspace(options?: ResolveSkillsWorkspaceOptions): {
config: ReturnType<typeof getRuntimeConfig>;
workspaceDir: string;
@@ -100,12 +103,37 @@ function resolveAgentOption(
return resolveOptionFromCommand<string>(command, "agent") ?? opts?.agent;
}
async function loadGatewaySkillsStatusReport(
resolved: ResolvedSkillsWorkspace,
): Promise<SkillStatusReport | null> {
try {
const { callGateway } = await import("../gateway/call.js");
return await callGateway<SkillStatusReport>({
config: resolved.config,
method: "skills.status",
params: { agentId: resolved.agentId },
timeoutMs: GATEWAY_SKILLS_STATUS_TIMEOUT_MS,
clientName: GATEWAY_CLIENT_NAMES.CLI,
mode: GATEWAY_CLIENT_MODES.CLI,
});
} catch {
return null;
}
}
async function loadSkillsStatusReport(
options?: ResolveSkillsWorkspaceOptions,
): Promise<SkillStatusReport> {
const { config, workspaceDir, agentId } = resolveSkillsWorkspace(options);
const resolved = resolveSkillsWorkspace(options);
const gatewayReport = await loadGatewaySkillsStatusReport(resolved);
if (gatewayReport) {
return gatewayReport;
}
const { buildWorkspaceSkillStatus } = await import("../skills/discovery/status.js");
return buildWorkspaceSkillStatus(workspaceDir, { config, agentId });
return buildWorkspaceSkillStatus(resolved.workspaceDir, {
config: resolved.config,
agentId: resolved.agentId,
});
}
async function runSkillsAction(
@@ -244,10 +272,6 @@ async function readSkillProposalInput(options: {
return { content: await readSkillProposalDraftFile(proposal!) };
}
function collectFeedSource(value: string, previous: string[]): string[] {
return [...previous, value];
}
/**
* Register the skills CLI commands
*/
@@ -268,57 +292,30 @@ export function registerSkillsCli(program: Command) {
.argument("[query...]", "Optional search query")
.option("--limit <n>", "Max results", (value) => parseStrictPositiveIntOption(value, "--limit"))
.option("--json", "Output as JSON", false)
.option("--catalog-feeds", "Search configured catalog feeds instead of ClawHub", false)
.option("--feed-source <id>", "Search one configured feed source id", collectFeedSource, [])
.action(
async (
queryParts: string[],
opts: { limit?: number; json?: boolean; catalogFeeds?: boolean; feedSource?: string[] },
) => {
try {
if (await shouldSearchCatalogFeeds(opts)) {
const results = await searchCatalogFeedEntriesForCli({
queryParts,
type: "skill",
limit: opts.limit,
opts,
});
if (opts.json) {
defaultRuntime.writeJson({ results });
return;
}
if (results.length === 0) {
defaultRuntime.log("No catalog feed skills found.");
return;
}
for (const entry of results) {
defaultRuntime.log(formatFeedSearchEntry(entry));
}
return;
}
const results = await searchSkillsFromClawHub({
query: normalizeOptionalString(queryParts.join(" ")),
limit: opts.limit,
});
if (opts.json) {
defaultRuntime.writeJson({ results });
return;
}
if (results.length === 0) {
defaultRuntime.log("No ClawHub skills found.");
return;
}
for (const entry of results) {
const version = entry.version ? ` v${entry.version}` : "";
const summary = entry.summary ? ` ${entry.summary}` : "";
defaultRuntime.log(`${entry.slug}${version} ${entry.displayName}${summary}`);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
.action(async (queryParts: string[], opts: { limit?: number; json?: boolean }) => {
try {
const results = await searchSkillsFromClawHub({
query: normalizeOptionalString(queryParts.join(" ")),
limit: opts.limit,
});
if (opts.json) {
defaultRuntime.writeJson({ results });
return;
}
},
);
if (results.length === 0) {
defaultRuntime.log("No ClawHub skills found.");
return;
}
for (const entry of results) {
const version = entry.version ? ` v${entry.version}` : "";
const summary = entry.summary ? ` ${entry.summary}` : "";
defaultRuntime.log(`${entry.slug}${version} ${entry.displayName}${summary}`);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
skills
.command("install")

View File

@@ -24,6 +24,7 @@ import {
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
resolveSessionTranscriptReadTarget,
resolveSessionTranscriptRuntimeReadTarget,
resolveSessionTranscriptRuntimeTarget,
trimSessionTranscriptForManualCompact,
@@ -1475,6 +1476,44 @@ describe("session accessor file-backed seam", () => {
expect(loadSessionEntry(scope)?.sessionFile).toBeUndefined();
});
it("uses a supplied read session entry without loading the store", () => {
const explicitSessionFile = path.join(tempDir, "entry-session.jsonl");
fs.writeFileSync(explicitSessionFile, "", "utf8");
fs.writeFileSync(storePath, "{not-json", "utf8");
const target = resolveSessionTranscriptReadTarget({
agentId: "main",
sessionEntry: {
sessionFile: explicitSessionFile,
sessionId: "session-1",
},
sessionId: "session-1",
sessionKey: "agent:main:main",
storePath,
});
expect(target).toMatchObject({
agentId: "main",
sessionFile: fs.realpathSync(explicitSessionFile),
sessionId: "session-1",
sessionKey: "agent:main:main",
});
});
it("resolves an explicit read transcript file without agent identity", () => {
const explicitSessionFile = path.join(tempDir, "explicit-read-session.jsonl");
const target = resolveSessionTranscriptReadTarget({
sessionFile: explicitSessionFile,
sessionId: "session-1",
});
expect(target).toEqual({
sessionFile: explicitSessionFile,
sessionId: "session-1",
});
});
it("keeps read and write runtime targets aligned for new topic sessions", async () => {
const scope = {
agentId: "main",

Some files were not shown because too many files have changed in this diff Show More