Compare commits

..

17 Commits

Author SHA1 Message Date
Gio Della-Libera
c487721eaf fix(feeds): refresh native search stack 2026-06-19 11:26:59 -07:00
Gio Della-Libera
f1ac0e219d fix(feeds): load native search through public surface 2026-06-19 11:13:19 -07:00
Gio Della-Libera
999552fa10 fix(feeds): keep native search lazy and policy evidence aligned 2026-06-19 11:13:19 -07:00
Gio Della-Libera
ab83a77caf fix(feeds): align native feed search activation 2026-06-19 11:13:19 -07:00
Gio Della-Libera
446caae6ae feat(feeds): add native feed search defaults 2026-06-19 11:13:18 -07:00
Gio Della-Libera
47aabc7bcd fix(feeds): stabilize lifecycle tooling 2026-06-19 10:58:27 -07:00
Gio Della-Libera
07af74f131 feat(feeds): add feed lifecycle tooling 2026-06-19 10:47:52 -07:00
Gio Della-Libera
bb7ed06773 fix(policy): refresh feed conformance restack 2026-06-19 10:44:48 -07:00
Gio Della-Libera
ee57dc6b87 fix(policy): align feed evidence with plugin activation 2026-06-19 10:38:15 -07:00
Gio Della-Libera
0738cb6ba4 fix(policy): align feed source conformance matching 2026-06-19 10:38:15 -07:00
Gio Della-Libera
11ef7d6549 fix(policy): require active feeds plugin for feed evidence 2026-06-19 10:38:15 -07:00
Gio Della-Libera
056d2a00e4 feat(policy): add feed catalog conformance 2026-06-19 10:38:15 -07:00
Gio Della-Libera
75abd34788 docs(feeds): document install policy 2026-06-19 10:25:53 -07:00
Gio Della-Libera
7f0b33d2b2 feat(feeds): install approved feed entries 2026-06-19 10:25:53 -07:00
Gio Della-Libera
57d27be40d fix(feeds): cap remote feed documents 2026-06-19 09:40:26 -07:00
Gio Della-Libera
504426827e fix(feeds): guard remote feed fetches 2026-06-19 09:14:31 -07:00
Gio Della-Libera
e54a94f5dc feat(feeds): add read-only feed discovery 2026-06-19 09:08:58 -07:00
140 changed files with 6098 additions and 2285 deletions

5
.github/labeler.yml vendored
View File

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

View File

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

View File

@@ -38,6 +38,8 @@ 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
@@ -103,6 +105,7 @@ 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
@@ -126,9 +129,13 @@ 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 and prints
install-ready package names. It searches code-plugin and bundle-plugin packages,
not skills. Use `openclaw skills search` for ClawHub skills.
`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.
<Note>
ClawHub is the primary distribution and discovery surface for most plugins. Npm
@@ -305,10 +312,12 @@ does not import plugin runtime code, run a package manager, or repair missing
dependencies.
</Note>
`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>`.
`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.
For bundled plugin work inside a packaged Docker image, bind-mount the plugin
source directory over the matching packaged source path, such as

View File

@@ -18,8 +18,9 @@ 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, 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,
feed catalog source 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
@@ -115,6 +116,17 @@ 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"],
@@ -182,8 +194,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,
data-handling config posture, config secret
provider and SecretRef provenance, config auth profile metadata, configured
configured Feeds plugin source declarations, 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
@@ -372,6 +384,20 @@ 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 |
@@ -666,6 +692,16 @@ 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",
@@ -815,6 +851,11 @@ Policy currently verifies:
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
| `policy/feeds-required-source-missing` | A required feed source id is not configured and enabled. |
| `policy/feeds-source-unpinned` | An enabled feed source is not pinned when policy requires pinned feeds. |
| `policy/feeds-source-unsigned` | An enabled feed source uses unsigned trust when policy denies unsigned feeds. |
| `policy/feeds-search-default-missing` | Native skills/plugins search is not configured to use feeds by default. |
| `policy/feeds-search-source-missing` | Native feed search does not require a policy-required source id. |
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |

View File

@@ -2,7 +2,7 @@
summary: "CLI reference for `openclaw skills` (search/install/update/verify/list/info/check/workshop)"
read_when:
- You want to see which skills are available and ready to run
- You want to search ClawHub or install skills from ClawHub, Git, or local directories
- You want to search ClawHub or configured catalog feeds, 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,8 +10,9 @@ title: "Skills"
# `openclaw skills`
Inspect local skills, search ClawHub, install skills from ClawHub/Git/local
directories, verify ClawHub skills, and update ClawHub-tracked installs.
Inspect local skills, search ClawHub or configured catalog feeds, install skills
from ClawHub/Git/local directories, verify ClawHub skills, and update
ClawHub-tracked installs.
Related:
@@ -25,6 +26,8 @@ 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
@@ -64,12 +67,14 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`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.
`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.
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.
@@ -86,8 +91,11 @@ 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.
ClawHub search feed, or the configured feed default when Feeds search is enabled.
- `search --limit <n>` caps returned results.
- `search --catalog-feeds` searches configured feed entries instead of ClawHub.
- `search --feed-source <id>` searches one configured feed source id; repeat it or
pass comma-separated ids to search multiple sources.
- `install git:owner/repo[@ref]` installs a Git skill. Branch refs may contain
slashes, such as `git:owner/repo@feature/foo`.
- `install ./path/to/skill` installs a local directory whose root contains

View File

@@ -37,7 +37,7 @@ that agent; if you copy credentials manually, copy only portable static
`api_key` or `token` profiles.
</Warning>
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-allowlists).
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -160,7 +160,7 @@ it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-skill-roots)
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
## Anthropic 429 extra usage required for long context

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements reply delivery behavior.
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
import {
buildOutboundSessionContext,
sendDurableMessageBatch,
@@ -156,19 +156,6 @@ 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[];
@@ -191,9 +178,7 @@ export async function deliverDiscordReply(params: {
void params.runtime;
const delivery = resolveDiscordDeliveryOptions(params);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
kind: params.kind,
}).map(formatDiscordReasoningPayload);
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
if (payloads.length === 0) {
return;
}

View File

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

View File

@@ -1,6 +1,5 @@
// 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,
@@ -29,7 +28,6 @@ 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;
@@ -78,10 +76,6 @@ 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) {
@@ -413,7 +407,7 @@ async function runExaSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await readExaErrorDetail(res);
const detail = await res.text();
throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`);
}
return readExaSearchResults(res);
@@ -613,7 +607,6 @@ export const testing = {
resolveExaSearchCount,
resolveExaSearchEndpoint,
resolveFreshnessStartDate,
readExaErrorDetail,
readExaSearchResults,
} as const;
export { testing as __testing };

View File

@@ -1,31 +1,9 @@
// Exa tests cover exa web search provider plugin behavior.
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } 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();
@@ -264,20 +242,4 @@ 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();
});
});

7
extensions/feeds/api.ts Normal file
View File

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

28
extensions/feeds/index.ts Normal file
View File

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

View File

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

View File

@@ -0,0 +1,24 @@
{
"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

1346
extensions/feeds/src/cli.ts Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,7 @@
// 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,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readProviderJsonArrayFieldResponse } 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";
@@ -20,7 +17,6 @@ import {
import { buildLmstudioAuthHeaders } from "./runtime.js";
const log = createSubsystemLogger("extensions/lmstudio/models");
const LMSTUDIO_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type LmstudioLoadResponse = {
status?: string;
@@ -257,7 +253,7 @@ export async function ensureLmstudioModelLoaded(params: {
});
try {
if (!response.ok) {
const body = await readResponseTextLimited(response, LMSTUDIO_ERROR_BODY_LIMIT_BYTES);
const body = await response.text();
throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`);
}
let payload: LmstudioLoadResponse;

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ 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";
@@ -30,7 +29,6 @@ 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];
@@ -117,7 +115,7 @@ async function requestOAuthCode(params: {
});
try {
if (!response.ok) {
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
const text = await response.text();
throw new Error(`MiniMax OAuth authorization failed: ${text || response.statusText}`);
}
@@ -173,7 +171,7 @@ async function pollOAuthToken(params: {
}
async function parseMiniMaxOAuthTokenResponse(response: Response): Promise<TokenResult> {
const text = await readResponseTextLimited(response, MINIMAX_OAUTH_ERROR_BODY_LIMIT_BYTES);
const text = await response.text();
let payload:
| {
status?: string;

View File

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

View File

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

View File

@@ -83,28 +83,6 @@ 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,
@@ -339,39 +317,6 @@ describe("ollama embedding provider", () => {
});
});
it("bounds embed error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
vi.stubGlobal(
"fetch",
vi.fn(async () => tracked.response),
);
const { provider } = await createOllamaEmbeddingProvider({
config: {} as OpenClawConfig,
provider: "ollama",
model: "nomic-embed-text",
fallback: "none",
remote: { baseUrl: "http://127.0.0.1:11434" },
});
let error: unknown;
try {
await provider.embedQuery("hello");
} catch (err) {
error = err;
}
expect(String(error)).toContain("Ollama embed HTTP 503");
expect(String(error)).toContain("ollama embed unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("reports malformed embed JSON with a provider-owned error", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -6,7 +6,6 @@ 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,
@@ -58,7 +57,6 @@ 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 = [
{
@@ -342,11 +340,7 @@ export async function createOllamaEmbeddingProvider(
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
throw new Error(`Ollama embed HTTP ${response.status}: ${await response.text()}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},

View File

@@ -1534,28 +1534,6 @@ 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>;
@@ -2706,14 +2684,12 @@ describe("createOllamaStreamFn", () => {
);
});
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"));
it("surfaces non-2xx HTTP response as status-prefixed error", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: tracked.response,
response: new Response("Service Unavailable", {
status: 503,
statusText: "Service Unavailable",
}),
release: vi.fn(async () => undefined),
});
try {
@@ -2729,10 +2705,6 @@ describe("createOllamaStreamFn", () => {
// The error message must start with the HTTP status code so that
// extractLeadingHttpStatus can parse it for failover/retry logic.
expect(errorEvent.error.errorMessage).toMatch(/^503\b/);
expect(errorEvent.error.errorMessage).toContain("Service Unavailable");
expect(errorEvent.error.errorMessage).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
} finally {
fetchWithSsrFGuardMock.mockReset();
}

View File

@@ -18,7 +18,6 @@ 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,
@@ -55,7 +54,6 @@ 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;
@@ -1213,10 +1211,7 @@ function createRawOllamaStreamFn(
try {
if (!response.ok) {
const errorText = await readResponseTextLimited(
response,
OLLAMA_STREAM_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`${response.status} ${errorText}`);
}
if (!response.body) {

View File

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

View File

@@ -18,7 +18,6 @@ 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";
@@ -56,7 +55,6 @@ 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;
@@ -128,7 +126,7 @@ async function fetchOpenAiBatchResource<T>(params: {
},
onResponse: async (res) => {
if (!res.ok) {
const text = await readResponseTextLimited(res, OPENAI_BATCH_ERROR_BODY_LIMIT_BYTES);
const text = await res.text();
throw new Error(`${params.errorPrefix} failed: ${res.status} ${text}`);
}
return await params.parse(res);

View File

@@ -18,28 +18,6 @@ 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) {
@@ -194,44 +172,6 @@ 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 {
@@ -301,28 +241,6 @@ describe("loginOpenAICodexDeviceCode", () => {
).rejects.toThrow("OpenAI device code request failed: HTTP 503 down now");
});
it("bounds user-code error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"device code unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn().mockResolvedValueOnce(tracked.response);
const error = await loginOpenAICodexDeviceCode({
fetchFn: fetchMock as typeof fetch,
onVerification: async () => {},
}).catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/OpenAI device code request failed: HTTP 503 device code unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("surfaces device authorization failures with sanitized payload details", async () => {
const fetchMock = vi
.fn()

View File

@@ -3,7 +3,6 @@ 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";
@@ -13,8 +12,6 @@ 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();
@@ -123,15 +120,6 @@ 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",
@@ -141,7 +129,7 @@ async function requestOpenAICodexDeviceCode(fetchFn: typeof fetch): Promise<Requ
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (!response.ok) {
if (response.status === 404) {
throw new Error(
@@ -192,7 +180,7 @@ async function pollOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (response.ok) {
const body = parseJsonObject(bodyText) as DeviceCodeTokenPayload | null;
const authorizationCode = trimNonEmptyString(body?.authorization_code);
@@ -242,7 +230,7 @@ async function exchangeOpenAICodexDeviceCode(params: {
}),
});
const bodyText = await readOpenAICodexDeviceBody(response);
const bodyText = await response.text();
if (!response.ok) {
throw new Error(
formatDeviceCodeError({

View File

@@ -44,28 +44,6 @@ 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.");
@@ -292,23 +270,4 @@ describe("runParallelMcpSearch", () => {
/initialize failed \(500\)/,
);
});
it("bounds initialize error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel mcp unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const error = await runParallelMcpSearch({ searchQueries: ["x"], maxResults: 5 }).catch(
(cause: unknown) => cause,
);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(/initialize failed \(503\): parallel mcp unavailable/);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,6 @@
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
@@ -12,7 +11,6 @@ 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 });
@@ -217,9 +215,7 @@ async function postMcp(params: {
ok: response.ok,
status: response.status,
statusText: response.statusText,
text: response.ok
? await response.text()
: await readResponseTextLimited(response, PARALLEL_MCP_ERROR_BODY_LIMIT_BYTES),
text: await response.text(),
sessionIdHeader: response.headers.get("mcp-session-id"),
}),
);

View File

@@ -1,6 +1,5 @@
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,
@@ -35,7 +34,6 @@ 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 });
@@ -146,9 +144,7 @@ async function runParallelSearch(params: {
},
async (res) => {
if (!res.ok) {
const detail = await readResponseTextLimited(res, PARALLEL_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = await res.text().catch(() => "");
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
}
try {
@@ -281,7 +277,6 @@ export const testing = {
resolveParallelConfig,
resolveParallelSearchCount,
resolveParallelSearchEndpoint,
PARALLEL_ERROR_BODY_LIMIT_BYTES,
USER_AGENT,
} as const;

View File

@@ -37,28 +37,6 @@ 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";
@@ -551,38 +529,6 @@ describe("parallel web search provider", () => {
expect(body.advanced_settings?.max_results).toBe(5);
});
it("bounds Parallel API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"parallel upstream unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "Content-Type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
endpointMockState.responses.push(tracked.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool
.execute({
objective: `parallel-error-body-${Date.now()}`,
search_queries: ["openclaw"],
})
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
/Parallel API error \(503\): parallel upstream unavailable/,
);
expect((error as Error).message).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
// Unique objective so this test does not collide with the SDK's
// module-level web-search cache across other cases.

View File

@@ -581,6 +581,11 @@ 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",
@@ -626,6 +631,869 @@ 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");
@@ -750,6 +1618,23 @@ 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",
@@ -1363,6 +2248,7 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -1397,6 +2283,7 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -1443,6 +2330,7 @@ describe("registerPolicyDoctorChecks", () => {
{
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,
@@ -1475,6 +2363,7 @@ describe("registerPolicyDoctorChecks", () => {
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>, {
includeIngress: false,
includeGatewayExposure: false,
includeFeeds: false,
includeAgentWorkspace: false,
includeDataHandling: false,
includeToolPosture: false,

View File

@@ -20,6 +20,8 @@ import {
type PolicyDataHandlingEvidence,
type PolicyEvidence,
type PolicyExecApprovalEvidence,
type PolicyFeedSourceEvidence,
type PolicyFeedSearchEvidence,
type PolicyIngressEvidence,
type PolicySandboxPostureEvidence,
type PolicyToolPostureEvidence,
@@ -56,6 +58,11 @@ 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",
@@ -124,6 +131,11 @@ 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,
@@ -589,6 +601,7 @@ const SUPPORTED_POLICY_SECTIONS = [
"channels",
"dataHandling",
"execApprovals",
"feeds",
"gateway",
"ingress",
"mcp",
@@ -673,6 +686,11 @@ 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);
@@ -979,6 +997,56 @@ 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",
@@ -1450,6 +1518,7 @@ 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,
@@ -1544,6 +1613,7 @@ 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);
@@ -1555,6 +1625,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
toolsRaw: toolsFile?.raw ?? "",
includeIngress,
includeGatewayExposure,
includeFeeds,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
@@ -1568,6 +1639,7 @@ async function evaluatePolicyUncached(ctx: HealthCheckContext): Promise<PolicyEv
evidence = collectPolicyEvidence(ctx.cfg as Record<string, unknown>, {
includeIngress,
includeGatewayExposure,
includeFeeds,
includeAgentWorkspace,
includeDataHandling,
includeToolPosture: policyHasToolPostureRules(policy),
@@ -1586,6 +1658,7 @@ 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),
@@ -2010,6 +2083,13 @@ export function policyContainerShapeFindings(
];
}
}
const feedsFinding = feedsPolicyShapeFinding(policy.feeds, {
policyDocName,
policyPath,
});
if (feedsFinding !== undefined) {
return [feedsFinding];
}
if (policy.secrets !== undefined && !isRecord(policy.secrets)) {
return [
policyShapeFinding(
@@ -2137,6 +2217,83 @@ 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: {
@@ -4203,6 +4360,166 @@ 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,
@@ -6183,6 +6500,22 @@ function policyHasGatewayRules(policy: unknown): boolean {
);
}
function policyHasFeedRules(policy: unknown): boolean {
if (!isRecord(policy) || !isRecord(policy.feeds)) {
return false;
}
return (
(isRecord(policy.feeds.sources) &&
(policy.feeds.sources.require !== undefined ||
policy.feeds.sources.requirePinned !== undefined ||
policy.feeds.sources.allowUnsigned !== undefined)) ||
(isRecord(policy.feeds.search) &&
(policy.feeds.search.requireDefault !== undefined ||
policy.feeds.search.requireSources !== undefined))
);
}
function policyHasAgentWorkspaceRules(policy: unknown): boolean {
if (!isRecord(policy)) {
return false;

View File

@@ -50,6 +50,8 @@ 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[];
@@ -158,6 +160,21 @@ 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:
@@ -313,6 +330,7 @@ export function collectPolicyEvidence(
readonly toolsRaw?: undefined;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -329,6 +347,7 @@ export function collectPolicyEvidence(
readonly toolsRaw: string;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -345,6 +364,7 @@ export function collectPolicyEvidence(
readonly toolsRaw?: string;
readonly includeIngress?: boolean;
readonly includeGatewayExposure?: boolean;
readonly includeFeeds?: boolean;
readonly includeAgentWorkspace?: boolean;
readonly includeDataHandling?: boolean;
readonly includeToolPosture?: boolean;
@@ -365,6 +385,9 @@ export function collectPolicyEvidence(
...(options.includeGatewayExposure === false
? {}
: { gatewayExposure: scanPolicyGatewayExposure(cfg) }),
...(options.includeFeeds === false
? {}
: { feeds: scanPolicyFeeds(cfg), feedSearch: scanPolicyFeedSearch(cfg) }),
...(options.includeAgentWorkspace === false
? {}
: { agentWorkspace: scanPolicyAgentWorkspace(cfg) }),
@@ -832,6 +855,86 @@ 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[] {
@@ -2418,6 +2521,22 @@ 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 : {};
}
@@ -3026,3 +3145,17 @@ function stableJson(value: unknown): string {
}
return JSON.stringify(value);
}
function feedPluginEnabledForPolicy(
plugins: Record<string, unknown>,
feeds: Record<string, unknown>,
): boolean {
const allow = readStringArray(plugins.allow);
const deny = readStringArray(plugins.deny);
return (
plugins.enabled !== false &&
feeds.enabled !== false &&
!deny.includes("feeds") &&
(allow.length === 0 || allow.includes("feeds"))
);
}

View File

@@ -98,6 +98,16 @@ export type HarnessParityResult = {
firstDriftTurn?: number;
};
export type HarnessParityReport = {
generatedAt: string;
providerMode: string;
left: HarnessVariant;
right: HarnessVariant;
results: HarnessParityResult[];
pass: boolean;
failures: string[];
};
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}

View File

@@ -23,6 +23,8 @@ export type QaRuntimeCapabilityLayer =
| "optional-profile-or-plugin"
| "structural-text";
export type QaCodexToolLoading = "direct" | "searchable";
export type RuntimeParityComparisonMode = "default" | "codex-native-workspace" | "outcome-only";
export type QaRuntimeToolCoverageMetadata = {

View File

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

View File

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

View File

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

View File

@@ -193,6 +193,7 @@ 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({
@@ -280,6 +281,9 @@ 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",
@@ -291,6 +295,9 @@ export const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"adaptive",
"max",
]);
export type VoiceCallRealtimeConsultThinkingLevel = z.infer<
typeof VoiceCallRealtimeConsultThinkingLevelSchema
>;
const VoiceCallStreamingProvidersConfigSchema = z
.record(z.string(), z.record(z.string(), z.unknown()))

View File

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

View File

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

View File

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

9
pnpm-lock.yaml generated
View File

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

View File

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

View File

@@ -49,8 +49,6 @@ 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",
@@ -128,13 +126,6 @@ export function findSessionTranscriptReaderBoundaryViolations(content, fileName
const legacyNamespaces = new Set();
const visit = (node) => {
if (ts.isIdentifier(node) && storageSpecificTranscriptReaderAliasNames.has(node.text)) {
violations.push({
line: toLine(sourceFile, node),
reason: `uses storage-specific transcript reader alias "${node.text}"`,
});
}
if (ts.isImportDeclaration(node)) {
const moduleName = importedModuleName(node);
const namedBindings = node.importClause?.namedBindings;

View File

@@ -980,7 +980,7 @@ export async function readBoundedResponseText(response, byteLimit, timeoutPromis
const contentLength = response.headers?.get?.("content-length");
if (contentLength && /^\d+$/u.test(contentLength)) {
const parsedContentLength = Number(contentLength);
if (!Number.isSafeInteger(parsedContentLength) || parsedContentLength > resolvedByteLimit) {
if (Number.isSafeInteger(parsedContentLength) && parsedContentLength > resolvedByteLimit) {
await response.body?.cancel?.().catch(() => undefined);
throw createFetchBodyTooLargeError(resolvedByteLimit);
}
@@ -1873,14 +1873,12 @@ 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 matchesCommandNeedles = (row) =>
const commandMatches = descendants.filter((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"),
);
@@ -1889,9 +1887,7 @@ async function samplePosixProcessTree(pid, run, commandLineNeedles) {
? commandMatches
: gatewayTitleMatches.length > 0
? gatewayTitleMatches
: descendants.length > 0
? descendants
: rootCommandMatches,
: descendants,
);
if (!selected) {
return null;

View File

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

View File

@@ -24,6 +24,7 @@ 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";
@@ -277,8 +278,7 @@ async function verifySigstoreNpmProvenanceBundle(
bundle: unknown,
policy: NpmProvenanceVerificationPolicy,
): Promise<void> {
const sigstore = require("sigstore") as { verify: VerifyNpmProvenanceBundle };
await sigstore.verify(bundle, policy);
await verifySigstoreBundle(bundle as Parameters<typeof verifySigstoreBundle>[0], policy);
}
export async function verifyNpmProvenanceAttestation(params: {

View File

@@ -570,13 +570,10 @@ async function readAvatarProbeBuffer(
response: Response,
timeoutPromise?: Promise<never>,
): Promise<Buffer> {
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 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 reader = response.body?.getReader?.();

View File

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

View File

@@ -125,11 +125,6 @@ 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;
@@ -678,10 +673,6 @@ async function requestPluginToolApproval(params: {
toolCallId: params.toolCallId,
agentId: params.ctx?.agentId,
sessionKey: params.ctx?.sessionKey,
turnSourceChannel: params.ctx?.turnSourceChannel,
turnSourceTo: params.ctx?.turnSourceTo,
turnSourceAccountId: params.ctx?.turnSourceAccountId,
turnSourceThreadId: params.ctx?.turnSourceThreadId,
timeoutMs,
twoPhase: true,
},

View File

@@ -429,8 +429,6 @@ 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;
@@ -1214,8 +1212,6 @@ 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 } : {}),
@@ -1229,10 +1225,6 @@ export function createOpenClawCodingTools(options?: {
sessionId: options?.sessionId,
runId: options?.runId,
channelId: options?.hookChannelId ?? options?.currentChannelId,
...(turnSourceChannel ? { turnSourceChannel } : {}),
...(turnSourceTo ? { turnSourceTo } : {}),
...(options?.agentAccountId ? { turnSourceAccountId: options.agentAccountId } : {}),
...(options?.currentThreadTs ? { turnSourceThreadId: options.currentThreadTs } : {}),
...(options?.trace ? { trace: options.trace } : {}),
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
onToolOutcome: options?.onToolOutcome,

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
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,
@@ -212,22 +211,30 @@ describe("createRuntimeProviderAuthLookup", () => {
});
async function withoutEnv<T>(key: string, fn: () => Promise<T>): Promise<T> {
const snapshot = captureEnv([key]);
deleteTestEnvValue(key);
const previous = process.env[key];
delete process.env[key];
try {
return await fn();
} finally {
snapshot.restore();
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
}
async function withEnv<T>(key: string, value: string, fn: () => Promise<T>): Promise<T> {
const snapshot = captureEnv([key]);
setTestEnvValue(key, value);
const previous = process.env[key];
process.env[key] = value;
try {
return await fn();
} finally {
snapshot.restore();
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
}

View File

@@ -3,7 +3,6 @@ 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,
@@ -147,15 +146,19 @@ async function runEnvProviderCase(params: {
expectedApiKeyRef: string;
}) {
// Mutate one env var at a time so auth-gated provider generation stays isolated.
const envSnapshot = captureEnv([params.envVar]);
setTestEnvValue(params.envVar, params.envValue);
const previousValue = process.env[params.envVar];
process.env[params.envVar] = params.envValue;
try {
await ensureOpenClawModelsJson({});
const provider = (await readGeneratedProviders(resolveDefaultAgentDir({})))[params.providerKey];
expect(provider?.apiKey).toBe(params.expectedApiKeyRef);
} finally {
envSnapshot.restore();
if (previousValue === undefined) {
delete process.env[params.envVar];
} else {
process.env[params.envVar] = previousValue;
}
}
}
@@ -189,7 +192,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.
setTestEnvValue("OPENCLAW_AGENT_DIR", agentDir);
process.env.OPENCLAW_AGENT_DIR = agentDir;
const result = await ensureOpenClawModelsJson(
{

View File

@@ -17,7 +17,7 @@ const {
const { tmpdir } = require("node:os");
const baseDir = mkdtempSync(nodePath.join(tmpdir(), "openclaw-sandbox-registry-"));
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
Reflect.set(process.env, "OPENCLAW_STATE_DIR", baseDir);
process.env.OPENCLAW_STATE_DIR = baseDir;
return {
TEST_STATE_DIR: baseDir,
@@ -38,7 +38,6 @@ 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,
@@ -78,9 +77,9 @@ afterAll(async () => {
closeOpenClawStateDatabaseForTest();
await fs.rm(TEST_STATE_DIR, { recursive: true, force: true });
if (PREVIOUS_OPENCLAW_STATE_DIR === undefined) {
deleteTestEnvValue("OPENCLAW_STATE_DIR");
delete process.env.OPENCLAW_STATE_DIR;
} else {
setTestEnvValue("OPENCLAW_STATE_DIR", PREVIOUS_OPENCLAW_STATE_DIR);
process.env.OPENCLAW_STATE_DIR = PREVIOUS_OPENCLAW_STATE_DIR;
}
});

View File

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

View File

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

View File

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

View File

@@ -155,22 +155,14 @@ 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,
sessionEntry,
sessionFile: entry?.sessionFile as string | undefined,
sessionId,
sessionKey,
storePath,
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,9 @@ 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}`),
};
});
@@ -36,6 +39,12 @@ 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");
@@ -48,6 +57,10 @@ 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 () => {
@@ -103,6 +116,22 @@ describe("plugins search command", () => {
);
});
it("searches catalog feed plugins when requested", async () => {
mocks.shouldSearchCatalogFeeds.mockResolvedValueOnce(true);
mocks.searchCatalogFeedEntriesForCli.mockResolvedValueOnce([{ id: "calendar-helper" }]);
await runPluginsSearchCommand(["calendar"], { catalogFeeds: true, limit: 5 }, mocks.runtime);
expect(mocks.searchCatalogFeedEntriesForCli).toHaveBeenCalledWith({
queryParts: ["calendar"],
type: "plugin",
limit: 5,
opts: { catalogFeeds: true, limit: 5 },
});
expect(mocks.searchClawHubPackages).not.toHaveBeenCalled();
expect(mocks.logs.join("\n")).toContain("feed:calendar-helper");
});
it("writes JSON results when requested", async () => {
mocks.searchClawHubPackages.mockResolvedValueOnce([]).mockResolvedValueOnce([]);

View File

@@ -8,11 +8,18 @@ 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"];
@@ -84,6 +91,25 @@ export async function runPluginsSearchCommand(
const limit = clampSearchLimit(opts.limit);
try {
if (await shouldSearchCatalogFeeds(opts)) {
const results = await searchCatalogFeedEntriesForCli({
queryParts,
type: "plugin",
limit,
opts,
});
if (opts.json) {
writeRuntimeJson(runtime, { results });
return;
}
if (results.length === 0) {
runtime.log("No catalog feed plugins found.");
return;
}
runtime.log(`${theme.heading("Catalog feed plugins")} ${theme.muted(`(${results.length})`)}`);
runtime.log(results.map(formatFeedSearchEntry).join("\n"));
return;
}
const groups = await Promise.all(
INSTALLABLE_PLUGIN_FAMILIES.map((family) =>
searchClawHubPackages({

View File

@@ -72,7 +72,6 @@ const mocks = vi.hoisted(() => {
return skillStatusReportFixture;
});
return {
callGatewayMock: vi.fn(),
loadConfigMock: vi.fn(() => ({})),
resolveDefaultAgentIdMock: vi.fn((_configForTest: unknown) => "main"),
resolveAgentIdByWorkspacePathMock: vi.fn(
@@ -82,6 +81,9 @@ 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(),
@@ -103,12 +105,14 @@ const mocks = vi.hoisted(() => {
});
const {
callGatewayMock,
loadConfigMock,
resolveDefaultAgentIdMock,
resolveAgentIdByWorkspacePathMock,
resolveAgentWorkspaceDirMock,
searchSkillsFromClawHubMock,
shouldSearchCatalogFeedsMock,
searchCatalogFeedEntriesForCliMock,
formatFeedSearchEntryMock,
installSkillFromClawHubMock,
installSkillFromSourceMock,
updateSkillsFromClawHubMock,
@@ -171,10 +175,6 @@ 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,6 +193,12 @@ 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),
@@ -256,12 +262,14 @@ 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();
@@ -275,12 +283,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",
@@ -375,6 +383,22 @@ 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.",
@@ -1203,60 +1227,6 @@ describe("skills cli commands", () => {
expectStatusWorkspaceCall("/tmp/workspace-writer");
});
it("uses gateway skills.status for read-only status commands when reachable", async () => {
routeWorkspaceByAgent();
const gatewayReport = {
...skillStatusReportFixture,
agentId: "writer",
workspaceDir: "/gateway/workspace-writer",
skills: [
{
...skillStatusReportFixture.skills[0],
name: "apple-notes",
description: "Notes helpers",
eligible: true,
modelVisible: true,
commandVisible: true,
requirements: {
bins: ["memo"],
anyBins: [],
env: [],
config: [],
os: ["darwin"],
},
missing: {
bins: [],
anyBins: [],
env: [],
config: [],
os: [],
},
},
],
};
callGatewayMock.mockResolvedValue(gatewayReport);
await runCommand(["skills", "check", "--agent", "writer", "--json"]);
expect(callGatewayMock).toHaveBeenCalledWith({
config: {},
method: "skills.status",
params: { agentId: "writer" },
timeoutMs: 1_500,
clientName: "cli",
mode: "cli",
});
expect(buildWorkspaceSkillStatusMock).not.toHaveBeenCalled();
const output = JSON.parse(runtimeStdout.at(-1) ?? "{}") as {
workspaceDir?: string;
eligible?: string[];
missingRequirements?: Array<{ name: string }>;
};
expect(output.workspaceDir).toBe("/gateway/workspace-writer");
expect(output.eligible).toEqual(["apple-notes"]);
expect(output.missingRequirements).toEqual([]);
});
it.each([
["list", ["skills", "list", "--agent", "writer", "--json"]],
["info", ["skills", "info", "calendar", "--agent", "writer", "--json"]],

View File

@@ -1,10 +1,6 @@
// 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 {
@@ -50,6 +46,11 @@ 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";
@@ -73,10 +74,6 @@ 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;
@@ -103,37 +100,12 @@ 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 resolved = resolveSkillsWorkspace(options);
const gatewayReport = await loadGatewaySkillsStatusReport(resolved);
if (gatewayReport) {
return gatewayReport;
}
const { config, workspaceDir, agentId } = resolveSkillsWorkspace(options);
const { buildWorkspaceSkillStatus } = await import("../skills/discovery/status.js");
return buildWorkspaceSkillStatus(resolved.workspaceDir, {
config: resolved.config,
agentId: resolved.agentId,
});
return buildWorkspaceSkillStatus(workspaceDir, { config, agentId });
}
async function runSkillsAction(
@@ -272,6 +244,10 @@ async function readSkillProposalInput(options: {
return { content: await readSkillProposalDraftFile(proposal!) };
}
function collectFeedSource(value: string, previous: string[]): string[] {
return [...previous, value];
}
/**
* Register the skills CLI commands
*/
@@ -292,30 +268,57 @@ 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)
.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;
.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);
}
if (results.length === 0) {
defaultRuntime.log("No ClawHub skills found.");
return;
}
for (const entry of results) {
const version = entry.version ? ` v${entry.version}` : "";
const summary = entry.summary ? ` ${entry.summary}` : "";
defaultRuntime.log(`${entry.slug}${version} ${entry.displayName}${summary}`);
}
} catch (err) {
defaultRuntime.error(String(err));
defaultRuntime.exit(1);
}
});
},
);
skills
.command("install")

View File

@@ -24,7 +24,6 @@ import {
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
resolveSessionTranscriptReadTarget,
resolveSessionTranscriptRuntimeReadTarget,
resolveSessionTranscriptRuntimeTarget,
trimSessionTranscriptForManualCompact,
@@ -1476,44 +1475,6 @@ 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",

View File

@@ -117,17 +117,27 @@ export type SessionAccessScope = {
storePath?: string;
};
export type SessionTranscriptAccessScope = Omit<SessionAccessScope, "sessionKey"> & {
export type SessionTranscriptReadScope = Omit<SessionAccessScope, "sessionKey"> & {
/** Explicit transcript file path; bypasses store lookup when already known. */
sessionFile?: string;
/** Runtime session id used to derive a transcript file when no explicit file is provided. */
sessionId: string;
/** Required when resolving through session metadata; optional for explicit transcript artifacts. */
/** Optional key for read callers that can resolve via the session entry. */
sessionKey?: string;
/** Channel thread suffix used when deriving topic transcript paths. */
threadId?: string | number;
};
export type SessionTranscriptAccessScope = SessionTranscriptReadScope & {
/**
* Identifies the owning entry when the transcript target must be resolved
* (and possibly persisted) through the session store. May be omitted only
* when an explicit sessionFile binds the operation to a concrete artifact;
* such writes never read or update entry metadata.
*/
sessionKey?: string;
};
export type SessionTranscriptRuntimeScope = SessionAccessScope & {
/** Resolved file-backed artifact for the current runtime target. */
sessionFile?: string;
@@ -135,21 +145,6 @@ export type SessionTranscriptRuntimeScope = SessionAccessScope & {
threadId?: string | number;
};
export type SessionTranscriptReadScope = Omit<SessionTranscriptRuntimeScope, "sessionKey"> & {
/** Canonical key when the caller has a session-store identity for this read. */
sessionKey?: string;
/** Entry already loaded by hot callers; avoids rereading the session store. */
sessionEntry?: Pick<SessionEntry, "sessionFile"> & Partial<Pick<SessionEntry, "sessionId">>;
};
export type SessionTranscriptReadTarget = Omit<
SessionTranscriptRuntimeTarget,
"agentId" | "sessionKey"
> & {
agentId?: string;
sessionKey?: string;
};
export type SessionTranscriptWriteScope = Omit<SessionTranscriptAccessScope, "sessionId"> & {
/** Optional for appenders that can operate on an existing explicit transcript target. */
sessionId?: string;
@@ -851,7 +846,7 @@ export async function persistSessionRolloverLifecycle(params: {
/** Reads parsed transcript records from an explicit or derived transcript target. */
export async function loadTranscriptEvents(
scope: SessionTranscriptAccessScope,
scope: SessionTranscriptReadScope,
): Promise<TranscriptEvent[]> {
const transcript = await resolveTranscriptReadAccess(scope);
const events: TranscriptEvent[] = [];
@@ -1484,82 +1479,6 @@ export async function resolveSessionTranscriptRuntimeReadTarget(
};
}
/**
* Resolves the current file-backed target for read-only transcript callers.
* Unlike writer/runtime resolution, this does not persist missing sessionFile
* metadata; reader projections must not mutate session metadata.
*/
export function resolveSessionTranscriptReadTarget(
scope: SessionTranscriptReadScope,
): SessionTranscriptReadTarget {
const explicitSessionFile = scope.sessionFile?.trim();
if (explicitSessionFile) {
return {
sessionFile: explicitSessionFile,
sessionId: scope.sessionId,
...(scope.agentId ? { agentId: scope.agentId } : {}),
...(scope.sessionKey ? { sessionKey: scope.sessionKey } : {}),
};
}
const agentId = scope.agentId ?? resolveAgentIdFromSessionKey(scope.sessionKey);
if (!agentId) {
throw new Error(`Cannot resolve transcript scope without an agent id: ${scope.sessionKey}`);
}
const storePath = resolveConcreteReadStorePath(scope.storePath);
const resolvedStoreEntry =
scope.sessionEntry || !scope.sessionKey
? undefined
: storePath
? resolveSessionStoreEntry({
store: loadSessionStore(storePath, { skipCache: true }),
sessionKey: scope.sessionKey,
})
: undefined;
const sessionEntry =
scope.sessionEntry ??
resolvedStoreEntry?.existing ??
(scope.sessionKey ? loadSessionEntry({ ...scope, sessionKey: scope.sessionKey }) : undefined);
const sessionKey = resolvedStoreEntry?.normalizedKey ?? scope.sessionKey;
const matchingSessionEntry =
sessionEntry?.sessionId === undefined || sessionEntry.sessionId === scope.sessionId
? sessionEntry
: undefined;
const threadId =
scope.threadId ?? (sessionKey ? parseSessionThreadInfo(sessionKey).threadId : undefined);
const sessionFile = matchingSessionEntry?.sessionFile
? resolveSessionFilePath(
scope.sessionId,
matchingSessionEntry,
resolveSessionFilePathOptions({
agentId,
...(storePath ? { storePath } : {}),
}),
)
: storePath
? resolveSessionTranscriptPathInDir(
// File-backed readers derive beside sessions.json only for the JSON-store
// deprecation window; the SQLite flip resolves from canonical metadata.
scope.sessionId,
path.dirname(path.resolve(storePath)),
threadId,
)
: resolveSessionTranscriptPath(scope.sessionId, agentId, threadId);
return {
agentId,
sessionFile,
sessionId: scope.sessionId,
...(sessionKey ? { sessionKey } : {}),
};
}
function resolveConcreteReadStorePath(storePath: string | undefined): string | undefined {
const trimmed = storePath?.trim();
if (!trimmed || trimmed === "(multiple)" || trimmed.includes("{agentId}")) {
return undefined;
}
return trimmed;
}
function createFallbackSessionEntry(patch: Partial<SessionEntry>): SessionEntry {
const now = Date.now();
return {
@@ -1636,7 +1555,7 @@ function resolveAccessStorePath(scope: SessionAccessScope): string {
});
}
async function resolveTranscriptReadAccess(scope: SessionTranscriptAccessScope): Promise<{
async function resolveTranscriptReadAccess(scope: SessionTranscriptReadScope): Promise<{
sessionFile: string;
}> {
if (scope.sessionFile?.trim()) {

View File

@@ -6,10 +6,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerBundledHealthChecks } from "./bundled-health-checks.js";
const mocks = vi.hoisted(() => ({
registerFeedsDoctorChecks: vi.fn(),
registerPolicyDoctorChecks: vi.fn(),
loadBundledPluginPublicArtifactModuleSync: vi.fn(() => ({
registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks,
})),
loadBundledPluginPublicArtifactModuleSync: vi.fn((params: { dirName: string }) =>
params.dirName === "feeds"
? {
registerFeedsDoctorChecks: mocks.registerFeedsDoctorChecks,
}
: {
registerPolicyDoctorChecks: mocks.registerPolicyDoctorChecks,
},
),
}));
vi.mock("../plugins/public-surface-loader.js", () => ({
@@ -35,6 +42,21 @@ describe("registerBundledHealthChecks", () => {
expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
});
it("loads bundled feeds health checks when feeds extension is enabled", () => {
registerBundledHealthChecks({
cfg: { plugins: { entries: { feeds: { enabled: true } } } },
cwd: workspaceDir,
});
expect(mocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith({
dirName: "feeds",
artifactBasename: "api.js",
});
expect(mocks.registerFeedsDoctorChecks).toHaveBeenCalledWith({
registerHealthCheck: expect.any(Function),
});
});
it("loads bundled policy health checks when policy extension is enabled", () => {
registerBundledHealthChecks({
cfg: { plugins: { entries: { policy: { enabled: true } } } },
@@ -67,6 +89,15 @@ describe("registerBundledHealthChecks", () => {
expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
});
it("honors explicit feeds disablement", () => {
registerBundledHealthChecks({
cfg: { plugins: { entries: { feeds: { enabled: true, config: { enabled: false } } } } },
cwd: workspaceDir,
});
expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
});
it("honors plugin control-plane disablement for policy checks", () => {
for (const plugins of [
{ enabled: false, entries: { policy: { enabled: true } } },
@@ -80,4 +111,18 @@ describe("registerBundledHealthChecks", () => {
expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
}
});
it("honors plugin control-plane disablement for feeds checks", () => {
for (const plugins of [
{ enabled: false, entries: { feeds: { enabled: true } } },
{ deny: ["feeds"], entries: { feeds: { enabled: true } } },
{ allow: ["telegram"], entries: { feeds: { enabled: true } } },
]) {
vi.clearAllMocks();
registerBundledHealthChecks({ cfg: { plugins }, cwd: workspaceDir });
expect(mocks.loadBundledPluginPublicArtifactModuleSync).not.toHaveBeenCalled();
}
});
});

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