mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 05:31:59 +08:00
Compare commits
46 Commits
feeds-nati
...
qa-fold-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48bdbad66a | ||
|
|
781e2ada63 | ||
|
|
fa78189458 | ||
|
|
dafcdb901f | ||
|
|
0b6eb3230c | ||
|
|
c1fe62ee83 | ||
|
|
aa60716363 | ||
|
|
62dea06219 | ||
|
|
e4270e7709 | ||
|
|
f237f1da6d | ||
|
|
6cfb025143 | ||
|
|
061a3705db | ||
|
|
9e5ac0cea4 | ||
|
|
aff6e221a7 | ||
|
|
5df5aa1640 | ||
|
|
59a93a817f | ||
|
|
23b8f5d037 | ||
|
|
17e2fbfa86 | ||
|
|
cbff4fa5bc | ||
|
|
330545f3e9 | ||
|
|
2b0a72bb48 | ||
|
|
583829a342 | ||
|
|
7b94ae9944 | ||
|
|
1609365b3e | ||
|
|
d216f7c876 | ||
|
|
d41a3d28a0 | ||
|
|
8aa58c5fb0 | ||
|
|
e7e85f5436 | ||
|
|
458904037f | ||
|
|
1e53ee4fd5 | ||
|
|
6037d1a85c | ||
|
|
2c8d19d73e | ||
|
|
70a48a680d | ||
|
|
0c210e5e52 | ||
|
|
38807ffba4 | ||
|
|
fb06df6cad | ||
|
|
50614c51a8 | ||
|
|
1f244f60ed | ||
|
|
10b8b32380 | ||
|
|
3b65f1d279 | ||
|
|
1c711048f9 | ||
|
|
f69f81af9e | ||
|
|
cdf4268540 | ||
|
|
b4651f3781 | ||
|
|
107c49e936 | ||
|
|
ffd8c6e5d9 |
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>";
|
||||
}
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
9
pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal file
29
qa/scenarios/runtime/openai-compatible-chat-tools.yaml
Normal 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.
|
||||
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal file
29
qa/scenarios/runtime/openai-web-search-minimal.yaml
Normal 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.
|
||||
@@ -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.
|
||||
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal file
28
qa/scenarios/runtime/openwebui-openai-compatible.yaml
Normal 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.
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
: [];
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user