Compare commits

..

4 Commits

Author SHA1 Message Date
snowzlmbot
a8904988e1 fix(onboard): refresh provider plugin registry after setup installs (#95792)
Merged via squash.

Prepared head SHA: c99d09f762
Co-authored-by: snowzlmbot <293528334+snowzlmbot@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete

(cherry picked from commit 604d607311)
2026-06-23 08:23:09 +08:00
Vincent Koc
871ba5ad82 fix(agents): bound channel context env 2026-06-23 08:11:33 +08:00
Lanzhi
be5b8434cd feat: pass channel context to exec 2026-06-22 16:59:16 +08:00
Lanzhi
1620d052cf feat(plugin-sdk): add extensible channel identity hook context 2026-06-22 16:59:16 +08:00
282 changed files with 2361 additions and 5999 deletions

View File

@@ -4,14 +4,6 @@ set -euo pipefail
repo="openclaw/openclaw"
months="12"
include_global="0"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(git -C "$script_dir/../../../.." rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$repo_root" ]; then
repo_root="$(cd "$script_dir/../../../.." && pwd)"
fi
# shellcheck disable=SC1091
source "$repo_root/scripts/lib/plain-gh.sh"
usage() {
printf 'Usage: %s [--repo owner/repo] [--months N] [--global] <github-login> [login...]\n' "$0"
@@ -26,10 +18,6 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
gh() {
gh_plain "$@"
}
date_utc_relative_months() {
local count="$1"
if date -u -v-"${count}"m +%Y-%m-%dT00:00:00Z >/dev/null 2>&1; then
@@ -143,8 +131,7 @@ done
exit 2
}
OPENCLAW_GH_BIN="$(resolve_plain_gh_bin)" || die "missing required command: gh"
export OPENCLAW_GH_BIN
need gh
need jq
since_ts=$(date_utc_relative_months "$months")

View File

@@ -4,12 +4,12 @@
* Usage: node secret-scanning.mjs <command> [options]
*/
import { spawnSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { spawnPlainGh } from "../../../../scripts/lib/plain-gh.mjs";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -29,7 +29,7 @@ function tmpFile(purpose) {
}
function gh(args, { json = true, allowFailure = false } = {}) {
const proc = spawnPlainGh(args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
const proc = spawnSync("gh", args, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 });
if (proc.status !== 0 && !allowFailure) {
fail(`gh ${args.slice(0, 3).join(" ")} failed:\n${(proc.stderr || proc.stdout || "").trim()}`);
}

View File

@@ -5,7 +5,6 @@
*/
import { execFileSync } from "node:child_process";
import process from "node:process";
import { plainGhEnv, resolvePlainGhBin } from "../../../../scripts/lib/plain-gh.mjs";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
@@ -16,9 +15,8 @@ if (!runId) {
}
function gh(args) {
return execFileSync(resolvePlainGhBin(), args, {
return execFileSync("gh", args, {
encoding: "utf8",
env: plainGhEnv(),
stdio: ["ignore", "pipe", "pipe"],
});
}
@@ -34,15 +32,14 @@ function githubRestJson(pathSuffix) {
"-lc",
[
"set -euo pipefail",
'token="$("$OPENCLAW_PLAIN_GH_BIN" auth token)"',
'token="$(gh auth token)"',
'curl -fsS -H "Authorization: Bearer ${token}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${OPENCLAW_GITHUB_REST_URL}"',
].join("\n"),
],
{
encoding: "utf8",
env: {
...plainGhEnv(),
OPENCLAW_PLAIN_GH_BIN: resolvePlainGhBin(),
...process.env,
OPENCLAW_GITHUB_REST_URL: `https://api.github.com/repos/${repo}/${pathSuffix}`,
},
maxBuffer: 16 * 1024 * 1024,

View File

@@ -41,32 +41,11 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# Keep the canonical main queue quiet long enough for a follow-up push to
# cancel this run before it registers the Blacksmith matrix.
runner-admission:
permissions:
contents: read
runs-on: ubuntu-24.04
timeout-minutes: 3
env:
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
steps:
- name: Debounce canonical main pushes
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
run: |
set -euo pipefail
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
- name: Admit non-main CI runs immediately
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
run: echo "No canonical main debounce required"
# Preflight: establish routing truth and job matrices once, then let real
# work fan out from a single source of truth.
preflight:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -293,22 +272,18 @@ jobs:
}
}
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
const nodeTestShards = runNodeFull
? createNodeTestShardBundles({
includeReleaseOnlyPluginShards: false,
compact: compactPullRequest,
}).map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
timeout_minutes: shard.timeoutMinutes,
}))
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
@@ -386,7 +361,6 @@ jobs:
security-fast:
permissions:
contents: read
needs: [runner-admission]
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
timeout-minutes: 20
@@ -852,7 +826,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
@@ -942,7 +916,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
steps:
- name: Checkout
@@ -1023,7 +997,7 @@ jobs:
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
@@ -1173,10 +1147,10 @@ jobs:
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 12
max-parallel: 6
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
@@ -1235,7 +1209,6 @@ jobs:
- name: Run Node test shard
env:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
@@ -1250,47 +1223,28 @@ jobs:
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
const plans = Array.isArray(groups) && groups.length > 0
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
}];
for (const plan of plans) {
const configs = plan.configs;
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const childEnv = {
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
writeFileSync(includeFile, JSON.stringify(plan.includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
} else {
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
}
const result = spawnSync(
"pnpm",
["exec", "node", "scripts/test-projects.mjs", ...configs],
{
env: childEnv,
stdio: "inherit",
},
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
const childEnv = { ...process.env };
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
env: childEnv,
stdio: "inherit",
});
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
EOF
@@ -1305,7 +1259,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix:
include:
- check_name: check-guards
@@ -1447,7 +1401,7 @@ jobs:
timeout-minutes: 20
strategy:
fail-fast: false
max-parallel: 8
max-parallel: 4
matrix:
include:
- check_name: check-additional-boundaries-a

View File

@@ -244,11 +244,6 @@ jobs:
exit 1
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides." >&2
exit 1
fi

View File

@@ -1,2 +1,2 @@
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl
b03809275ae18fd5c843335a82304eb6e52905847406ae24a8b0b591205e9fc5 plugin-sdk-api-baseline.json
1f94e05ce0565d2a4fd8797ebde3366060eac1606fdd88b67a60c37e865e312b plugin-sdk-api-baseline.jsonl

View File

@@ -63,9 +63,9 @@ If `plugins.allow` is a non-empty restrictive list, explicitly selecting
ClickClack in channel setup or running `openclaw plugins enable clickclack`
appends `clickclack` to that list. Onboarding installation uses the same
explicit-selection behavior. These paths do not override `plugins.deny` or a
global `plugins.enabled: false` setting. Direct
`openclaw plugins install @openclaw/clickclack` follows the normal
plugin-install policy and also records ClickClack in an existing allowlist.
global `plugins.enabled: false` setting. Direct `openclaw plugins install
clickclack` follows the normal plugin-install policy and also records ClickClack
in an existing allowlist.
## Multiple bots

View File

@@ -42,7 +42,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Raft](/channels/raft) - Raft CLI wake bridge for human and agent collaboration (external plugin).
- [Signal](/channels/signal) - signal-cli; privacy-focused.
- [Slack](/channels/slack) - Bolt SDK; workspace apps.
- [SMS](/channels/sms) - Twilio-backed SMS through the Gateway webhook (official plugin).
- [SMS](/channels/sms) - Twilio-backed SMS through the Gateway webhook (bundled plugin).
- [Synology Chat](/channels/synology-chat) - Synology NAS Chat via outgoing+incoming webhooks (bundled plugin).
- [Telegram](/channels/telegram) - Bot API via grammY; supports groups.
- [Tlon](/channels/tlon) - Urbit-based messenger (bundled plugin).

View File

@@ -7,18 +7,12 @@ read_when:
---
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
Install the official IRC plugin, then configure it under `channels.irc`.
IRC ships as a bundled plugin, but it is configured in the main config under `channels.irc`.
## Quick start
1. Install the plugin:
```bash
openclaw plugins install @openclaw/irc
```
2. Enable IRC config in `~/.openclaw/openclaw.json`.
3. Set at least:
1. Enable IRC config in `~/.openclaw/openclaw.json`.
2. Set at least:
```json5
{
@@ -37,7 +31,7 @@ openclaw plugins install @openclaw/irc
Prefer a private IRC server for bot coordination. If you intentionally use a public IRC network, common choices include Libera.Chat, OFTC, and Snoonet. Avoid predictable public channels for bot or swarm backchannel traffic.
4. Start/restart gateway:
3. Start/restart gateway:
```bash
openclaw gateway run

View File

@@ -389,7 +389,7 @@ If the homeserver requires UIA to upload cross-signing keys, OpenClaw tries no-a
Useful flags:
- `--recovery-key-stdin` (pair with `printf '%s\n' "$MATRIX_RECOVERY_KEY" | …`) or `--recovery-key <key>`
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only; requires the active recovery key to be stored or supplied with `--recovery-key-stdin`)
- `--force-reset-cross-signing` to discard the current cross-signing identity (intentional only)
### Room-key backup

View File

@@ -32,7 +32,7 @@ Details: [Plugins](/tools/plugin)
<Steps>
<Step title="Ensure plugin is available">
Install `@openclaw/mattermost` with the command above, then restart the Gateway if it is already running.
Current packaged OpenClaw releases already bundle it. Older/custom installs can add it manually with the commands above.
</Step>
<Step title="Create a Mattermost bot">
Create a Mattermost bot account and copy the **bot token**.

View File

@@ -20,18 +20,12 @@ Status: external CLI integration. Gateway talks to `signal-cli` over HTTP — ei
## Quick setup (beginner)
1. Use a **separate Signal number** for the bot (recommended).
2. Install the OpenClaw plugin:
```bash
openclaw plugins install @openclaw/signal
```
3. Install `signal-cli` (Java required if you use the JVM build).
4. Choose one setup path:
2. Install `signal-cli` (Java required if you use the JVM build).
3. Choose one setup path:
- **Path A (QR link):** `signal-cli link -n "OpenClaw"` and scan with Signal.
- **Path B (SMS register):** register a dedicated number with captcha + SMS verification.
5. Configure OpenClaw and restart the gateway.
6. Send a first DM and approve pairing (`openclaw pairing approve signal <CODE>`).
4. Configure OpenClaw and restart the gateway.
5. Send a first DM and approve pairing (`openclaw pairing approve signal <CODE>`).
Minimal config:

View File

@@ -24,7 +24,6 @@ OpenClaw can receive and send SMS through a Twilio phone number or Messaging Ser
You need:
- The official SMS plugin installed with `openclaw plugins install @openclaw/sms`.
- A Twilio account with an SMS-capable phone number, or a Twilio Messaging Service.
- The Twilio Account SID and Auth Token.
- A public HTTPS URL that reaches your OpenClaw Gateway.
@@ -35,11 +34,6 @@ Use one Twilio number for both SMS and Voice Call if the number has both capabil
## Quick Setup
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/sms
```
</Step>
<Step title="Create or choose a Twilio sender">
In Twilio, open **Phone Numbers > Manage > Active numbers** and choose an SMS-capable number. Save:

View File

@@ -8,51 +8,38 @@ read_when:
- You are changing ClawSweeper dispatch or GitHub activity forwarding
---
OpenClaw CI runs on every push to `main` and every pull request. Canonical
`main` pushes first pass through a 90-second hosted-runner admission window.
The existing `CI` concurrency group cancels that waiting run when a newer
commit lands, so sequential merges do not each register a full Blacksmith
matrix. Pull requests and manual dispatches skip the wait. The `preflight` job
then classifies the diff and turns expensive lanes off when only unrelated
areas changed. Manual `workflow_dispatch` runs intentionally bypass smart
scoping and fan out the full graph for release candidates and broad
validation. Android lanes stay opt-in through `include_android`. Release-only
plugin coverage lives in the separate [`Plugin Prerelease`](#plugin-prerelease)
workflow and only runs from [`Full Release Validation`](#full-release-validation)
or an explicit manual dispatch.
OpenClaw CI runs on every push to `main` and every pull request. The `preflight` job classifies the diff and turns expensive lanes off when only unrelated areas changed. Manual `workflow_dispatch` runs intentionally bypass smart scoping and fan out the full graph for release candidates and broad validation. Android lanes stay opt-in through `include_android`. Release-only plugin coverage lives in the separate [`Plugin Prerelease`](#plugin-prerelease) workflow and only runs from [`Full Release Validation`](#full-release-validation) or an explicit manual dispatch.
## Pipeline overview
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `runner-admission` | Hosted 90-second debounce for canonical `main` pushes before Blacksmith work is registered | Every CI run; sleep only on canonical `main` pushes |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
| `checks-fast-contracts-plugins-*` | Two sharded plugin contract checks | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two sharded channel contract checks | Node-relevant changes |
| `checks-node-core-*` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `check-*` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional-*` | Architecture, sharded boundary/prompt drift, extension guards, package boundary, and runtime topology | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
| Job | Purpose | When it runs |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
| `checks-fast-contracts-plugins-*` | Two sharded plugin contract checks | Node-relevant changes |
| `checks-fast-contracts-channels-*` | Two sharded channel contract checks | Node-relevant changes |
| `checks-node-core-*` | Core Node test shards, excluding channel, bundled, contract, and extension lanes | Node-relevant changes |
| `check-*` | Sharded main local gate equivalent: prod types, lint, guards, test types, and strict smoke | Node-relevant changes |
| `check-additional-*` | Architecture, sharded boundary/prompt drift, extension guards, package boundary, and runtime topology | Node-relevant changes |
| `checks-node-compat-node22` | Node 22 compatibility build and smoke lane | Manual CI dispatch for releases |
| `check-docs` | Docs formatting, lint, and broken-link checks | Docs changed |
| `skills-python` | Ruff + pytest for Python-backed skills | Python-skill-relevant changes |
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
## Fail-fast order
1. `runner-admission` waits only for canonical `main` pushes; a newer push cancels the run before Blacksmith registration.
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
4. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
1. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
2. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
3. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
4. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
@@ -87,15 +74,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Pull requests on the canonical repository use an additional compact admission plan: the same per-config groups run in isolated subprocesses inside the current 34-job Linux Node plan, so a single PR does not register the full 70-plus-job Node matrix. `main` pushes, manual dispatches, and release gates retain the full matrix. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Once admitted, canonical Linux CI permits up to 12 concurrent Node jobs and 8 for
the smaller fast/check lanes; Windows and Android stay at two because those
runner pools are narrower.
The compact PR plan emits 18 Node jobs for the current suite: whole-config
groups are batched in isolated subprocesses with a 120-minute batch timeout,
while include-pattern groups share the same bounded job budget.
The slowest Node test families are split or balanced so each job stays small without over-reserving runners: plugin contracts and channel contracts each run as two weighted Blacksmith-backed shards with the standard GitHub runner fallback, core unit fast/support lanes run separately, core runtime infra is split between state, process/config, shared, and three cron domain shards, auto-reply runs as balanced workers (with the reply subtree split into agent-runner, dispatch, and commands/state-routing shards), and agentic gateway/server configs are split across chat/auth/model/http-plugin/runtime/startup lanes instead of waiting on built artifacts. Normal CI then packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites; heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU. Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard. `check-additional-*` keeps package-boundary compile/canary work together and separates runtime topology architecture from gateway watch coverage; the boundary guard list is striped into one prompt-heavy shard and one combined shard for the remaining guard stripes, each running selected independent guards concurrently and printing per-check timings. The expensive Codex happy-path prompt snapshot drift check runs as its own additional job for manual CI and for prompt-affecting changes only, so normal unrelated Node changes do not wait behind cold prompt snapshot generation and the boundary shards stay balanced while prompt drift is still pinned to the PR that caused it; the same flag skips prompt snapshot Vitest generation inside the built-artifact core support-boundary shard. Gateway watch, channel tests, and the core support-boundary shard run concurrently inside `build-artifacts` after `dist/` and `dist-runtime/` are already built.
Android CI runs both `testPlayDebugUnitTest` and `testThirdPartyDebugUnitTest` and then builds the Play debug APK. The third-party flavor has no separate source set or manifest; its unit-test lane still compiles the flavor with the SMS/call-log BuildConfig flags, while avoiding a duplicate debug APK packaging job on every Android-relevant push.

View File

@@ -333,7 +333,7 @@ Gateway model capability checks also read explicit `models.providers.<id>.models
### Moonshot AI (Kimi)
Install `@openclaw/moonshot-provider` before onboarding. Add an explicit `models.providers.moonshot` entry only when you need to override the base URL or model metadata:
Moonshot ships as a bundled provider plugin. Use the built-in provider by default, and add an explicit `models.providers.moonshot` entry only when you need to override the base URL or model metadata:
- Provider: `moonshot`
- Auth: `MOONSHOT_API_KEY`

View File

@@ -319,10 +319,56 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
plugin hooks can scope metrics, side effects, or state to a specific scheduled
job.
For channel-originated runs, `ctx.messageProvider` is the provider surface such
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
identifier when OpenClaw can derive one from the session key or delivery
metadata.
For channel-originated runs, `ctx.channel` and `ctx.messageProvider` identify
the provider surface such as `discord` or `telegram`, while `ctx.channelId` is
the conversation target identifier when OpenClaw can derive one from the session
key or delivery metadata.
When sender identity is available, agent hook contexts also include:
- `ctx.senderId` — channel-scoped sender ID (e.g. Feishu `open_id`, Discord
user ID). Populated when the run originates from a user message with known
sender metadata.
- `ctx.chatId` — transport-native conversation identifier (e.g. Feishu
`chat_id`, Telegram `chat_id`). Populated when the originating channel
provides a native conversation ID.
- `ctx.channelContext.sender.id` — the same sender ID as `ctx.senderId`, under a
channel-owned object that plugins can extend with channel-specific fields.
- `ctx.channelContext.chat.id` — the same conversation ID as `ctx.chatId`, under a
channel-owned object that plugins can extend with channel-specific fields.
Core only defines the nested `id` fields. Channel plugins that pass richer
sender or chat metadata through the inbound helper can augment
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from
`openclaw/plugin-sdk/channel-inbound`:
```ts
declare module "openclaw/plugin-sdk/channel-inbound" {
interface PluginHookChannelSenderContext {
unionId?: string;
userId?: string;
}
}
```
Channel plugins pass those fields through the inbound SDK helper:
```ts
buildChannelInboundEventContext({
// ...
channelContext: {
sender: { id: senderOpenId, unionId, userId },
chat: { id: chatId },
},
});
```
These fields are optional and absent for system-originated runs (heartbeat,
cron, exec-event).
`ctx.senderExternalId` remains as a deprecated source-compatibility field for
older plugins. Core does not populate it; new channel-specific sender identities
should live under `ctx.channelContext.sender` through module augmentation.
`agent_end` is an observation hook. Gateway and persistent harness paths run it
fire-and-forget after the turn, while short-lived one-shot CLI paths wait for the

View File

@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
59 plugins
72 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -69,6 +69,8 @@ Each entry lists the package, distribution route, and description.
- **[canvas](/plugins/reference/canvas)** (`@openclaw/canvas-plugin`) - included in OpenClaw. Experimental Canvas control and A2UI rendering surfaces for paired nodes.
- **[clickclack](/plugins/reference/clickclack)** (`@openclaw/clickclack`) - included in OpenClaw. Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/cohere-provider`. OpenClaw Cohere provider plugin.
@@ -89,6 +91,8 @@ Each entry lists the package, distribution route, and description.
- **[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.
- **[github-copilot](/plugins/reference/github-copilot)** (`@openclaw/github-copilot-provider`) - included in OpenClaw. Adds GitHub Copilot model provider support to OpenClaw.
- **[google](/plugins/reference/google)** (`@openclaw/google-plugin`) - included in OpenClaw. Adds Google, Google Gemini CLI, Google Vertex model provider support to OpenClaw.
@@ -97,12 +101,16 @@ Each entry lists the package, distribution route, and description.
- **[imessage](/plugins/reference/imessage)** (`@openclaw/imessage`) - included in OpenClaw. Adds the iMessage channel surface for sending and receiving OpenClaw messages.
- **[irc](/plugins/reference/irc)** (`@openclaw/irc`) - included in OpenClaw. Adds the IRC channel surface for sending and receiving OpenClaw messages.
- **[litellm](/plugins/reference/litellm)** (`@openclaw/litellm-provider`) - included in OpenClaw. Adds LiteLLM model provider support to OpenClaw.
- **[llm-task](/plugins/reference/llm-task)** (`@openclaw/llm-task`) - included in OpenClaw. Generic JSON-only LLM tool for structured tasks callable from workflows.
- **[lmstudio](/plugins/reference/lmstudio)** (`@openclaw/lmstudio-provider`) - included in OpenClaw. Adds LM Studio model provider support to OpenClaw.
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - included in OpenClaw. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds agent-callable tools.
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
@@ -119,6 +127,8 @@ Each entry lists the package, distribution route, and description.
- **[mistral](/plugins/reference/mistral)** (`@openclaw/mistral-provider`) - included in OpenClaw. Adds Mistral model provider support to OpenClaw.
- **[moonshot](/plugins/reference/moonshot)** (`@openclaw/moonshot-provider`) - included in OpenClaw. Adds Moonshot model provider support to OpenClaw.
- **[novita](/plugins/reference/novita)** (`@openclaw/novita-provider`) - included in OpenClaw. Adds Novita, Novita AI, Novitaai model provider support to OpenClaw.
- **[nvidia](/plugins/reference/nvidia)** (`@openclaw/nvidia-provider`) - included in OpenClaw. Adds NVIDIA model provider support to OpenClaw.
@@ -141,18 +151,32 @@ Each entry lists the package, distribution route, and description.
- **[runway](/plugins/reference/runway)** (`@openclaw/runway-provider`) - included in OpenClaw. Adds video generation provider support.
- **[searxng](/plugins/reference/searxng)** (`@openclaw/searxng-plugin`) - included in OpenClaw. Adds web search provider support.
- **[senseaudio](/plugins/reference/senseaudio)** (`@openclaw/senseaudio-provider`) - included in OpenClaw. Adds media understanding provider support.
- **[sglang](/plugins/reference/sglang)** (`@openclaw/sglang-provider`) - included in OpenClaw. Adds SGLang model provider support to OpenClaw.
- **[signal](/plugins/reference/signal)** (`@openclaw/signal`) - included in OpenClaw. Adds the Signal channel surface for sending and receiving OpenClaw messages.
- **[sms](/plugins/reference/sms)** (`@openclaw/sms`) - included in OpenClaw. Twilio SMS channel plugin for OpenClaw text messages.
- **[synthetic](/plugins/reference/synthetic)** (`@openclaw/synthetic-provider`) - included in OpenClaw. Adds Synthetic model provider support to OpenClaw.
- **[tavily](/plugins/reference/tavily)** (`@openclaw/tavily-plugin`) - included in OpenClaw. Adds agent-callable tools. Adds web search provider support.
- **[telegram](/plugins/reference/telegram)** (`@openclaw/telegram`) - included in OpenClaw. Adds the Telegram channel surface for sending and receiving OpenClaw messages.
- **[tencent](/plugins/reference/tencent)** (`@openclaw/tencent-provider`) - included in OpenClaw. Adds Tencent TokenHub model provider support to OpenClaw.
- **[together](/plugins/reference/together)** (`@openclaw/together-provider`) - included in OpenClaw. Adds Together model provider support to OpenClaw.
- **[tts-local-cli](/plugins/reference/tts-local-cli)** (`@openclaw/tts-local-cli`) - included in OpenClaw. Adds text-to-speech provider support.
- **[venice](/plugins/reference/venice)** (`@openclaw/venice-provider`) - included in OpenClaw. Adds Venice model provider support to OpenClaw.
- **[vercel-ai-gateway](/plugins/reference/vercel-ai-gateway)** (`@openclaw/vercel-ai-gateway-provider`) - included in OpenClaw. Adds Vercel AI Gateway model provider support to OpenClaw.
- **[vllm](/plugins/reference/vllm)** (`@openclaw/vllm-provider`) - included in OpenClaw. Adds vLLM model provider support to OpenClaw.
- **[volcengine](/plugins/reference/volcengine)** (`@openclaw/volcengine-provider`) - included in OpenClaw. Adds Volcengine, Volcengine Plan model provider support to OpenClaw.
@@ -171,9 +195,11 @@ Each entry lists the package, distribution route, and description.
- **[xiaomi](/plugins/reference/xiaomi)** (`@openclaw/xiaomi-provider`) - included in OpenClaw. Adds Xiaomi, Xiaomi Token Plan model provider support to OpenClaw.
- **[zai](/plugins/reference/zai)** (`@openclaw/zai-provider`) - included in OpenClaw. Adds Z.AI model provider support to OpenClaw.
## Official external packages
68 plugins
55 plugins
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
@@ -191,8 +217,6 @@ Each entry lists the package, distribution route, and description.
- **[chutes](/plugins/reference/chutes)** (`@openclaw/chutes-provider`) - npm; ClawHub: `clawhub:@openclaw/chutes-provider`. Adds Chutes model provider support to OpenClaw.
- **[clickclack](/plugins/reference/clickclack)** (`@openclaw/clickclack`) - npm; ClawHub: `clawhub:@openclaw/clickclack`. Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
- **[cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway)** (`@openclaw/cloudflare-ai-gateway-provider`) - npm; ClawHub: `clawhub:@openclaw/cloudflare-ai-gateway-provider`. Adds Cloudflare AI Gateway model provider support to OpenClaw.
- **[codex](/plugins/reference/codex)** (`@openclaw/codex`) - npm; ClawHub. OpenClaw Codex app-server harness and model provider plugin with a Codex-managed GPT catalog.
@@ -219,8 +243,6 @@ Each entry lists the package, distribution route, and description.
- **[firecrawl](/plugins/reference/firecrawl)** (`@openclaw/firecrawl-plugin`) - npm; ClawHub: `clawhub:@openclaw/firecrawl-plugin`. Adds agent-callable tools. Adds web fetch provider support. Adds web search provider support.
- **[fireworks](/plugins/reference/fireworks)** (`@openclaw/fireworks-provider`) - npm; ClawHub: `clawhub:@openclaw/fireworks-provider`. Adds Fireworks model provider support to OpenClaw.
- **[gmi](/plugins/reference/gmi)** (`@openclaw/gmi-provider`) - npm; ClawHub: `clawhub:@openclaw/gmi-provider`. OpenClaw GMI Cloud provider plugin.
- **[google-meet](/plugins/reference/google-meet)** (`@openclaw/google-meet`) - npm; ClawHub. OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.
@@ -233,8 +255,6 @@ Each entry lists the package, distribution route, and description.
- **[inworld](/plugins/reference/inworld)** (`@openclaw/inworld-speech`) - npm; ClawHub: `clawhub:@openclaw/inworld-speech`. Inworld streaming text-to-speech (MP3, OGG_OPUS, PCM telephony).
- **[irc](/plugins/reference/irc)** (`@openclaw/irc`) - npm; ClawHub: `clawhub:@openclaw/irc`. Adds the IRC channel surface for sending and receiving OpenClaw messages.
- **[kilocode](/plugins/reference/kilocode)** (`@openclaw/kilocode-provider`) - npm; ClawHub: `clawhub:@openclaw/kilocode-provider`. Adds Kilocode model provider support to OpenClaw.
- **[kimi](/plugins/reference/kimi)** (`@openclaw/kimi-provider`) - npm; ClawHub: `clawhub:@openclaw/kimi-provider`. Adds Kimi, Kimi Coding model provider support to OpenClaw.
@@ -247,12 +267,8 @@ Each entry lists the package, distribution route, and description.
- **[matrix](/plugins/reference/matrix)** (`@openclaw/matrix`) - ClawHub: `clawhub:@openclaw/matrix`; npm. OpenClaw Matrix channel plugin for rooms and direct messages.
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - npm; ClawHub: `clawhub:@openclaw/mattermost`. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
- **[memory-lancedb](/plugins/reference/memory-lancedb)** (`@openclaw/memory-lancedb`) - npm; ClawHub. OpenClaw LanceDB-backed long-term memory plugin with auto-recall, auto-capture, and vector search.
- **[moonshot](/plugins/reference/moonshot)** (`@openclaw/moonshot-provider`) - npm; ClawHub: `clawhub:@openclaw/moonshot-provider`. Adds Moonshot model provider support to OpenClaw.
- **[msteams](/plugins/reference/msteams)** (`@openclaw/msteams`) - npm; ClawHub. OpenClaw Microsoft Teams channel plugin for bot conversations.
- **[nextcloud-talk](/plugins/reference/nextcloud-talk)** (`@openclaw/nextcloud-talk`) - npm; ClawHub. OpenClaw Nextcloud Talk channel plugin for conversations.
@@ -275,38 +291,22 @@ Each entry lists the package, distribution route, and description.
- **[raft](/plugins/reference/raft)** (`@openclaw/raft`) - npm; ClawHub. OpenClaw Raft channel plugin for secure CLI wake bridges.
- **[searxng](/plugins/reference/searxng)** (`@openclaw/searxng-plugin`) - npm; ClawHub: `clawhub:@openclaw/searxng-plugin`. Adds web search provider support.
- **[signal](/plugins/reference/signal)** (`@openclaw/signal`) - npm; ClawHub: `clawhub:@openclaw/signal`. Adds the Signal channel surface for sending and receiving OpenClaw messages.
- **[slack](/plugins/reference/slack)** (`@openclaw/slack`) - npm; ClawHub. OpenClaw Slack channel plugin for channels, DMs, commands, and app events.
- **[sms](/plugins/reference/sms)** (`@openclaw/sms`) - npm; ClawHub: `clawhub:@openclaw/sms`. Twilio SMS channel plugin for OpenClaw text messages.
- **[stepfun](/plugins/reference/stepfun)** (`@openclaw/stepfun-provider`) - npm; ClawHub: `clawhub:@openclaw/stepfun-provider`. Adds StepFun, StepFun Plan model provider support to OpenClaw.
- **[synology-chat](/plugins/reference/synology-chat)** (`@openclaw/synology-chat`) - npm; ClawHub. Synology Chat channel plugin for OpenClaw channels and direct messages.
- **[tavily](/plugins/reference/tavily)** (`@openclaw/tavily-plugin`) - npm; ClawHub: `clawhub:@openclaw/tavily-plugin`. Adds agent-callable tools. Adds web search provider support.
- **[tencent](/plugins/reference/tencent)** (`@openclaw/tencent-provider`) - npm; ClawHub: `clawhub:@openclaw/tencent-provider`. Adds Tencent TokenHub model provider support to OpenClaw.
- **[tlon](/plugins/reference/tlon)** (`@openclaw/tlon`) - npm; ClawHub. OpenClaw Tlon/Urbit channel plugin for chat workflows.
- **[tokenjuice](/plugins/reference/tokenjuice)** (`@openclaw/tokenjuice`) - npm; ClawHub: `clawhub:@openclaw/tokenjuice`. Compacts exec and bash tool results with tokenjuice reducers.
- **[twitch](/plugins/reference/twitch)** (`@openclaw/twitch`) - npm; ClawHub. OpenClaw Twitch channel plugin for chat and moderation workflows.
- **[venice](/plugins/reference/venice)** (`@openclaw/venice-provider`) - npm; ClawHub: `clawhub:@openclaw/venice-provider`. Adds Venice model provider support to OpenClaw.
- **[vercel-ai-gateway](/plugins/reference/vercel-ai-gateway)** (`@openclaw/vercel-ai-gateway-provider`) - npm; ClawHub: `clawhub:@openclaw/vercel-ai-gateway-provider`. Adds Vercel AI Gateway model provider support to OpenClaw.
- **[voice-call](/plugins/reference/voice-call)** (`@openclaw/voice-call`) - npm; ClawHub. OpenClaw voice-call plugin for Twilio, Telnyx, and Plivo phone calls.
- **[whatsapp](/plugins/reference/whatsapp)** (`@openclaw/whatsapp`) - ClawHub: `clawhub:@openclaw/whatsapp`; npm. OpenClaw WhatsApp channel plugin for WhatsApp Web chats.
- **[zai](/plugins/reference/zai)** (`@openclaw/zai-provider`) - npm; ClawHub: `clawhub:@openclaw/zai-provider`. Adds Z.AI model provider support to OpenClaw.
- **[zalo](/plugins/reference/zalo)** (`@openclaw/zalo`) - npm; ClawHub. OpenClaw Zalo channel plugin for bot and webhook chats.
- **[zalouser](/plugins/reference/zalouser)** (`@openclaw/zalouser`) - npm; ClawHub. OpenClaw Zalo Personal Account plugin via native zca-js integration.

View File

@@ -12,7 +12,7 @@ Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/clickclack`
- Install route: npm; ClawHub: `clawhub:@openclaw/clickclack`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Fireworks model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/fireworks-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/fireworks-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds the IRC channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/irc`
- Install route: npm; ClawHub: `clawhub:@openclaw/irc`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/mattermost`
- Install route: npm; ClawHub: `clawhub:@openclaw/mattermost`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Moonshot model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/moonshot-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/moonshot-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds web search provider support.
## Distribution
- Package: `@openclaw/searxng-plugin`
- Install route: npm; ClawHub: `clawhub:@openclaw/searxng-plugin`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds the Signal channel surface for sending and receiving OpenClaw messages.
## Distribution
- Package: `@openclaw/signal`
- Install route: npm; ClawHub: `clawhub:@openclaw/signal`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Twilio SMS channel plugin for OpenClaw text messages.
## Distribution
- Package: `@openclaw/sms`
- Install route: npm; ClawHub: `clawhub:@openclaw/sms`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds agent-callable tools. Adds web search provider support.
## Distribution
- Package: `@openclaw/tavily-plugin`
- Install route: npm; ClawHub: `clawhub:@openclaw/tavily-plugin`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Tencent TokenHub model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/tencent-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/tencent-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Venice model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/venice-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/venice-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Vercel AI Gateway model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/vercel-ai-gateway-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/vercel-ai-gateway-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -12,7 +12,7 @@ Adds Z.AI model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/zai-provider`
- Install route: npm; ClawHub: `clawhub:@openclaw/zai-provider`
- Install route: included in OpenClaw
## Surface

View File

@@ -29,7 +29,10 @@ import {
```
- `buildChannelInboundEventContext(...)`: project normalized channel facts into
the prompt/session context.
the prompt/session context. Use `channelContext` to pass channel-owned
sender/chat metadata through to plugin hook `ctx.channelContext`; augment
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from this
subpath for channel-specific fields.
- `runChannelInboundEvent(...)`: run ingest, classify, preflight, resolve,
record, dispatch, and finalize for one inbound platform event.
- `dispatchChannelInboundReply(...)`: record and dispatch an already assembled

View File

@@ -515,7 +515,7 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, `createPlainTextToolCallCompatWrapper`, and `setQwenChatTemplateThinking(...)`.
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, and `createPlainTextToolCallCompatWrapper`.
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with

View File

@@ -164,7 +164,7 @@ and pairing-path families.
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |

View File

@@ -7,12 +7,12 @@ read_when:
- You are debugging Kimi thinking-off behavior on Fireworks
---
[Fireworks](https://fireworks.ai) exposes open-weight and routed models through an OpenAI-compatible API. Install the official Fireworks provider plugin to use two pre-cataloged Kimi models and any Fireworks model or router id at runtime.
[Fireworks](https://fireworks.ai) exposes open-weight and routed models through an OpenAI-compatible API. OpenClaw includes a bundled Fireworks provider plugin that ships with two pre-cataloged Kimi models and accepts any Fireworks model or router id at runtime.
| Property | Value |
| --------------- | ------------------------------------------------------ |
| Provider id | `fireworks` (alias: `fireworks-ai`) |
| Package | `@openclaw/fireworks-provider` |
| Plugin | bundled, `enabledByDefault: true` |
| Auth env var | `FIREWORKS_API_KEY` |
| Onboarding flag | `--auth-choice fireworks-api-key` |
| Direct CLI flag | `--fireworks-api-key <key>` |
@@ -24,11 +24,6 @@ read_when:
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/fireworks-provider
```
</Step>
<Step title="Set the Fireworks API key">
<CodeGroup>
@@ -113,7 +108,7 @@ OpenClaw accepts any Fireworks model or router id at runtime. Use the exact id s
</Accordion>
<Accordion title="Why thinking is forced off for Kimi">
Fireworks K2.6 returns a 400 if the request carries `reasoning_*` parameters even though Kimi supports thinking through Moonshot's own API. The provider policy (`extensions/fireworks/thinking-policy.ts`) advertises only the `off` thinking level for Kimi model ids, so manual `/think` switches and provider-policy surfaces stay aligned with the runtime contract.
Fireworks K2.6 returns a 400 if the request carries `reasoning_*` parameters even though Kimi supports thinking through Moonshot's own API. The bundled policy (`extensions/fireworks/thinking-policy.ts`) advertises only the `off` thinking level for Kimi model ids, so manual `/think` switches and provider-policy surfaces stay aligned with the runtime contract.
To use Kimi reasoning end-to-end, configure the [Moonshot provider](/providers/moonshot) and route the same model through it.

View File

@@ -30,7 +30,7 @@ Moonshot and Kimi Coding are **separate providers**. Keys are not interchangeabl
[//]: # "moonshot-kimi-k2-ids:end"
Catalog cost estimates for current Moonshot-hosted K2 models use Moonshot's
Bundled cost estimates for current Moonshot-hosted K2 models use Moonshot's
published pay-as-you-go rates: Kimi K2.7 Code is $0.19/MTok cache hit,
$0.95/MTok input, and $4.00/MTok output; Kimi K2.6 is $0.16/MTok cache hit,
$0.95/MTok input, and $4.00/MTok output; Kimi K2.5 is $0.10/MTok cache hit,
@@ -213,11 +213,6 @@ Choose your provider and follow the setup steps.
</Note>
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/kimi-provider
```
</Step>
<Step title="Run onboarding">
```bash
openclaw onboard --auth-choice kimi-code-api-key
@@ -262,7 +257,8 @@ Choose your provider and follow the setup steps.
## Kimi web search
The Moonshot plugin also registers **Kimi** as a `web_search` provider, backed by Moonshot web search.
OpenClaw also ships **Kimi** as a `web_search` provider, backed by Moonshot web
search.
<Steps>
<Step title="Run interactive web search setup">
@@ -409,7 +405,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
capabilities, so compatible custom provider ids targeting the same native
Moonshot hosts inherit the same streaming-usage behavior.
With the catalog K2.6 pricing, streamed usage that includes input, output,
With the bundled K2.6 pricing, streamed usage that includes input, output,
and cache-read tokens is also converted into local estimated USD cost for
`/status`, `/usage full`, `/usage cost`, and transcript-backed session
accounting.

View File

@@ -6,12 +6,12 @@ read_when:
- You need the TokenHub API key setup
---
Install the official Tencent Cloud provider plugin to access Tencent Hy3 preview through the TokenHub endpoint (`tencent-tokenhub`) using an OpenAI-compatible API.
Tencent Cloud ships as a bundled provider plugin in OpenClaw. It gives access to Tencent Hy3 preview through the TokenHub endpoint (`tencent-tokenhub`) using an OpenAI-compatible API.
| Property | Value |
| ---------------- | ----------------------------------------------------- |
| Provider id | `tencent-tokenhub` |
| Package | `@openclaw/tencent-provider` |
| Plugin | bundled, `enabledByDefault: true` |
| Auth env var | `TOKENHUB_API_KEY` |
| Onboarding flag | `--auth-choice tokenhub-api-key` |
| Direct CLI flag | `--tokenhub-api-key <key>` |
@@ -23,11 +23,6 @@ Install the official Tencent Cloud provider plugin to access Tencent Hy3 preview
## Quick start
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/tencent-provider
```
</Step>
<Step title="Create a TokenHub API key">
Create an API key in Tencent Cloud TokenHub. If you choose a limited access scope for the key, include **Hy3 preview** in the allowed models.
</Step>
@@ -83,7 +78,7 @@ Hy3 preview is Tencent Hunyuan's large MoE language model for reasoning, long-co
## Tiered pricing
The provider catalog ships tiered cost metadata that scales with input window length, so cost estimates are populated without manual overrides.
The bundled catalog ships tiered cost metadata that scales with input window length, so cost estimates are populated without manual overrides.
| Input tokens range | Input rate | Output rate | Cache read |
| ------------------ | ---------- | ----------- | ---------- |

View File

@@ -42,11 +42,6 @@ Anonymized models are **not** fully private. Venice strips metadata before forwa
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/venice-provider
```
</Step>
<Step title="Get your API key">
1. Sign up at [venice.ai](https://venice.ai)
2. Go to **Settings > API Keys > Create new key**

View File

@@ -9,13 +9,12 @@ read_when:
The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to
access hundreds of models through a single endpoint.
| Property | Value |
| ------------- | -------------------------------------- |
| Provider | `vercel-ai-gateway` |
| Package | `@openclaw/vercel-ai-gateway-provider` |
| Auth | `AI_GATEWAY_API_KEY` |
| API | Anthropic Messages compatible |
| Model catalog | Auto-discovered via `/v1/models` |
| Property | Value |
| ------------- | -------------------------------- |
| Provider | `vercel-ai-gateway` |
| Auth | `AI_GATEWAY_API_KEY` |
| API | Anthropic Messages compatible |
| Model catalog | Auto-discovered via `/v1/models` |
<Tip>
OpenClaw auto-discovers the Gateway `/v1/models` catalog, so
@@ -27,11 +26,6 @@ OpenClaw auto-discovers the Gateway `/v1/models` catalog, so
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/vercel-ai-gateway-provider
```
</Step>
<Step title="Set the API key">
Run onboarding and choose the AI Gateway auth option:

View File

@@ -13,7 +13,6 @@ OpenClaw uses the `zai` provider with a Z.AI API key.
| Property | Value |
| -------- | -------------------------------------------- |
| Provider | `zai` |
| Package | `@openclaw/zai-provider` |
| Auth | `ZAI_API_KEY` (legacy alias: `Z_AI_API_KEY`) |
| API | Z.AI Chat Completions (Bearer auth) |
@@ -24,12 +23,6 @@ refs such as `zai/glm-5.2`: provider `zai`, model id `glm-5.2`.
## Getting started
Install the provider plugin first:
```bash
openclaw plugins install @openclaw/zai-provider
```
<Tabs>
<Tab title="Auto-detect endpoint">
**Best for:** most users. OpenClaw probes supported Z.AI endpoints with your API key and applies the correct base URL automatically.
@@ -103,7 +96,7 @@ you want to force a specific Coding Plan or general API surface.
## Built-in catalog
The `zai` provider plugin ships its catalog in the plugin manifest, so read-only
OpenClaw ships the bundled `zai` provider catalog in the plugin manifest, so read-only
listing can show known GLM rows without loading provider runtime:
```bash
@@ -150,7 +143,7 @@ known to your installed version.
<AccordionGroup>
<Accordion title="Forward-resolving unknown GLM-5 models">
Unknown `glm-5*` ids still forward-resolve on the provider path by
Unknown `glm-5*` ids still forward-resolve on the bundled provider path by
synthesizing provider-owned metadata from the `glm-4.7` template when the id
matches the current GLM-5 family shape.
</Accordion>
@@ -207,7 +200,7 @@ known to your installed version.
</Accordion>
<Accordion title="Image understanding">
The Z.AI plugin registers image understanding.
The bundled Z.AI plugin registers image understanding.
| Property | Value |
| ------------- | ----------- |

View File

@@ -191,11 +191,10 @@ release state.
closeout requires both assets and a matching checksum. A partial manifest
replays its recorded `main` SHA and rollback drill to regenerate identical
bytes, then attaches the missing checksum; an invalid pair, or a checksum
without a manifest, stays blocking. A push-triggered run without rollback
drill repository variables skips without completing closeout; a missing or
more-than-90-day-old drill record still blocks manual evidence-backed
closeout. Private recovery commands remain in the maintainer-only runbook.
Use manual dispatch only to repair or replay an evidence-backed stable closeout.
without a manifest, stays blocking. A missing or more-than-90-day-old drill
record blocks a new evidence-backed closeout; private recovery commands
remain in the maintainer-only runbook. Use manual dispatch only to repair or
replay an evidence-backed stable closeout.
A legacy fallback correction tag may reuse base-package evidence only when
the correction tag resolves to the same source commit as the base stable tag.
A correction with different source must publish and verify its own package

View File

@@ -92,6 +92,8 @@ Notes:
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
- For channel-origin runs, OpenClaw also exposes a narrow sender/chat identity JSON payload in
`OPENCLAW_CHANNEL_CONTEXT` when the channel provided those ids.
- `openclaw channels login` is blocked from `exec` because it is an interactive channel-auth flow; run it in a terminal on the gateway host, or use the channel-native login tool from chat when one exists.
- Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto`
resolves to `gateway`. Explicit `host=sandbox` still fails closed instead of silently

View File

@@ -20,11 +20,6 @@ Advantages:
## Setup
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/searxng-plugin
```
</Step>
<Step title="Run a SearXNG instance">
```bash
docker run -d -p 8888:8080 searxng/searxng

View File

@@ -15,22 +15,16 @@ title: "Tavily"
Tavily returns structured results optimized for LLM consumption with configurable search depth, topic filtering, domain filters, AI-generated answer summaries, and content extraction from URLs (including JavaScript-rendered pages).
| Property | Value |
| --------- | ----------------------------------- |
| Plugin id | `tavily` |
| Package | `@openclaw/tavily-plugin` |
| Auth | `TAVILY_API_KEY` or config `apiKey` |
| Base URL | `https://api.tavily.com` (default) |
| Tools | `tavily_search`, `tavily_extract` |
| Property | Value |
| ------------- | ----------------------------------- |
| Plugin id | `tavily` |
| Auth | `TAVILY_API_KEY` or config `apiKey` |
| Base URL | `https://api.tavily.com` (default) |
| Bundled tools | `tavily_search`, `tavily_extract` |
## Getting started
<Steps>
<Step title="Install the plugin">
```bash
openclaw plugins install @openclaw/tavily-plugin
```
</Step>
<Step title="Get an API key">
Create a Tavily account at [tavily.com](https://tavily.com), then generate an API key in the dashboard.
</Step>
@@ -66,7 +60,7 @@ Tavily returns structured results optimized for LLM consumption with configurabl
</Steps>
<Tip>
Choosing Tavily in onboarding or `openclaw configure --section web` installs and enables the official Tavily plugin when needed.
Choosing Tavily in onboarding or `openclaw configure --section web` enables the bundled Tavily plugin automatically.
</Tip>
## Tool reference

View File

@@ -844,79 +844,6 @@ describe("active-memory plugin", () => {
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("does not run for dreaming-narrative cron session keys", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:dreaming-narrative-light-abc123",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("does not run when a session id resolves to a dreaming-narrative cron session key", async () => {
hoisted.sessionStore["agent:main:dreaming-narrative-light-abc123"] = {
sessionId: "dreaming-session",
updatedAt: 1,
};
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionId: "dreaming-session",
messageProvider: "webchat",
},
);
expect(result).toBeUndefined();
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("allows non-canonical session keys that merely contain the dreaming-narrative substring", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:webchat:dreaming-narrative-room",
messageProvider: "webchat",
},
);
// Real session keys that happen to contain "dreaming-narrative" in a
// non-canonical way (not {light|rem|deep} phase suffix) must remain eligible.
// The session key "agent:main:webchat:dreaming-narrative-room" is a real chat
// whose topic happens to contain the string — not a dreaming cron key.
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
expect(result).not.toBeUndefined();
});
it("allows real webchat session keys whose peer id starts with a phased dreaming-narrative prefix", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should i order?", messages: [] },
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:webchat:dreaming-narrative-light-room",
messageProvider: "webchat",
},
);
// A real webchat session key whose peer id begins with a phased dreaming-narrative
// phrase must not be excluded. Only the canonical bare or agent-prefixed key
// shape (dreaming-narrative-<phase>-<hash> directly after agentId or bare) should
// be rejected — not the same phrase appearing deeper in the key as a peer id.
expect(runEmbeddedAgent).toHaveBeenCalledTimes(1);
expect(result).not.toBeUndefined();
});
it("defaults to direct-style sessions only", async () => {
const result = await hooks.before_prompt_build(
{ prompt: "what wings should we order?", messages: [] },

View File

@@ -1155,19 +1155,6 @@ function isEligibleInteractiveSession(ctx: {
if (ctx.trigger !== "user") {
return false;
}
// Exclude only canonical dreaming-narrative session keys (bare or agent-prefixed).
// Canonical forms: "dreaming-narrative-<phase>-<hash>" or
// "agent:<agentId>:dreaming-narrative-<phase>-<hash>".
// A colon-delimited match would also exclude real chat session ids whose peer id
// begins with a phased dreaming-narrative phrase (e.g.
// "agent:main:feishu:group:dreaming-narrative-light-room").
const sessionKey = ctx.sessionKey ?? "";
if (
/^dreaming-narrative-(light|rem|deep)-/i.test(sessionKey) ||
/^agent:[^:]+:dreaming-narrative-(light|rem|deep)-/i.test(sessionKey)
) {
return false;
}
if (!ctx.sessionKey && !ctx.sessionId) {
return false;
}
@@ -3630,12 +3617,7 @@ export default definePluginEntry({
});
return undefined;
}
if (
!isEligibleInteractiveSession({
...ctx,
sessionKey: resolvedSessionKey ?? ctx.sessionKey,
})
) {
if (!isEligibleInteractiveSession(ctx)) {
await persistPluginStatusLines({
api,
agentId: effectiveAgentId,

View File

@@ -20,27 +20,8 @@ function shouldSkipMissingA2uiAssets(env = process.env) {
return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE);
}
function isRelativeWithin(relPath) {
return (
relPath === "" ||
(relPath !== ".." && !relPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relPath))
);
}
function pathsOverlap(leftDir, rightDir) {
const left = path.resolve(leftDir);
const right = path.resolve(rightDir);
return (
isRelativeWithin(path.relative(left, right)) || isRelativeWithin(path.relative(right, left))
);
}
/** Copies A2UI assets, optionally tolerating missing bundles in sparse builds. */
export async function copyA2uiAssets({ srcDir, outDir }) {
if (pathsOverlap(srcDir, outDir)) {
throw new Error("A2UI source and output directories must not overlap.");
}
const skipMissing = shouldSkipMissingA2uiAssets(process.env);
try {
await fs.stat(path.join(srcDir, "index.html"));
@@ -56,7 +37,6 @@ export async function copyA2uiAssets({ srcDir, outDir }) {
throw new Error(message, { cause: err });
}
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.rm(outDir, { recursive: true, force: true });
await fs.cp(srcDir, outDir, { recursive: true });
}

View File

@@ -1,4 +1,5 @@
// Canvas tests cover copy a2ui plugin behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
@@ -7,6 +8,24 @@ import { copyA2uiAssets } from "./copy-a2ui.mjs";
const ORIGINAL_SKIP_MISSING = process.env.OPENCLAW_A2UI_SKIP_MISSING;
const ORIGINAL_SPARSE_PROFILE = process.env.OPENCLAW_SPARSE_PROFILE;
const REQUIRED_COMPATIBILITY_ASSETS = [
{
path: path.join("assets", "providers", "google.png"),
sha256: "cea7e50b816514db6ca0f21d9545173fae1669643c71ed475c45c7f8440dac53",
},
{
path: path.join("assets", "providers", "x.png"),
sha256: "307c5dbde1ad66164fcfa1d9787435d99906fa78e7ba7d068f2aa705e86ff5aa",
},
{
path: "granola.png",
sha256: "16bc6b7f1b1229c8b1984c64520c30141b62c24b156c7590f86ca50bdc494d34",
},
];
function sha256(bytes: Buffer): string {
return createHash("sha256").update(bytes).digest("hex");
}
describe("canvas a2ui copy", () => {
beforeEach(() => {
@@ -35,11 +54,24 @@ describe("canvas a2ui copy", () => {
);
}
it("ships provider assets and the legacy granola compatibility image", async () => {
const srcDir = path.join(process.cwd(), "extensions", "canvas", "src", "host", "a2ui");
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (const asset of REQUIRED_COMPATIBILITY_ASSETS) {
const bytes = await fs.readFile(path.join(srcDir, asset.path));
expect([...bytes.subarray(0, pngSignature.length)]).toEqual(pngSignature);
expect(bytes.length).toBeGreaterThan(64);
expect(sha256(bytes)).toBe(asset.sha256);
}
});
it("throws a helpful error when assets are missing", async () => {
await withA2uiFixture(async (dir) => {
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
).rejects.toThrow('Run "pnpm canvas:a2ui:bundle"');
await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow(
'Run "pnpm canvas:a2ui:bundle"',
);
});
});
@@ -47,7 +79,7 @@ describe("canvas a2ui copy", () => {
await withA2uiFixture(async (dir) => {
process.env.OPENCLAW_A2UI_SKIP_MISSING = "1";
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") }),
).resolves.toBeUndefined();
});
});
@@ -56,7 +88,7 @@ describe("canvas a2ui copy", () => {
await withA2uiFixture(async (dir) => {
process.env.OPENCLAW_SPARSE_PROFILE = "core";
await expect(
copyA2uiAssets({ srcDir: path.join(dir, "src"), outDir: path.join(dir, "out") }),
copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") }),
).resolves.toBeUndefined();
});
});
@@ -80,42 +112,28 @@ describe("canvas a2ui copy", () => {
});
});
it("copies nested bundled assets and removes stale output", async () => {
it("preserves provider assets and the legacy granola compatibility image", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
const outDir = path.join(dir, "dist");
const nestedAssetDir = path.join(srcDir, "assets", "demo");
await fs.mkdir(nestedAssetDir, { recursive: true });
await fs.mkdir(outDir, { recursive: true });
const providerAssetDir = path.join(srcDir, "assets", "providers");
await fs.mkdir(providerAssetDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await fs.writeFile(path.join(nestedAssetDir, "sample.txt"), "nested-asset", "utf8");
await fs.writeFile(path.join(outDir, "stale.txt"), "stale-output", "utf8");
await fs.writeFile(path.join(providerAssetDir, "google.png"), "google-asset", "utf8");
await fs.writeFile(path.join(providerAssetDir, "x.png"), "x-asset", "utf8");
await fs.writeFile(path.join(srcDir, "granola.png"), "legacy-granola-asset", "utf8");
await copyA2uiAssets({ srcDir, outDir });
await expect(
fs.readFile(path.join(outDir, "assets", "demo", "sample.txt"), "utf8"),
).resolves.toBe("nested-asset");
await expect(fs.stat(path.join(outDir, "stale.txt"))).rejects.toMatchObject({
code: "ENOENT",
});
});
});
it("rejects overlapping source and output directories before cleaning output", async () => {
await withA2uiFixture(async (dir) => {
const srcDir = path.join(dir, "src");
await fs.mkdir(srcDir, { recursive: true });
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
await expect(copyA2uiAssets({ srcDir, outDir: srcDir })).rejects.toThrow("must not overlap");
await expect(fs.readFile(path.join(srcDir, "index.html"), "utf8")).resolves.toBe(
"<html></html>",
);
await expect(copyA2uiAssets({ srcDir, outDir: path.join(srcDir, "dist") })).rejects.toThrow(
"must not overlap",
fs.readFile(path.join(outDir, "assets", "providers", "google.png"), "utf8"),
).resolves.toBe("google-asset");
await expect(
fs.readFile(path.join(outDir, "assets", "providers", "x.png"), "utf8"),
).resolves.toBe("x-asset");
await expect(fs.readFile(path.join(outDir, "granola.png"), "utf8")).resolves.toBe(
"legacy-granola-asset",
);
});
});

View File

@@ -23,8 +23,6 @@ let resolvingA2uiRoot: Promise<string | null> | null = null;
let cachedA2uiResolvedAtMs = 0;
const A2UI_ROOT_RETRY_NULL_AFTER_MS = 10_000;
type A2uiRootResolver = () => Promise<string | null>;
async function resolveA2uiRoot(): Promise<string | null> {
const here = path.dirname(fileURLToPath(import.meta.url));
const entryDir = process.argv[1] ? path.dirname(path.resolve(process.argv[1])) : null;
@@ -82,10 +80,10 @@ async function resolveA2uiRootReal(): Promise<string | null> {
return resolvingA2uiRoot;
}
async function handleA2uiHttpRequestWithRootResolver(
/** Handles one HTTP request for the hosted A2UI asset surface. */
export async function handleA2uiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
resolveRootReal: A2uiRootResolver,
): Promise<boolean> {
const urlRaw = req.url;
if (!urlRaw) {
@@ -105,7 +103,7 @@ async function handleA2uiHttpRequestWithRootResolver(
return true;
}
const a2uiRootReal = await resolveRootReal();
const a2uiRootReal = await resolveA2uiRootReal();
if (!a2uiRootReal) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
@@ -150,22 +148,3 @@ async function handleA2uiHttpRequestWithRootResolver(
await result.handle.close().catch(() => {});
}
}
/** Creates an HTTP handler for a specific hosted A2UI asset root. */
export function createA2uiHttpRequestHandler(params: {
rootDir: string;
}): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
let rootRealPromise: Promise<string> | null = null;
return async (req, res) => {
rootRealPromise ??= fs.realpath(params.rootDir);
return await handleA2uiHttpRequestWithRootResolver(req, res, async () => await rootRealPromise);
};
}
/** Handles one HTTP request for the hosted A2UI asset surface. */
export async function handleA2uiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
return await handleA2uiHttpRequestWithRootResolver(req, res, resolveA2uiRootReal);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

View File

@@ -108,13 +108,9 @@ async function captureHandlerResponse(
return await captureHttpResponse(handler.handleHttpRequest, url, method);
}
async function captureA2uiFixtureResponse(
rootDir: string,
url: string,
method = "GET",
): Promise<CapturedResponse> {
const { createA2uiHttpRequestHandler } = await import("./a2ui.js");
return await captureHttpResponse(createA2uiHttpRequestHandler({ rootDir }), url, method);
async function captureA2uiResponse(url: string, method = "GET"): Promise<CapturedResponse> {
const { handleA2uiHttpRequest } = await import("./a2ui.js");
return await captureHttpResponse(handleA2uiHttpRequest, url, method);
}
describe("canvas host", () => {
@@ -443,56 +439,59 @@ describe("canvas host", () => {
});
it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => {
const a2uiRoot = await createCaseDir();
const nestedAssetDir = path.join(a2uiRoot, "assets", "demo");
const a2uiRoot = path.resolve(process.cwd(), "extensions/canvas/src/host/a2ui");
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
try {
await fs.stat(bundlePath);
} catch {
await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8");
createdBundle = true;
}
await fs.mkdir(nestedAssetDir, { recursive: true });
await fs.writeFile(
path.join(a2uiRoot, "index.html"),
`<openclaw-a2ui-host></openclaw-a2ui-host>
<script>openclawCanvasA2UIAction</script>`,
"utf8",
);
await fs.writeFile(path.join(a2uiRoot, "a2ui.bundle.js"), "window.openclawA2UI = {};", "utf8");
await fs.writeFile(path.join(nestedAssetDir, "sample.txt"), "nested asset", "utf8");
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
try {
const res = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/`);
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
const html = res.body;
expect(res.status).toBe(200);
expect(html).toContain("openclaw-a2ui-host");
expect(html).toContain("openclawCanvasA2UIAction");
const bundleRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/a2ui.bundle.js`);
const bundleRes = await captureA2uiResponse(`${A2UI_PATH}/a2ui.bundle.js`);
const js = bundleRes.body;
expect(bundleRes.status).toBe(200);
expect(js).toContain("openclawA2UI");
const assetRes = await captureA2uiFixtureResponse(
a2uiRoot,
`${A2UI_PATH}/assets/demo/sample.txt`,
);
expect(assetRes.status).toBe(200);
expect(assetRes.headers["content-type"]).toBe("text/plain");
expect(assetRes.body).toBe("nested asset");
const traversalRes = await captureA2uiFixtureResponse(
a2uiRoot,
`${A2UI_PATH}/%2e%2e%2fpackage.json`,
);
const expectedPngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
for (const assetPath of [
"assets/providers/google.png",
"assets/providers/x.png",
"granola.png",
]) {
const assetRes = await captureA2uiResponse(`${A2UI_PATH}/${assetPath}`);
expect(assetRes.status).toBe(200);
expect(assetRes.headers["content-type"]).toBe("image/png");
expect(assetRes.bodyBytes.subarray(0, expectedPngSignature.length)).toEqual(
expectedPngSignature,
);
}
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
expect(traversalRes.status).toBe(404);
expect(traversalRes.body).toBe("not found");
const malformedRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/%E0%A4%A`);
const malformedRes = await captureA2uiResponse(`${A2UI_PATH}/%E0%A4%A`);
expect(malformedRes.status).toBe(404);
expect(malformedRes.body).toBe("not found");
const symlinkRes = await captureA2uiFixtureResponse(a2uiRoot, `${A2UI_PATH}/${linkName}`);
const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`);
expect(symlinkRes.status).toBe(404);
expect(symlinkRes.body).toBe("not found");
} finally {
await fs.rm(linkPath, { force: true });
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}
}
});
});

View File

@@ -1,13 +0,0 @@
# ClickClack OpenClaw channel
Official OpenClaw channel plugin for ClickClack.
## Install
```sh
openclaw plugins install @openclaw/clickclack
```
## Docs
See `docs/channels/clickclack.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/clickclack`.

View File

@@ -1,54 +0,0 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/clickclack",
"version": "2026.6.9",
"dependencies": {
"ws": "8.21.0",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.9"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,12 +1,13 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw ClickClack channel plugin",
"type": "module",
"exports": {
".": "./dist/index.js",
"./api.js": "./dist/api.js",
"./runtime-api.js": "./dist/runtime-api.js"
".": "./index.ts",
"./api.js": "./api.ts",
"./runtime-api.js": "./runtime-api.ts"
},
"dependencies": {
"ws": "8.21.0",
@@ -44,28 +45,6 @@
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
}
},
"install": {
"clawhubSpec": "clawhub:@openclaw/clickclack",
"npmSpec": "@openclaw/clickclack",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
}
}

View File

@@ -278,11 +278,6 @@ describe("CodexAppServerEventProjector", () => {
const { onAssistantMessageStart, onPartialReply, projector } =
await createProjectorWithAssistantHooks();
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-1", phase: "final_answer", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("hel"));
await projector.handleNotification(agentMessageDelta("lo"));
await projector.handleNotification(
@@ -310,10 +305,7 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
expect(onPartialReply.mock.calls.map((call) => call[0])).toEqual([
{ text: "hel", delta: "hel" },
{ text: "hello", delta: "lo" },
]);
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
@@ -329,13 +321,7 @@ describe("CodexAppServerEventProjector", () => {
});
it("streams final-answer assistant deltas into partial replies", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const projector = await createProjector({
...(await createParams()),
onAgentEvent,
onPartialReply,
});
const { onPartialReply, projector } = await createProjectorWithAssistantHooks();
await projector.handleNotification(
forCurrentTurn("item/started", {
@@ -355,79 +341,6 @@ describe("CodexAppServerEventProjector", () => {
{ text: "hel", delta: "hel" },
{ text: "hello", delta: "lo" },
]);
expect(
onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "assistant"),
).toEqual([
{ stream: "assistant", data: { text: "hel", delta: "hel" } },
{ stream: "assistant", data: { text: "hello", delta: "lo" } },
]);
});
it("streams assistant deltas when the app-server omits the item phase", async () => {
// Newer Codex app-servers (>= 0.139) stream agentMessage deltas without a
// "final_answer" phase. These surface on the replaceable agent-event path;
// legacy append-oriented partial callbacks stay quiet.
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const params = await createParams();
const projector = await createProjector({
...params,
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(agentMessageDelta("hel", "msg-final"));
await projector.handleNotification(agentMessageDelta("lo", "msg-final"));
expect(onPartialReply).not.toHaveBeenCalled();
expect(onAgentEvent.mock.calls.map((call) => call[0])).toEqual([
{ stream: "assistant", data: { text: "hel", delta: "hel", replaceable: true } },
{ stream: "assistant", data: { text: "hello", delta: "lo", replaceable: true } },
]);
});
it("marks partial replacement when an unphased intermediate item is superseded by a final item", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
const params = await createParams();
const projector = await createProjector({
...params,
onAgentEvent,
onPartialReply,
});
await projector.handleNotification(agentMessageDelta("coordination ", "msg-intermediate"));
await projector.handleNotification(agentMessageDelta("draft", "msg-intermediate"));
await projector.handleNotification(
forCurrentTurn("item/started", {
item: { type: "agentMessage", id: "msg-final", phase: "final_answer", text: "" },
}),
);
await projector.handleNotification(agentMessageDelta("final ", "msg-final"));
await projector.handleNotification(agentMessageDelta("answer", "msg-final"));
expect(onPartialReply).not.toHaveBeenCalled();
expect(
onAgentEvent.mock.calls
.map((call) => call[0])
.filter((event) => event.stream === "assistant"),
).toEqual([
{
stream: "assistant",
data: { text: "coordination ", delta: "coordination ", replaceable: true },
},
{
stream: "assistant",
data: { text: "coordination draft", delta: "draft", replaceable: true },
},
{
stream: "assistant",
data: { text: "final ", delta: "", replace: true, replaceable: true },
},
{ stream: "assistant", data: { text: "final answer", delta: "answer", replaceable: true } },
]);
});
it("suppresses mirrored user prompt when the inbound message was already persisted", async () => {
@@ -1128,8 +1041,6 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(onAssistantMessageStart).toHaveBeenCalledTimes(1);
// Phase-less snapshots stay on the replaceable agent-event path so legacy
// append-only channel previews do not render superseded coordination text.
expect(onPartialReply).not.toHaveBeenCalled();
expect(result.assistantTexts).toEqual([
"release fixes first. please drop affected PRs, failing checks, and blockers here.",

View File

@@ -196,8 +196,6 @@ export class CodexAppServerEventProjector {
private assistantStarted = false;
private reasoningStarted = false;
private reasoningEnded = false;
private streamedPartialAssistantItemId: string | undefined;
private streamedPartialAssistantItemReplaceable = false;
private completedTurn: CodexTurn | undefined;
private promptError: unknown;
private promptErrorSource: EmbeddedRunAttemptResult["promptErrorSource"] = null;
@@ -523,46 +521,13 @@ export class CodexAppServerEventProjector {
this.assistantTextByItem.set(itemId, text);
if (this.isCommentaryAssistantItem(itemId)) {
this.emitCommentaryProgress({ itemId, text });
} else {
const knownFinalAnswer = this.shouldStreamAssistantPartial(itemId);
const replace =
this.streamedPartialAssistantItemId !== undefined &&
this.streamedPartialAssistantItemId !== itemId;
// Codex defines final_answer as terminal text. Replacement mode is for
// phase-unknown/provisional items; append-only consumers cannot retract
// bytes after a known terminal answer has started.
if (replace && (!knownFinalAnswer || this.streamedPartialAssistantItemReplaceable)) {
this.streamedPartialAssistantItemReplaceable = true;
} else if (this.streamedPartialAssistantItemId === undefined) {
this.streamedPartialAssistantItemReplaceable = !knownFinalAnswer;
}
this.streamedPartialAssistantItemId = itemId;
const replaceable = this.streamedPartialAssistantItemReplaceable;
const replacement = replace && replaceable;
const streamPayload = {
text,
delta: replacement ? "" : delta,
...(replacement ? { replace: true as const } : {}),
};
this.emitAgentEvent({
stream: "assistant",
data: {
...streamPayload,
...(replaceable ? { replaceable: true as const } : {}),
},
});
// Legacy channel preview callbacks are append-oriented and do not all
// understand replacement snapshots. Keep them on the known final-answer
// path; replaceable snapshots stay on the typed agent-event path.
if (knownFinalAnswer && !replaceable) {
await this.params.onPartialReply?.(streamPayload);
}
} else if (this.shouldStreamAssistantPartial(itemId)) {
await this.params.onPartialReply?.({ text, delta });
}
// Stream non-commentary assistant deltas as partial replies and assistant
// agent events so live surfaces (TUI, WebChat) render incremental answer
// text via gateway emitChatDelta. When Codex switches to a new non-commentary
// item, mark replace:true with an empty delta so live merge and append-oriented
// partial consumers reset to the new cumulative text instead of concatenating.
// Codex app-server can emit multiple agentMessage items per turn, including
// intermediate coordination/progress prose. Keep those deltas internal until
// their phase identifies terminal answer text or turn completion chooses the
// last assistant item as the user-visible reply.
}
private async handleReasoningDelta(

View File

@@ -185,14 +185,8 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
(event) => event.stream === "lifecycle" && event.data.phase === "start",
);
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
const assistantEvents = agentEvents.filter((event) => event.stream === "assistant");
expect(assistantEvents).toHaveLength(2);
expect(assistantEvents[0]?.data).toEqual({
text: "hello back",
delta: "hello back",
replaceable: true,
});
expect(assistantEvents[1]?.data).toEqual({ text: "hello back" });
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
expect(assistantEvent?.data).toEqual({ text: "hello back" });
const lifecycleEnd = agentEvents.find(
(event) => event.stream === "lifecycle" && event.data.phase === "end",
);
@@ -208,16 +202,10 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(assistantIndex).toBeGreaterThan(startIndex);
expect(endIndex).toBeGreaterThan(assistantIndex);
const globalAssistantEvents = globalAgentEvents.filter((event) => event.stream === "assistant");
expect(globalAssistantEvents).toHaveLength(2);
expect(globalAssistantEvents[0]?.runId).toBe("run-1");
expect(globalAssistantEvents[0]?.sessionKey).toBe("agent:main:session-1");
expect(globalAssistantEvents[0]?.data).toEqual({
text: "hello back",
delta: "hello back",
replaceable: true,
});
expect(globalAssistantEvents[1]?.data).toEqual({ text: "hello back" });
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");
expect(globalAssistantEvent?.runId).toBe("run-1");
expect(globalAssistantEvent?.sessionKey).toBe("agent:main:session-1");
expect(globalAssistantEvent?.data).toEqual({ text: "hello back" });
const globalEndEvent = globalAgentEvents.find(
(event) => event.stream === "lifecycle" && event.data.phase === "end",
);

View File

@@ -2720,8 +2720,6 @@ export async function runCodexAppServerAttempt(
}
turnIdRef.current = turn.turn.id;
const activeTurnId = turn.turn.id;
let assistantStreamEventEmitted = false;
let assistantStreamNeedsTerminalSnapshot = false;
emitExecutionPhaseOnce("turn_accepted", { phase: "turn_accepted" });
userInputBridgeRef.current = createCodexUserInputBridge({
paramsForRun: params,
@@ -2736,16 +2734,7 @@ export async function runCodexAppServerAttempt(
imagesCount: params.images?.length ?? 0,
});
projectorRef.current = new CodexAppServerEventProjector(
{
...dynamicToolParams,
onAgentEvent: (event) => {
if (event.stream === "assistant" && typeof event.data.delta === "string") {
assistantStreamEventEmitted = true;
assistantStreamNeedsTerminalSnapshot ||= event.data.replaceable === true;
}
return dynamicToolParams.onAgentEvent?.(event);
},
},
dynamicToolParams,
thread.threadId,
activeTurnId,
{
@@ -3013,12 +3002,7 @@ export async function runCodexAppServerAttempt(
turnId: activeTurnId,
});
const terminalAssistantText = collectTerminalAssistantText(result);
if (
terminalAssistantText &&
(!assistantStreamEventEmitted || assistantStreamNeedsTerminalSnapshot) &&
!finalAborted &&
!finalPromptError
) {
if (terminalAssistantText && !finalAborted && !finalPromptError) {
void emitCodexAppServerEvent(params, {
stream: "assistant",
data: { text: terminalAssistantText },

View File

@@ -1,13 +0,0 @@
# Fireworks OpenClaw provider
Official OpenClaw provider plugin for Fireworks.
## Install
```sh
openclaw plugins install @openclaw/fireworks-provider
```
## Docs
See `docs/providers/fireworks.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/providers/fireworks`.

View File

@@ -1,12 +0,0 @@
{
"name": "@openclaw/fireworks-provider",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/fireworks-provider",
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "@openclaw/fireworks-provider",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Fireworks provider plugin",
"type": "module",
"devDependencies": {
@@ -9,27 +10,6 @@
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/fireworks-provider",
"npmSpec": "@openclaw/fireworks-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9"
},
"compat": {
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
]
}
}

View File

@@ -30,34 +30,6 @@ beforeEach(() => {
accessMocks.applyGoogleChatInboundAccessPolicy.mockReset();
});
function createInboundClassificationHarness() {
const resolveAgentRoute = vi.fn(() => ({
agentId: "agent-1",
accountId: "work",
sessionKey: "session-1",
}));
const buildContext = vi.fn((payload: unknown) => payload);
const runTurn = vi.fn();
const core = {
logging: { shouldLogVerbose: () => false },
channel: {
routing: { resolveAgentRoute },
session: {
resolveStorePath: () => "/tmp/openclaw-googlechat-test",
readSessionUpdatedAt: () => undefined,
recordInboundSession: vi.fn(),
},
reply: {
resolveEnvelopeFormatOptions: () => ({}),
formatAgentEnvelope: ({ body }: { body: string }) => body,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(),
},
inbound: { buildContext, run: runTurn },
},
} as unknown as GoogleChatCoreRuntime;
return { buildContext, core, resolveAgentRoute, runTurn };
}
describe("googlechat monitor bot loop protection", () => {
it("maps accepted bot-authored messages to shared channel-turn facts", () => {
expect(
@@ -187,74 +159,6 @@ describe("googlechat monitor bot loop protection", () => {
});
});
describe("googlechat monitor inbound space classification", () => {
const cases = [
{ name: "legacy DM", space: { type: "DM" }, peerKind: "direct" },
{ name: "modern direct message", space: { spaceType: "DIRECT_MESSAGE" }, peerKind: "direct" },
{ name: "single-user bot DM", space: { singleUserBotDm: true }, peerKind: "direct" },
{ name: "modern space", space: { spaceType: "SPACE" }, peerKind: "group" },
{ name: "modern group chat", space: { spaceType: "GROUP_CHAT" }, peerKind: "group" },
{
name: "modern space over legacy DM",
space: { type: "DM", spaceType: "SPACE" },
peerKind: "group",
},
] as const;
it.each(cases)("$name uses the expected access and route branch", async ({ space, peerKind }) => {
const { buildContext, core, resolveAgentRoute, runTurn } = createInboundClassificationHarness();
const account = {
accountId: "work",
config: {},
credentialSource: "inline",
} as ResolvedGoogleChatAccount;
const event = {
type: "MESSAGE",
space: { name: "spaces/CLASSIFY", ...space },
message: {
name: "spaces/CLASSIFY/messages/1",
text: "hello",
sender: { name: "users/alice", displayName: "Alice", type: "HUMAN" },
},
} satisfies GoogleChatEvent;
accessMocks.applyGoogleChatInboundAccessPolicy.mockResolvedValue({
ok: true,
commandAuthorized: undefined,
effectiveWasMentioned: undefined,
groupBotLoopProtection: undefined,
groupSystemPrompt: undefined,
});
await testing.processMessageWithPipeline({
event,
account,
config: {},
runtime: { error: vi.fn(), log: vi.fn() },
core,
mediaMaxMb: 10,
});
const isGroup = peerKind === "group";
expect(accessMocks.applyGoogleChatInboundAccessPolicy).toHaveBeenCalledWith(
expect.objectContaining({ isGroup }),
);
expect(resolveAgentRoute).toHaveBeenCalledWith({
cfg: {},
channel: "googlechat",
accountId: "work",
peer: { kind: peerKind, id: "spaces/CLASSIFY" },
});
expect(buildContext).toHaveBeenCalledWith(
expect.objectContaining({
conversation: expect.objectContaining({ kind: isGroup ? "channel" : "direct" }),
extra: expect.objectContaining({ ChatType: isGroup ? "channel" : "direct" }),
}),
);
expect(runTurn).toHaveBeenCalledOnce();
});
});
describe("googlechat monitor direct messages", () => {
it("creates typing messages by default", async () => {
const runTurn = vi.fn();

View File

@@ -29,7 +29,7 @@ import type {
} from "./monitor-types.js";
import { warnAppPrincipalMisconfiguration } from "./monitor-webhook.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type { GoogleChatAttachment, GoogleChatEvent, GoogleChatSpace } from "./types.js";
import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js";
setGoogleChatWebhookEventProcessor(processGoogleChatEvent);
@@ -62,20 +62,6 @@ function resolveGoogleChatTimestampMs(eventTime?: string): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}
function isGoogleChatGroupSpace(space: GoogleChatSpace): boolean {
const spaceType = (space.spaceType ?? "").toUpperCase();
// Google Chat deprecates `type` in favor of `spaceType`; known modern
// values must win if the fields disagree. Fall back to the bot-DM flag and
// legacy type so incomplete payloads retain their existing direct routing.
if (spaceType === "DIRECT_MESSAGE") {
return false;
}
if (spaceType === "SPACE" || spaceType === "GROUP_CHAT") {
return true;
}
return space.singleUserBotDm !== true && (space.type ?? "").toUpperCase() !== "DM";
}
function resolveGoogleChatBotLoopProtection(params: {
allowBots: boolean;
isBotSender: boolean;
@@ -200,7 +186,8 @@ async function processMessageWithPipeline(params: {
if (!spaceId) {
return;
}
const isGroup = isGoogleChatGroupSpace(space);
const spaceType = (space.type ?? "").toUpperCase();
const isGroup = spaceType !== "DM";
const sender = message.sender ?? event.user;
const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? "";

View File

@@ -3,10 +3,6 @@ export type GoogleChatSpace = {
name?: string;
displayName?: string;
type?: string;
/** Current Google Chat field that replaces the deprecated `type` field. */
spaceType?: string;
/** True when the space is a 1:1 DM between a user and the Chat app. */
singleUserBotDm?: boolean;
};
export type GoogleChatUser = {

View File

@@ -1,13 +0,0 @@
# IRC OpenClaw channel
Official OpenClaw channel plugin for IRC.
## Install
```sh
openclaw plugins install @openclaw/irc
```
## Docs
See `docs/channels/irc.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/irc`.

View File

@@ -1,24 +0,0 @@
{
"name": "@openclaw/irc",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/irc",
"version": "2026.6.9",
"dependencies": {
"zod": "4.4.3"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -11,11 +11,7 @@
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/irc",
"npmSpec": "@openclaw/irc",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
"minHostVersion": ">=2026.4.10"
},
"setupEntry": "./setup-entry.ts",
"channel": {
@@ -40,24 +36,9 @@
"specifier": "./configured-state",
"exportName": "hasIrcConfiguredState"
}
},
"compat": {
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"dependencies": {
"zod": "4.4.3"
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
}
}

View File

@@ -4,6 +4,7 @@ import {
buildIrcAllowlistCandidates,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
resolveIrcAllowlistMatch,
} from "./normalize.js";
describe("irc normalize", () => {
@@ -32,5 +33,24 @@ describe("irc normalize", () => {
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice!ident@example.org"],
message,
}).allowed,
).toBe(true);
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice"],
message,
}).allowed,
).toBe(false);
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice"],
message,
allowNameMatching: true,
}).allowed,
).toBe(true);
});
});

View File

@@ -2,6 +2,7 @@
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
normalizeStringEntriesLower,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { hasIrcControlChars } from "./control-chars.js";
import type { IrcInboundMessage } from "./types.js";
@@ -84,3 +85,23 @@ export function buildIrcAllowlistCandidates(
}
return [...candidates];
}
export function resolveIrcAllowlistMatch(params: {
allowFrom: string[];
message: IrcInboundMessage;
allowNameMatching?: boolean;
}): { allowed: boolean; source?: string } {
const allowFrom = new Set(normalizeStringEntriesLower(params.allowFrom));
if (allowFrom.has("*")) {
return { allowed: true, source: "wildcard" };
}
const candidates = buildIrcAllowlistCandidates(params.message, {
allowNameMatching: params.allowNameMatching,
});
for (const candidate of candidates) {
if (allowFrom.has(candidate)) {
return { allowed: true, source: candidate };
}
}
return { allowed: false };
}

View File

@@ -22,6 +22,7 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-embeddings", () => ({
import llamaCppPlugin from "./index.js";
import {
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
createLlamaCppEmbeddingProvider,
createLlamaCppMemoryEmbeddingProvider,
formatLlamaCppSetupError,
llamaCppEmbeddingProviderAdapter,
@@ -70,16 +71,14 @@ describe("llama.cpp provider plugin", () => {
});
const abortController = new AbortController();
const result = await llamaCppEmbeddingProviderAdapter.create({
config: {},
provider: "local",
model: "text-embedding-3-small",
});
const provider = result.provider;
expect(provider).not.toBeNull();
if (!provider) {
throw new Error("expected llama.cpp provider");
}
const provider = await createLlamaCppEmbeddingProvider(
{
config: {},
provider: "local",
model: "text-embedding-3-small",
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
await expect(provider.embed("hello")).resolves.toEqual([0.6, 0.8]);
await expect(
@@ -101,7 +100,7 @@ describe("llama.cpp provider plugin", () => {
},
},
{
nodeLlamaCppImportUrl: expect.stringContaining("node-llama-cpp"),
nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js",
},
);
const workerProvider =

View File

@@ -182,6 +182,17 @@ function adaptMemoryEmbeddingProvider(provider: MemoryEmbeddingProvider): Embedd
};
}
export async function createLlamaCppEmbeddingProvider(
options: EmbeddingProviderCreateOptions,
runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {},
): Promise<EmbeddingProvider> {
const result = await createLlamaCppEmbeddingProviderResult(options, runtimeOptions);
if (!result.provider) {
throw new Error("llama.cpp local embedding provider was unavailable");
}
return result.provider;
}
export async function createLlamaCppMemoryEmbeddingProvider(
options: MemoryEmbeddingProviderCreateOptions,
runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {},

View File

@@ -1647,10 +1647,7 @@ export function registerMatrixCli(params: { program: Command }): void {
.description("Enable Matrix E2EE, bootstrap verification, and print next steps")
.option("--account <id>", "Account ID (for multi-account setups)")
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
.option(
"--force-reset-cross-signing",
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
)
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(
@@ -2124,10 +2121,7 @@ export function registerMatrixCli(params: { program: Command }): void {
"Recovery key to apply before bootstrap (prefer --recovery-key-stdin)",
)
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
.option(
"--force-reset-cross-signing",
"Force reset cross-signing identity before bootstrap (requires active recovery key)",
)
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
.option("--verbose", "Show detailed diagnostics")
.option("--json", "Output as JSON")
.action(

View File

@@ -1645,54 +1645,6 @@ describe("MatrixClient crypto bootstrapping", () => {
expect(bootstrapSpy).toHaveBeenCalledTimes(2);
});
it("rejects recovery keys when secret-storage metadata cannot authenticate them", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");
fs.writeFileSync(
recoveryKeyPath,
JSON.stringify({
version: 1,
createdAt: new Date().toISOString(),
keyId: "SSSSKEY",
privateKeyBase64: Buffer.from([1, 2, 3, 4]).toString("base64"),
}),
"utf8",
);
const checkKey = vi.fn(async () => true);
Object.assign(matrixJsClient, {
secretStorage: {
getDefaultKeyId: vi.fn(async () => "SSSSKEY"),
getKey: vi.fn(async () => [
"SSSSKEY",
{
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
iv: "authenticated-iv",
},
]),
checkKey,
},
});
const client = new MatrixClient("https://matrix.example.org", "token", {
encryption: true,
recoveryKeyPath,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
const bootstrapper = (
client as unknown as {
cryptoBootstrapper: {
deps: { canUnlockSecretStorage: () => Promise<boolean> };
};
}
).cryptoBootstrapper;
await expect(bootstrapper.deps.canUnlockSecretStorage()).resolves.toBe(false);
expect(checkKey).not.toHaveBeenCalled();
});
it("provides secret storage callbacks and resolves stored recovery key", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-sdk-test-"));
const recoveryKeyPath = path.join(tmpDir, "recovery-key.json");

View File

@@ -484,39 +484,6 @@ export class MatrixClient {
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => this.password,
canUnlockSecretStorage: async () => {
const secretStorage = (
this.client as {
secretStorage?: Partial<
Pick<MatrixJsClient["secretStorage"], "checkKey" | "getDefaultKeyId" | "getKey">
>;
}
).secretStorage;
// Partial test/runtime facades can omit secretStorage; forced reset must fail closed
// without turning missing recovery access into a noisy caught TypeError.
if (
!secretStorage ||
typeof secretStorage.getDefaultKeyId !== "function" ||
typeof secretStorage.getKey !== "function" ||
typeof secretStorage.checkKey !== "function"
) {
return false;
}
const defaultKeyId = await secretStorage.getDefaultKeyId();
if (!defaultKeyId) {
return false;
}
const keyTuple = await secretStorage.getKey(defaultKeyId);
const key = this.recoveryKeyStore.getSecretStorageKeyCandidate(defaultKeyId);
if (!keyTuple || !key) {
return false;
}
const keyInfo = keyTuple[1];
if (!keyInfo.iv?.trim() || !keyInfo.mac?.trim()) {
return false;
}
return await secretStorage.checkKey(key, keyInfo);
},
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
@@ -780,9 +747,13 @@ export class MatrixClient {
"Cross-signing/bootstrap is incomplete for an already owner-signed device; skipping automatic reset and preserving the current identity. Restore the recovery key or run an explicit verification bootstrap if repair is needed.",
);
} else {
// Forced reset validates the active SSSS recovery key before rotating local keys.
// Missing or stale recovery material fails without mutating crypto state.
// No password guard: passwordless token-auth bots should still attempt repair.
// UIA failures inside bootstrap() are caught below and logged as warnings.
try {
// The repair path already force-resets cross-signing; allow secret storage
// recreation so the new keys can be persisted. Without this, a device that
// lost its recovery key enters a permanent failure loop because the new
// cross-signing keys have nowhere to be stored.
const repaired = await cryptoBootstrapper.bootstrap(
crypto,
MATRIX_AUTOMATIC_REPAIR_BOOTSTRAP_OPTIONS,

View File

@@ -1,7 +1,6 @@
// Matrix tests cover crypto bootstrap plugin behavior.
import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
import { MatrixCryptoBootstrapper, type MatrixCryptoBootstrapperDeps } from "./crypto-bootstrap.js";
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
import type { MatrixCryptoBootstrapApi, MatrixRawEvent } from "./types.js";
type BootstrapCrossSigningMock = Mock<MatrixCryptoBootstrapApi["bootstrapCrossSigning"]>;
@@ -40,15 +39,12 @@ function createBootstrapperDeps() {
return {
getUserId: vi.fn(async () => "@bot:example.org"),
getPassword: vi.fn<() => string | undefined>(() => "super-secret-password"),
canUnlockSecretStorage: vi.fn(async () => true),
getDeviceId: vi.fn(() => "DEVICE123"),
verificationManager: {
trackVerificationRequest: vi.fn(),
},
recoveryKeyStore: {
bootstrapSecretStorageWithRecoveryKey: vi.fn<
MatrixRecoveryKeyStore["bootstrapSecretStorageWithRecoveryKey"]
>(async () => {}),
bootstrapSecretStorageWithRecoveryKey: vi.fn(async () => {}),
},
decryptBridge: {
bindCryptoRetrySignals: vi.fn(),
@@ -136,6 +132,17 @@ function createForcedResetHarness(bootstrapCrossSigning: BootstrapCrossSigningMo
});
}
function expectForcedResetCrossSigningCalls(
bootstrapCrossSigning: BootstrapCrossSigningMock,
params: { setupNewCall: number; totalCalls: number },
) {
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(params.totalCalls);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.setupNewCall, {
setupNewCrossSigning: true,
});
expectBootstrapCrossSigningCall(bootstrapCrossSigning, params.totalCalls);
}
async function bootstrapWithVerificationRequestListener(overrides?: {
deps?: Partial<ReturnType<typeof createBootstrapperDeps>>;
crypto?: Partial<MatrixCryptoBootstrapApi>;
@@ -399,72 +406,32 @@ describe("MatrixCryptoBootstrapper", () => {
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
});
it("rejects forced reset before mutation when the active recovery key is unavailable", async () => {
const deps = createBootstrapperDeps();
deps.canUnlockSecretStorage = vi.fn(async () => false);
const bootstrapCrossSigning = vi.fn(async () => {});
const crypto = createCryptoApi({ bootstrapCrossSigning });
const bootstrapper = new MatrixCryptoBootstrapper(
deps as unknown as MatrixCryptoBootstrapperDeps<MatrixRawEvent>,
);
await expect(
bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
).rejects.toThrow(
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).not.toHaveBeenCalled();
});
it("fails closed without recreating SSSS when forced reset cannot unlock it", async () => {
it("recreates secret storage and retries a forced reset when stale server SSSS blocks it", async () => {
const bootstrapCrossSigning = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"));
.mockRejectedValueOnce(new Error("getSecretStorageKey callback returned falsey"))
.mockResolvedValueOnce(undefined);
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
await expect(
bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
).rejects.toThrow(
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
);
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
});
it("does not repair SSSS after a non-strict forced reset failure", async () => {
const bootstrapCrossSigning = vi.fn(async () => {
throw new Error("getSecretStorageKey callback returned falsey");
});
const { deps, crypto, bootstrapper } = createForcedResetHarness(bootstrapCrossSigning);
const result = await bootstrapper.bootstrap(crypto, {
strict: false,
await bootstrapper.bootstrap(crypto, {
strict: true,
forceResetCrossSigning: true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(result).toEqual({
crossSigningReady: false,
crossSigningPublished: false,
ownDeviceVerified: null,
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
forceNewSecretStorage: true,
},
);
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
setupNewCall: 2,
totalCalls: 3,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).not.toHaveBeenCalled();
expect(bootstrapCrossSigning).toHaveBeenCalledOnce();
});
it("re-exports cross-signing keys after forced reset creates secret storage", async () => {
@@ -477,16 +444,16 @@ describe("MatrixCryptoBootstrapper", () => {
allowSecretStorageRecreateWithoutRecoveryKey: true,
});
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledOnce();
expect(deps.recoveryKeyStore.bootstrapSecretStorageWithRecoveryKey).toHaveBeenCalledWith(
crypto,
expect.objectContaining({
{
allowSecretStorageRecreateWithoutRecoveryKey: true,
}),
},
);
// No redundant second bootstrapCrossSigning call — no double reset (gh-78396).
expect(bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expectBootstrapCrossSigningCall(bootstrapCrossSigning, 1, { setupNewCrossSigning: true });
expectForcedResetCrossSigningCalls(bootstrapCrossSigning, {
setupNewCall: 1,
totalCalls: 2,
});
});
it("trusts the fresh own identity after a forced cross-signing reset", async () => {

View File

@@ -20,7 +20,6 @@ import { isMatrixDeviceOwnerVerified } from "./verification-status.js";
export type MatrixCryptoBootstrapperDeps<TRawEvent extends MatrixRawEvent> = {
getUserId: () => Promise<string>;
getPassword?: () => string | undefined;
canUnlockSecretStorage: () => Promise<boolean>;
getDeviceId: () => string | null | undefined;
verificationManager: MatrixVerificationManager;
recoveryKeyStore: MatrixRecoveryKeyStore;
@@ -52,17 +51,11 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options: MatrixCryptoBootstrapOptions = {},
): Promise<MatrixCryptoBootstrapResult> {
const strict = options.strict === true;
const forceReset = options.forceResetCrossSigning === true;
const deferSecretStorageBootstrapUntilAfterCrossSigning = forceReset;
if (forceReset && !(await this.deps.canUnlockSecretStorage())) {
throw new Error(
"Forced cross-signing reset requires the active Matrix recovery key; supply it before retrying",
);
}
const deferSecretStorageBootstrapUntilAfterCrossSigning =
options.forceResetCrossSigning === true;
// Register verification listeners before expensive bootstrap work so incoming requests
// are not missed during startup.
this.registerVerificationRequestHandler(crypto);
if (!deferSecretStorageBootstrapUntilAfterCrossSigning) {
await this.bootstrapSecretStorage(crypto, {
strict,
@@ -70,33 +63,30 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
}
const crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: forceReset,
let crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: options.forceResetCrossSigning === true,
allowAutomaticCrossSigningReset: options.allowAutomaticCrossSigningReset !== false,
// A repair retry would generate another identity after the SDK already rotated local keys.
// Fail closed instead; the server identity and existing recovery material remain authoritative.
allowSecretStorageRecreateWithoutRecoveryKey: forceReset
? false
: options.allowSecretStorageRecreateWithoutRecoveryKey === true,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
if (forceReset && (!crossSigning.ready || !crossSigning.published)) {
return {
crossSigningReady: crossSigning.ready,
crossSigningPublished: crossSigning.published,
ownDeviceVerified: null,
};
}
// Second SSSS pass to pick up cross-signing keys published during bootstrap.
// Forced repair may need password UIA to upload new cross-signing keys. Delay any
// secret-storage repair/recreation until after that step succeeds so passwordless bots do
// not partially mutate SSSS on homeservers that require password-based UIA.
await this.bootstrapSecretStorage(crypto, {
strict,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
});
if (deferSecretStorageBootstrapUntilAfterCrossSigning) {
crossSigning = await this.bootstrapCrossSigning(crypto, {
forceResetCrossSigning: false,
allowAutomaticCrossSigningReset: false,
allowSecretStorageRecreateWithoutRecoveryKey:
options.allowSecretStorageRecreateWithoutRecoveryKey === true,
strict,
});
}
const ownDeviceVerified = await this.ensureOwnDeviceTrust(crypto, {
strict,
});
@@ -244,12 +234,6 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
}
LogService.warn("MatrixClientLite", "Forced cross-signing reset failed:", err);
if (options.strict) {
if (isRepairableSecretStorageAccessError(err)) {
throw new Error(
"Forced cross-signing reset cannot access secret storage; restore the Matrix recovery key before retrying",
{ cause: err },
);
}
throw err instanceof Error ? err : new Error(String(err));
}
return { ready: false, published: false };

View File

@@ -148,7 +148,6 @@ describe("MatrixRecoveryKeyStore", () => {
expect(fs.existsSync(recoveryKeyPath)).toBe(false);
expect(fs.existsSync(`${recoveryKeyPath}.migrated`)).toBe(true);
const callbacks = store.buildCryptoCallbacks();
expect(store.getSecretStorageKeyCandidate("SSSS")).toEqual(new Uint8Array([1, 2, 3, 4]));
const resolved = await callbacks.getSecretStorageKey?.(
{ keys: { SSSS: { name: "test" } } },
"m.cross_signing.master",
@@ -156,12 +155,6 @@ describe("MatrixRecoveryKeyStore", () => {
expect(resolved?.[0]).toBe("SSSS");
expect(Array.from(resolved?.[1] ?? [])).toEqual([1, 2, 3, 4]);
const resolvedFromMultipleKeys = await callbacks.getSecretStorageKey?.(
{ keys: { OLD: { name: "old" }, SSSS: { name: "active" } } },
"m.cross_signing.master",
);
expect(resolvedFromMultipleKeys?.[0]).toBe("SSSS");
});
it("keeps a readable legacy recovery key usable when SQLite migration fails", async () => {
@@ -240,15 +233,6 @@ describe("MatrixRecoveryKeyStore", () => {
expect(saved.privateKeyBase64).toBe(Buffer.from([9, 8, 7]).toString("base64"));
});
it("does not authorize destructive reset from an ephemeral cached key", () => {
const store = new MatrixRecoveryKeyStore();
const callbacks = store.buildCryptoCallbacks();
callbacks.cacheSecretStorageKey?.("KEY123", { name: "openclaw" }, new Uint8Array([9, 8, 7]));
expect(store.getSecretStorageKeyCandidate("KEY123")).toBeNull();
});
it("creates and persists a recovery key when secret storage is missing", async () => {
const { store, createRecoveryKeyFromPassphrase, bootstrapSecretStorage } =
await runSecretStorageBootstrapScenario({

View File

@@ -135,27 +135,6 @@ export class MatrixRecoveryKeyStore {
};
}
getSecretStorageKeyCandidate(keyId: string): Uint8Array | null {
const normalizedKeyId = keyId.trim();
if (!normalizedKeyId) {
return null;
}
const staged = this.resolveStagedSecretStorageKey([normalizedKeyId]);
if (staged) {
return staged[1];
}
const stored = this.loadStoredRecoveryKey();
if (!stored?.privateKeyBase64) {
return null;
}
const privateKey = new Uint8Array(Buffer.from(stored.privateKeyBase64, "base64"));
if (privateKey.length === 0) {
return null;
}
this.rememberSecretStorageKey(normalizedKeyId, privateKey, stored.keyInfo);
return privateKey;
}
private resolveEncodedRecoveryKeyInput(params: {
encodedPrivateKey: string;
keyId?: string | null;

View File

@@ -1,13 +0,0 @@
# Mattermost OpenClaw channel
Official OpenClaw channel plugin for Mattermost.
## Install
```sh
openclaw plugins install @openclaw/mattermost
```
## Docs
See `docs/channels/mattermost.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/mattermost`.

View File

@@ -1,54 +0,0 @@
{
"name": "@openclaw/mattermost",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/mattermost",
"version": "2026.6.9",
"dependencies": {
"ws": "8.21.0",
"zod": "4.4.3"
},
"peerDependencies": {
"openclaw": ">=2026.6.9"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -38,22 +38,7 @@
"order": 65
},
"install": {
"clawhubSpec": "clawhub:@openclaw/mattermost",
"npmSpec": "@openclaw/mattermost",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
},
"compat": {
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
"minHostVersion": ">=2026.4.10"
}
}
}

View File

@@ -1,13 +0,0 @@
# Moonshot OpenClaw provider
Official OpenClaw provider plugin for Moonshot.
## Install
```sh
openclaw plugins install @openclaw/moonshot-provider
```
## Docs
See `docs/providers/moonshot.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/providers/moonshot`.

View File

@@ -1,12 +0,0 @@
{
"name": "@openclaw/moonshot-provider",
"version": "2026.6.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/moonshot-provider",
"version": "2026.6.9"
}
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "@openclaw/moonshot-provider",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw Moonshot provider plugin",
"type": "module",
"devDependencies": {
@@ -9,27 +10,6 @@
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/moonshot-provider",
"npmSpec": "@openclaw/moonshot-provider",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9"
},
"compat": {
"pluginApi": ">=2026.6.9"
},
"build": {
"openclawVersion": "2026.6.9",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
]
}
}

View File

@@ -14,7 +14,6 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { responseWithRelease } from "../response-with-release.js";
import type { MSTeamsAttachmentLike } from "./types.js";
type InlineImageCandidate =
@@ -577,6 +576,52 @@ export async function resolveAndValidateIP(
/** Maximum number of redirects to follow in safeFetch. */
const MAX_SAFE_REDIRECTS = 5;
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (err) {
await releaseOnce();
throw err;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => {});
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
/**
* Fetch a URL with redirect: "manual", validating each redirect target
* against the hostname allowlist and optional DNS-resolved IP (anti-SSRF).

View File

@@ -4,13 +4,13 @@ import { fetchWithSsrFGuard, type MSTeamsConfig } from "../runtime-api.js";
import { GRAPH_ROOT } from "./attachments/shared.js";
import { resolveMSTeamsSdkCloudOptions } from "./cloud.js";
import { createMSTeamsHttpError } from "./http-error.js";
import { responseWithRelease } from "./response-with-release.js";
import { createMSTeamsTokenProvider, loadMSTeamsSdkWithAuth } from "./sdk.js";
import { readAccessToken } from "./token-response.js";
import { resolveDelegatedAccessToken, resolveMSTeamsCredentials } from "./token.js";
import { buildUserAgent } from "./user-agent.js";
const GRAPH_BETA = "https://graph.microsoft.com/beta";
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export type GraphUser = {
id?: string;
@@ -31,6 +31,50 @@ type GraphChannel = {
export type GraphResponse<T> = { value?: T[] };
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function normalizeQuery(value?: string | null): string {
return value?.trim() ?? "";
}

View File

@@ -9,6 +9,8 @@ import {
type MSTeamsSsoFetch,
handleSigninTokenExchangeInvoke,
handleSigninVerifyStateInvoke,
parseSigninTokenExchangeValue,
parseSigninVerifyStateValue,
} from "./sso.js";
function createMemorySsoTokenStore(): MSTeamsSsoTokenStore {
@@ -66,6 +68,29 @@ function createFakeFetch(handlers: Array<(url: string, init?: unknown) => unknow
return { fetchImpl, calls };
}
describe("msteams signin invoke value parsers", () => {
it("parses signin/tokenExchange values", () => {
expect(
parseSigninTokenExchangeValue({
id: "flow-1",
connectionName: "Graph",
token: "eyJ...",
}),
).toEqual({ id: "flow-1", connectionName: "Graph", token: "eyJ..." });
});
it("rejects non-object signin/tokenExchange values", () => {
expect(parseSigninTokenExchangeValue(null)).toBeNull();
expect(parseSigninTokenExchangeValue("nope")).toBeNull();
});
it("parses signin/verifyState values", () => {
expect(parseSigninVerifyStateValue({ state: "123456" })).toEqual({ state: "123456" });
expect(parseSigninVerifyStateValue({})).toEqual({ state: undefined });
expect(parseSigninVerifyStateValue(null)).toBeNull();
});
});
describe("handleSigninTokenExchangeInvoke", () => {
it("exchanges the Teams token and persists the result", async () => {
const { fetchImpl, calls } = createFakeFetch([

View File

@@ -1,45 +0,0 @@
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return response;
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await reader.read();
if (next.done) {
controller.close();
await releaseOnce();
return;
}
controller.enqueue(next.value);
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
void reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

View File

@@ -96,6 +96,32 @@ type SigninVerifyStateValue = {
state?: string;
};
/**
* Extract and validate the `signin/tokenExchange` activity value. Teams
* delivers `{ id, connectionName, token }`; any field may be missing on
* malformed invocations, so callers should check the parsed result.
*/
export function parseSigninTokenExchangeValue(value: unknown): SigninTokenExchangeValue | null {
if (!value || typeof value !== "object") {
return null;
}
const obj = value as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id : undefined;
const connectionName = typeof obj.connectionName === "string" ? obj.connectionName : undefined;
const token = typeof obj.token === "string" ? obj.token : undefined;
return { id, connectionName, token };
}
/** Extract the `signin/verifyState` activity value `{ state }`. */
export function parseSigninVerifyStateValue(value: unknown): SigninVerifyStateValue | null {
if (!value || typeof value !== "object") {
return null;
}
const obj = value as Record<string, unknown>;
const state = typeof obj.state === "string" ? obj.state : undefined;
return { state };
}
type UserTokenServiceCallParams = {
baseUrl: string;
path: string;

View File

@@ -11,7 +11,6 @@ import { isMap, isScalar, isSeq, type Node, type Pair } from "yaml";
import type { MdAst } from "./ast.js";
import type { JsoncValue } from "./jsonc/ast.js";
import type { JsonlAst, JsonlLine } from "./jsonl/ast.js";
import { pickJsonlLine } from "./jsonl/line.js";
import type { OcPath } from "./oc-path.js";
import {
MAX_TRAVERSAL_DEPTH,
@@ -365,7 +364,7 @@ const jsonlOps: WalkOps<JsonlAst> = {
}
},
lookup(ast, key) {
const line = pickJsonlLine(ast, key);
const line = pickLine(ast, key);
if (line === null) {
return null;
}
@@ -441,6 +440,37 @@ function topLevelLeafText(value: JsoncValue, key: string): string | null {
return null;
}
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === "$first") {
for (const l of ast.lines) {
if (l.kind === "value") {
return l;
}
}
return null;
}
if (addr === "$last") {
for (let i = ast.lines.length - 1; i >= 0; i--) {
const l = ast.lines[i];
if (l !== undefined && l.kind === "value") {
return l;
}
}
return null;
}
const m = /^L(\d+)$/.exec(addr);
if (m === null || m[1] === undefined) {
return null;
}
const target = Number(m[1]);
for (const l of ast.lines) {
if (l.line === target) {
return l;
}
}
return null;
}
// ---------- YAML walker ----------------------------------------------------
function walkYaml(

View File

@@ -1,33 +0,0 @@
import { POS_FIRST, POS_LAST } from "../oc-path.js";
import type { JsonlAst, JsonlLine } from "./ast.js";
export function pickJsonlLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === POS_FIRST) {
for (const line of ast.lines) {
if (line.kind === "value") {
return line;
}
}
return null;
}
if (addr === POS_LAST) {
for (let index = ast.lines.length - 1; index >= 0; index -= 1) {
const line = ast.lines[index];
if (line !== undefined && line.kind === "value") {
return line;
}
}
return null;
}
const match = /^L(\d+)$/.exec(addr);
if (match === null || match[1] === undefined) {
return null;
}
const target = Number(match[1]);
for (const line of ast.lines) {
if (line.line === target) {
return line;
}
}
return null;
}

View File

@@ -18,9 +18,14 @@
import type { JsoncEntry, JsoncValue } from "../jsonc/ast.js";
import { resolveJsoncValueOcPath } from "../jsonc/resolve-value.js";
import type { OcPath } from "../oc-path.js";
import { isQuotedSeg, splitRespectingBrackets, unquoteSeg } from "../oc-path.js";
import {
POS_FIRST,
POS_LAST,
isQuotedSeg,
splitRespectingBrackets,
unquoteSeg,
} from "../oc-path.js";
import type { JsonlAst, JsonlLine } from "./ast.js";
import { pickJsonlLine } from "./line.js";
export type JsonlOcPathMatch =
| { readonly kind: "root"; readonly node: JsonlAst }
@@ -45,7 +50,7 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
return { kind: "root", node: ast };
}
const lineEntry = pickJsonlLine(ast, head);
const lineEntry = pickLine(ast, head);
if (lineEntry === null) {
return null;
}
@@ -85,3 +90,34 @@ export function resolveJsonlOcPath(ast: JsonlAst, path: OcPath): JsonlOcPathMatc
}
return { kind: "value", node: match.node, line: lineEntry.line, path: match.path };
}
function pickLine(ast: JsonlAst, addr: string): JsonlLine | null {
if (addr === POS_FIRST) {
for (const l of ast.lines) {
if (l.kind === "value") {
return l;
}
}
return null;
}
if (addr === POS_LAST) {
for (let i = ast.lines.length - 1; i >= 0; i--) {
const l = ast.lines[i];
if (l !== undefined && l.kind === "value") {
return l;
}
}
return null;
}
const m = /^L(\d+)$/.exec(addr);
if (m === null || m[1] === undefined) {
return null;
}
const target = Number(m[1]);
for (const l of ast.lines) {
if (l.line === target) {
return l;
}
}
return null;
}

View File

@@ -122,7 +122,7 @@ function explicitSessionsSpawnPrompt(token: string) {
return [
"Use sessions_spawn for this QA check.",
`task="${threadSubagentTask(token)}"`,
"label=qa-thread-subagent thread=true mode=session",
"label=qa-thread-subagent thread=true mode=session runTimeoutSeconds=30",
].join(" ");
}
@@ -219,27 +219,6 @@ describe("qa mock openai server", () => {
expect(debugPayload.plannedToolName).toBe("read");
});
it("returns a substantive private final fixture for the message-tool warning scenario", async () => {
const server = await startMockServer();
const body = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
model: "gpt-5.5",
input: [
makeUserInput(
"qa private final reply warning check. Reply to me directly in two complete sentences with `QA-STRANDED-85714` in the first sentence and a short explanation in the second sentence. Do NOT call any tool. Do NOT use the message tool.",
),
],
});
const text = body.output?.[0]?.content?.[0]?.text ?? "";
expect(text).toContain("QA-STRANDED-85714");
expect(text.length).toBeGreaterThanOrEqual(120);
expect(text.match(/[.!?]+(?:\s|$)/g)).toHaveLength(2);
});
it("emits deterministic text deltas for generic streaming QA prompts", async () => {
const server = await startMockServer();
@@ -663,62 +642,6 @@ describe("qa mock openai server", () => {
expect(debugPayload.plannedToolName).toBe("read");
});
it("reads unquoted fixture paths and honors exact replies after tool output", async () => {
const server = await startMockServer();
const prompt =
"Read large-cache-fixture.txt, verify it contains CACHE-FIXTURE-1600, then reply exactly QA-LARGE-CACHE-WARMUP-OK.";
const toolPlan = await expectResponsesText(server, {
stream: true,
input: [makeUserInput(prompt)],
});
expect(toolPlan).toContain('"name":"read"');
expect(toolPlan).toContain('"arguments":"{\\"path\\":\\"large-cache-fixture.txt\\"}"');
const completion = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
input: [
makeUserInput(prompt),
{
type: "function_call_output",
call_id: "call_mock_read_1",
output: "CACHE-FIXTURE-1600 stable tool-result evidence.",
},
],
});
expect(outputText(completion)).toBe("QA-LARGE-CACHE-WARMUP-OK");
});
it("preserves unquoted repo-scoped read targets", async () => {
const server = await startMockServer();
const toolPlan = await expectResponsesText(server, {
stream: true,
input: [makeUserInput("Read repo/qa/scenarios/index.yaml before continuing.")],
});
expect(toolPlan).toContain('"name":"read"');
expect(toolPlan).toContain('"arguments":"{\\"path\\":\\"repo/qa/scenarios/index.yaml\\"}"');
});
it("does not treat natural reply-exactly-with phrasing as a marker token", async () => {
const server = await startMockServer();
const response = await expectResponsesJson<{
output?: Array<{ content?: Array<{ text?: string }> }>;
}>(server, {
stream: false,
input: [
makeUserInput(
"Use qa-visible-skill now. Reply exactly with the visible skill marker and nothing else.",
),
],
});
expect(outputText(response)).toBe("VISIBLE-SKILL-OK");
});
it("drives the Lobster Invaders write flow and memory recall responses", async () => {
const server = await startQaMockOpenAiServer({
host: "127.0.0.1",
@@ -1637,7 +1560,6 @@ describe("qa mock openai server", () => {
expect(spawnArgs.label).toBe("qa-direct-fallback-worker");
expect(spawnArgs.thread).toBe(false);
expect(spawnArgs.mode).toBe("run");
expect(spawnArgs).not.toHaveProperty("runTimeoutSeconds");
const body = await expectResponsesText(server, {
stream: true,
@@ -1704,6 +1626,7 @@ describe("qa mock openai server", () => {
label: "qa-thread-subagent",
thread: true,
mode: "session",
runTimeoutSeconds: 30,
}),
},
{
@@ -1740,6 +1663,7 @@ describe("qa mock openai server", () => {
label: "qa-thread-subagent",
thread: true,
mode: "session",
runTimeoutSeconds: 30,
}),
},
{
@@ -3942,7 +3866,7 @@ describe("qa mock openai server", () => {
expect(toolUseBlock?.input.label).toBe("qa-thread-subagent");
expect(toolUseBlock?.input.thread).toBe(true);
expect(toolUseBlock?.input.mode).toBe("session");
expect(toolUseBlock?.input).not.toHaveProperty("runTimeoutSeconds");
expect(toolUseBlock?.input.runTimeoutSeconds).toBe(30);
const debugResponse = await fetch(`${server.baseUrl}/debug/last-request`);
expect(debugResponse.status).toBe(200);

View File

@@ -740,13 +740,6 @@ function readTargetFromPrompt(prompt: string) {
return repoScoped;
}
const loosePath = /\b[A-Za-z0-9._-]+\.(?:md|json|ts|tsx|js|mjs|cjs|txt|yaml|yml)\b/i
.exec(prompt)?.[0]
?.trim();
if (loosePath) {
return loosePath;
}
if (/\bdocs?\b/i.test(prompt)) {
return "repo/docs/help/testing.md";
}
@@ -913,6 +906,7 @@ function buildQaToolSearchArgs(targetTool: string, failureMode: boolean): Record
label: "runtime-tool-fixture",
mode: "run",
thread: false,
runTimeoutSeconds: 30,
};
}
if (targetTool === "memory_recall") {
@@ -965,10 +959,7 @@ function extractExactReplyDirective(text: string) {
if (backtickedMatch) {
return backtickedMatch;
}
return (
extractLastCapture(text, /reply(?: with)? exactly:\s*([^\n]+)/i) ??
extractLastCapture(text, /reply(?: with)? exactly\s+(?!with\b)([^\s`.,;:!?]+)/i)
);
return extractLastCapture(text, /reply(?: with)? exactly:\s*([^\n]+)/i);
}
function extractFinishExactlyDirective(text: string) {
@@ -1116,12 +1107,18 @@ function buildExplicitSessionsSpawnArgs(text: string): Record<string, unknown> |
const label = extractQuotedToolArg(text, "label") ?? extractBareToolArg(text, "label");
const mode = extractBareToolArg(text, "mode")?.toLowerCase();
const context = extractBareToolArg(text, "context")?.toLowerCase();
const runTimeoutSecondsRaw = extractBareToolArg(text, "runTimeoutSeconds");
const runTimeoutSeconds =
runTimeoutSecondsRaw && /^\d+$/.test(runTimeoutSecondsRaw)
? Number(runTimeoutSecondsRaw)
: undefined;
return {
task,
...(label ? { label } : {}),
...(extractBareToolArg(text, "thread")?.toLowerCase() === "true" ? { thread: true } : {}),
...(mode === "session" || mode === "run" ? { mode } : {}),
...(context === "fork" || context === "isolated" ? { context } : {}),
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
};
}
@@ -1340,15 +1337,9 @@ function buildAssistantText(
if (/silent snack recall check/i.test(prompt)) {
return "Protocol note: I do not have enough context to say what you usually want for QA movie night.";
}
if (/qa private final reply warning check/i.test(prompt)) {
return "QA-STRANDED-85714 is present in this private final reply and I am not calling the message tool. This second sentence makes the omitted delivery substantive enough for the warning check.";
}
if (/tool continuity check/i.test(prompt) && toolOutput) {
return `Protocol note: model switch handoff confirmed on ${model || "the requested model"}. QA mission from QA_KICKOFF_TASK.md still applies: understand this OpenClaw repo from source + docs before acting.`;
}
if (toolOutput && promptExactReplyDirective) {
return promptExactReplyDirective;
}
if ((toolOutput || allInputText) && /repo contract followthrough check/i.test(allInputText)) {
const repoEvidenceText = [scenarioToolOutput, allInputText].filter(Boolean).join("\n");
if (
@@ -2084,6 +2075,7 @@ async function buildResponsesPayload(
label: "qa-direct-fallback-worker",
thread: false,
mode: "run",
runTimeoutSeconds: 30,
});
}
if (toolOutput && canCallSessionsYield && !/\byielded\b/i.test(toolOutput)) {

View File

@@ -76,7 +76,6 @@ const qaTestFileScenarioExecutionSchema = z.discriminatedUnion("kind", [
qaTestFileScenarioExecutionBaseSchema.extend({ kind: z.literal("playwright") }),
qaTestFileScenarioExecutionBaseSchema.extend({
kind: z.literal("script"),
allowBlockedEvidence: z.boolean().optional(),
args: z.array(z.string()).optional(),
}),
]);

View File

@@ -89,64 +89,6 @@ async function makeTempRepo(prefix: string) {
return repoRoot;
}
async function writeScriptProducerEvidence(params: {
outputDir: string;
scenarioId?: string;
status: "blocked" | "fail" | "pass";
failureReason?: string;
}) {
const scenarioArtifactBase = path.join(params.outputDir, params.scenarioId ?? "scenario-script");
const runRoot = path.join(scenarioArtifactBase, "run-1");
await fs.mkdir(runRoot, { recursive: true });
await fs.writeFile(
path.join(runRoot, "qa-evidence.json"),
`${JSON.stringify(
{
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-14T00:00:00.000Z",
evidenceMode: "full",
entries: [
{
test: {
kind: "script-producer-check",
id: "script-producer.web-ui.smoke",
title: "Script producer: web-ui smoke",
source: { path: "scripts/evidence-producer.ts" },
},
coverage: [{ id: "ui.control", role: "primary" }],
execution: {
runner: "evidence-producer-script",
environment: { ref: "scenario-ref", os: "darwin", nodeVersion: "v24.0.0" },
provider: {
id: "script-producer",
live: false,
model: { name: null, ref: null },
fixture: "mocked-script-evidence",
},
packageSource: { kind: "source-checkout", sha: "abc123" },
artifacts: [],
},
result: {
status: params.status,
...(params.failureReason ? { failure: { reason: params.failureReason } } : {}),
timing: { wallMs: 1 },
},
},
],
},
null,
2,
)}\n`,
"utf8",
);
await fs.writeFile(
path.join(scenarioArtifactBase, "latest-run.json"),
`${JSON.stringify({ qaEvidence: path.join(runRoot, "qa-evidence.json") }, null, 2)}\n`,
"utf8",
);
}
describe("qa test file scenario runner", () => {
afterEach(async () => {
await Promise.all([
@@ -787,97 +729,6 @@ describe("qa test file scenario runner", () => {
});
});
it("fails script scenario results when imported producer evidence is blocked by default", async () => {
const repoRoot = await makeTempRepo("qa-script-producer-blocked-");
const outputDir = path.join(
repoRoot,
".artifacts",
"qa-e2e",
"scenario-script-producer-blocked",
);
const result = await runQaTestFileScenarios({
repoRoot,
outputDir,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [makeTestFileScenario("script", "scripts/evidence-producer.ts")],
runCommand: async () => {
await writeScriptProducerEvidence({
outputDir,
status: "blocked",
failureReason: "Playwright browser is missing.",
});
return {
exitCode: 0,
stdout: "script blocked\n",
stderr: "",
};
},
env: {
OPENCLAW_QA_REF: "scenario-ref",
} as NodeJS.ProcessEnv,
});
expect(result.results[0]).toMatchObject({
status: "blocked",
failureMessage: "Playwright browser is missing.",
});
});
it("allows blocked imported producer evidence for opt-in script scenarios", async () => {
const repoRoot = await makeTempRepo("qa-script-producer-blocked-allowed-");
const outputDir = path.join(
repoRoot,
".artifacts",
"qa-e2e",
"scenario-script-producer-blocked-allowed",
);
const scenario = makeTestFileScenario("script", "scripts/evidence-producer.ts");
if (scenario.execution.kind !== "script") {
throw new Error("expected script scenario");
}
scenario.execution.allowBlockedEvidence = true;
const result = await runQaTestFileScenarios({
repoRoot,
outputDir,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
scenarios: [scenario],
runCommand: async () => {
await writeScriptProducerEvidence({
outputDir,
status: "blocked",
failureReason: "Playwright browser is missing.",
});
return {
exitCode: 0,
stdout: "script blocked\n",
stderr: "",
};
},
env: {
OPENCLAW_QA_REF: "scenario-ref",
} as NodeJS.ProcessEnv,
});
expect(result.results[0]).toMatchObject({
status: "pass",
producerEvidence: {
entries: [
{
test: {
id: "script-producer.web-ui.smoke",
},
result: {
status: "blocked",
},
},
],
},
});
});
it("carries the suite profile into merged producer evidence", async () => {
const repoRoot = await makeTempRepo("qa-script-profile-");
const result = await runQaTestFileScenarios({

View File

@@ -502,25 +502,18 @@ async function runQaTestFileScenario(params: {
return {
...result,
...producerEvidenceResult,
...statusFromProducerEvidence({
allowBlockedEvidence: params.scenario.execution.allowBlockedEvidence === true,
producerEvidence: producerEvidenceResult.producerEvidence,
}),
...statusFromProducerEvidence(producerEvidenceResult.producerEvidence),
};
}
function statusFromProducerEvidence(params: {
allowBlockedEvidence: boolean;
producerEvidence: QaEvidenceSummaryJson | undefined;
}): Pick<QaTestFileScenarioResult, "failureMessage" | "status"> {
const { allowBlockedEvidence, producerEvidence } = params;
function statusFromProducerEvidence(
producerEvidence: QaEvidenceSummaryJson | undefined,
): Pick<QaTestFileScenarioResult, "failureMessage" | "status"> {
if (!producerEvidence || producerEvidence.entries.length === 0) {
return { status: "pass" };
}
const blockingEntry = producerEvidence.entries.find(
(entry) =>
entry.result.status === "fail" ||
(!allowBlockedEvidence && entry.result.status === "blocked"),
(entry) => entry.result.status === "fail" || entry.result.status === "blocked",
);
if (blockingEntry) {
return {

View File

@@ -44,6 +44,7 @@ vi.mock("playwright-core", () => ({
}));
import {
closeAllQaWebSessions,
closeQaWebSessions,
qaWebEvaluate,
qaWebOpenPage,
@@ -113,7 +114,7 @@ describe("qa web runtime", () => {
});
const snapshot = await qaWebSnapshot({ pageId: opened.pageId, maxChars: 5 });
const evaluated = await qaWebEvaluate({ pageId: opened.pageId, expression: "'ok'" });
await closeQaWebSessions();
await closeAllQaWebSessions();
const launchOptions = requireLaunchOptions();
expect(launchOptions?.channel).toBe("chrome");
@@ -143,7 +144,7 @@ describe("qa web runtime", () => {
);
const snapshot = await qaWebSnapshot({ pageId: second.pageId });
expect(snapshot.text).toBe("hello from body");
await closeQaWebSessions();
await closeAllQaWebSessions();
});
it("caps oversized web runtime timeouts", async () => {
@@ -165,7 +166,7 @@ describe("qa web runtime", () => {
expression: "'ok'",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
await closeQaWebSessions();
await closeAllQaWebSessions();
expect(goto).toHaveBeenCalledWith("http://127.0.0.1:3000/chat", {
waitUntil: "domcontentloaded",

View File

@@ -207,3 +207,7 @@ export async function closeQaWebSessions(pageIds?: Iterable<string>): Promise<vo
await session.browser.close().catch(() => {});
}
}
export async function closeAllQaWebSessions(): Promise<void> {
await closeQaWebSessions();
}

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