mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-14 01:58:47 +08:00
Compare commits
161 Commits
perf/main-
...
codex/secu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93fc591af9 | ||
|
|
4c23d1d597 | ||
|
|
8eb1fa09c6 | ||
|
|
2d2c1e63f0 | ||
|
|
6cdbccaa9e | ||
|
|
9f522ee7df | ||
|
|
7404b2b5b4 | ||
|
|
73aabcceda | ||
|
|
b1fc8673df | ||
|
|
4cf4e54179 | ||
|
|
84519f7e3c | ||
|
|
6314c377bb | ||
|
|
d3e7e03669 | ||
|
|
64f9f3c278 | ||
|
|
cd3eb438f0 | ||
|
|
26281a8a11 | ||
|
|
4208c89ec4 | ||
|
|
c9c19a1106 | ||
|
|
f78d7b52d8 | ||
|
|
ff6940036b | ||
|
|
b477bfe84b | ||
|
|
d4237cb14d | ||
|
|
20bc546d94 | ||
|
|
069cb8d636 | ||
|
|
8cc5d2d85c | ||
|
|
690f27749c | ||
|
|
7af8153388 | ||
|
|
a66a065ffb | ||
|
|
64d0fc8336 | ||
|
|
f3df863aff | ||
|
|
26b9736922 | ||
|
|
44f45d8729 | ||
|
|
1c655008cd | ||
|
|
618d78144e | ||
|
|
56f2102c28 | ||
|
|
99db98a7ce | ||
|
|
0dbfa1f6be | ||
|
|
f06f2f17c2 | ||
|
|
7bd533a80e | ||
|
|
561b293c7a | ||
|
|
21aa8faf8a | ||
|
|
b0bd9c8ed8 | ||
|
|
c5d599c8c4 | ||
|
|
3a1a5c0dac | ||
|
|
0849cac106 | ||
|
|
32ce06daf8 | ||
|
|
751d3db1cc | ||
|
|
4640baa299 | ||
|
|
c9f0bfd476 | ||
|
|
ab559a7257 | ||
|
|
7c08804541 | ||
|
|
991471b8ec | ||
|
|
7190fc4de8 | ||
|
|
9d9389bc6b | ||
|
|
6c88811b4b | ||
|
|
4a6666796f | ||
|
|
68222ba5e3 | ||
|
|
1bd783045b | ||
|
|
6cf06e8e7e | ||
|
|
ded3a93058 | ||
|
|
0063f3076c | ||
|
|
8c7e5c6918 | ||
|
|
e338037034 | ||
|
|
05796759ad | ||
|
|
d8b3e523ff | ||
|
|
4809ac70fa | ||
|
|
777edadb36 | ||
|
|
8d9ce35b92 | ||
|
|
69bf333dde | ||
|
|
e3a6da0f51 | ||
|
|
8ec1c0676b | ||
|
|
e4b6b9ea66 | ||
|
|
aba3751ad7 | ||
|
|
9921825e17 | ||
|
|
652e616a29 | ||
|
|
f385491c23 | ||
|
|
7387083a95 | ||
|
|
462092936a | ||
|
|
da4671ebcc | ||
|
|
9386d6214f | ||
|
|
8673c65c6b | ||
|
|
f3eb8e9714 | ||
|
|
f80f472190 | ||
|
|
3643de4ba7 | ||
|
|
41a9277844 | ||
|
|
79901fb4ba | ||
|
|
e728957989 | ||
|
|
d9124c9700 | ||
|
|
81c553e2fb | ||
|
|
0fc5a57a34 | ||
|
|
1bd04ac983 | ||
|
|
294779e5d6 | ||
|
|
888835cfe6 | ||
|
|
a716950a3c | ||
|
|
667bc2c4ca | ||
|
|
01b004c594 | ||
|
|
9cf1ef1d90 | ||
|
|
1c5099803f | ||
|
|
0d4968d466 | ||
|
|
0efe5857bc | ||
|
|
fed2c36611 | ||
|
|
bcc1105b30 | ||
|
|
b750d314b7 | ||
|
|
d4819948f3 | ||
|
|
4a3d06ee37 | ||
|
|
ff04e24ead | ||
|
|
a956ab8481 | ||
|
|
3b78d41a9e | ||
|
|
3c9c4aa428 | ||
|
|
6223a538bc | ||
|
|
8be3beec74 | ||
|
|
6a2ec62865 | ||
|
|
301213a05f | ||
|
|
9827490f5f | ||
|
|
575cae59d4 | ||
|
|
4e4dc10db0 | ||
|
|
34a1102506 | ||
|
|
1156ab637c | ||
|
|
0fc2faa0f4 | ||
|
|
fdf3667e09 | ||
|
|
de9260f813 | ||
|
|
4d45884419 | ||
|
|
1bea7d8ef3 | ||
|
|
008d785a80 | ||
|
|
eebcb100b8 | ||
|
|
b5295a6a34 | ||
|
|
3d6252a517 | ||
|
|
c4d3f0545c | ||
|
|
6b0525f237 | ||
|
|
287b10a895 | ||
|
|
ea813a2476 | ||
|
|
9be1699074 | ||
|
|
777f7409d8 | ||
|
|
2bec2caf0c | ||
|
|
047785eb30 | ||
|
|
f7ee25291a | ||
|
|
79d7defd0b | ||
|
|
68ec783e74 | ||
|
|
9a6c71a47d | ||
|
|
99d0bdc23a | ||
|
|
6fb0c940fa | ||
|
|
cdb55b3edb | ||
|
|
8d72cb9401 | ||
|
|
4f3c2cd2df | ||
|
|
16ea3f272f | ||
|
|
83705fba04 | ||
|
|
3883d7365e | ||
|
|
71d3d8bc74 | ||
|
|
0b80732137 | ||
|
|
038da08073 | ||
|
|
4b4211e6c7 | ||
|
|
1532b46e2a | ||
|
|
60e818f563 | ||
|
|
7e88c287a1 | ||
|
|
96e1b8c3ba | ||
|
|
43b4e27699 | ||
|
|
43b1088962 | ||
|
|
5d42ad6654 | ||
|
|
865fdab075 | ||
|
|
c692fabeba | ||
|
|
1e878dde7c |
@@ -317,6 +317,23 @@ pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Before tagging, diff publishable plugin package manifests against the last
|
||||
reachable stable/beta release tag. For every newly publishable package
|
||||
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
|
||||
package name did not exist in the base tag, verify the target registry package
|
||||
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
|
||||
package first. Do not hide or disable release surfaces just to unblock a
|
||||
train unless the owner explicitly decides the plugin should not ship in that
|
||||
release; first-package registry ownership is release prep, not product
|
||||
rollback. The mint/prepublish path must either be the real release publish
|
||||
path for the auto-bumped beta version, or a deliberately non-consuming
|
||||
registry-prep step that cannot occupy the next beta version/tag. Confirm
|
||||
registry owner, npm scope/package-creation permission, provenance path, and
|
||||
first-package publish plan before the full release publish continues. Useful
|
||||
npm probe:
|
||||
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
|
||||
a package newly added to the release is a release-prep blocker, not something
|
||||
to discover from the publish job.
|
||||
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
|
||||
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
|
||||
`otel-trace-smoke`, and checks span names plus content/identifier redaction
|
||||
@@ -562,7 +579,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
|
||||
does not support trusted publishing for `npm dist-tag add`.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
- Publishable plugins that are new to npm require owner-led first-package
|
||||
minting before the full release publish. Do not consume the next beta version
|
||||
with an ad-hoc manual package publish; use the release-owned auto-bumped
|
||||
version path, or a non-consuming registry setup/preflight step. Bundled
|
||||
disk-tree-only plugins stay unpublished.
|
||||
|
||||
## Fallback local mac publish
|
||||
|
||||
@@ -619,7 +640,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
mac app, signing, notarization, and appcast path.
|
||||
12. Confirm the target npm version is not already published.
|
||||
13. Create and push the git tag from the release branch.
|
||||
14. Create or refresh the matching GitHub release.
|
||||
14. Do not create or publish the matching GitHub release page yet. The real
|
||||
publish workflow creates or undrafts it only after postpublish verification
|
||||
and release evidence upload pass.
|
||||
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
|
||||
for the mock parity, live Matrix, and live Telegram credentialed-channel
|
||||
lanes to pass.
|
||||
@@ -642,20 +665,29 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
with `preflight_only=true` and wait for it to pass. Save that run id because
|
||||
the real publish requires it to reuse the notarized mac artifacts.
|
||||
21. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and matching GitHub release, recreate them from the fixed
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes. For pushed or
|
||||
published beta tags, do not delete/recreate; increment to the next beta tag.
|
||||
For preflight-only failures where npm did not publish the beta version,
|
||||
delete/recreate the same beta tag and prerelease at the fixed commit instead
|
||||
of skipping a prerelease number.
|
||||
delete the tag and any accidental draft/incomplete GitHub release, recreate
|
||||
the tag from the fixed commit, and rerun all relevant preflights from
|
||||
scratch before continuing. Never reuse old preflight results after the
|
||||
commit changes. Once the npm version exists, do not rerun the publish
|
||||
workflow for that same version; finalize the existing draft/evidence state
|
||||
manually or cut a correction tag. For pushed or published beta tags, do not
|
||||
delete/recreate; increment to the next beta tag. For preflight-only failures
|
||||
where npm did not publish the beta version, delete/recreate the same beta
|
||||
tag and any accidental draft/incomplete prerelease at the fixed commit
|
||||
instead of skipping a prerelease number.
|
||||
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
|
||||
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
|
||||
`latest` only when you intentionally want direct stable publish), keep it
|
||||
the same as the preflight run, and pass the successful npm
|
||||
`preflight_run_id`.
|
||||
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
24. Run postpublish verification:
|
||||
24. Wait for the real publish workflow to run postpublish verification,
|
||||
create or update the GitHub release as a draft, upload dependency evidence,
|
||||
append release verification proof, and only then undraft/publish it. If a
|
||||
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
|
||||
the release draft with OpenClaw npm evidence and exits red; do not undraft
|
||||
until the plugin publish gap is repaired. The standalone verifier command
|
||||
remains the recovery probe:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
25. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
|
||||
61
.github/codeql/codeql-process-exec-boundary-critical-security.yml
vendored
Normal file
61
.github/codeql/codeql-process-exec-boundary-critical-security.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: openclaw-codeql-process-exec-boundary-critical-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- src/process
|
||||
- src/tui/tui-local-shell.ts
|
||||
- src/tui/tui.ts
|
||||
- src/plugin-sdk/windows-spawn.ts
|
||||
- packages/agent-core/src/harness/env
|
||||
- packages/memory-host-sdk/src/host
|
||||
- extensions/acpx/src
|
||||
- extensions/bonjour/src/advertiser.ts
|
||||
- extensions/browser/src/browser/chrome-mcp.ts
|
||||
- extensions/browser/src/browser/chrome.executables.ts
|
||||
- extensions/browser/src/browser/chrome.ts
|
||||
- extensions/codex/src/app-server/sandbox-exec-server
|
||||
- extensions/codex/src/app-server/transport-stdio.ts
|
||||
- extensions/codex/src/node-cli-sessions.ts
|
||||
- extensions/codex-supervisor/src/json-rpc-client.ts
|
||||
- extensions/file-transfer/src
|
||||
- extensions/google-meet/src
|
||||
- extensions/imessage/src
|
||||
- extensions/memory-core/src/memory/qmd-manager.ts
|
||||
- extensions/memory-wiki/src/obsidian.ts
|
||||
- extensions/microsoft-foundry/cli.ts
|
||||
- extensions/ollama/src/wsl2-crash-loop-check.ts
|
||||
- extensions/qa-lab/src
|
||||
- extensions/signal/src/daemon.ts
|
||||
- extensions/tts-local-cli/speech-provider.ts
|
||||
- extensions/voice-call/src
|
||||
- scripts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*-runtime.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.spec.ts"
|
||||
- "**/*.spec.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
47
.github/workflows/codeql.yml
vendored
47
.github/workflows/codeql.yml
vendored
@@ -17,7 +17,28 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "extensions/acpx/src/**"
|
||||
- "extensions/bonjour/src/advertiser.ts"
|
||||
- "extensions/browser/src/browser/chrome-mcp.ts"
|
||||
- "extensions/browser/src/browser/chrome.executables.ts"
|
||||
- "extensions/browser/src/browser/chrome.ts"
|
||||
- "extensions/codex/src/app-server/sandbox-exec-server/**"
|
||||
- "extensions/codex/src/app-server/transport-stdio.ts"
|
||||
- "extensions/codex/src/node-cli-sessions.ts"
|
||||
- "extensions/codex-supervisor/src/json-rpc-client.ts"
|
||||
- "extensions/file-transfer/src/**"
|
||||
- "extensions/google-meet/src/**"
|
||||
- "extensions/imessage/src/**"
|
||||
- "extensions/memory-core/src/memory/qmd-manager.ts"
|
||||
- "extensions/memory-wiki/src/obsidian.ts"
|
||||
- "extensions/microsoft-foundry/cli.ts"
|
||||
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
|
||||
- "extensions/qa-lab/src/**"
|
||||
- "extensions/signal/src/daemon.ts"
|
||||
- "extensions/tts-local-cli/speech-provider.ts"
|
||||
- "extensions/voice-call/src/**"
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
push:
|
||||
branches:
|
||||
@@ -26,7 +47,28 @@ on:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "extensions/acpx/src/**"
|
||||
- "extensions/bonjour/src/advertiser.ts"
|
||||
- "extensions/browser/src/browser/chrome-mcp.ts"
|
||||
- "extensions/browser/src/browser/chrome.executables.ts"
|
||||
- "extensions/browser/src/browser/chrome.ts"
|
||||
- "extensions/codex/src/app-server/sandbox-exec-server/**"
|
||||
- "extensions/codex/src/app-server/transport-stdio.ts"
|
||||
- "extensions/codex/src/node-cli-sessions.ts"
|
||||
- "extensions/codex-supervisor/src/json-rpc-client.ts"
|
||||
- "extensions/file-transfer/src/**"
|
||||
- "extensions/google-meet/src/**"
|
||||
- "extensions/imessage/src/**"
|
||||
- "extensions/memory-core/src/memory/qmd-manager.ts"
|
||||
- "extensions/memory-wiki/src/obsidian.ts"
|
||||
- "extensions/microsoft-foundry/cli.ts"
|
||||
- "extensions/ollama/src/wsl2-crash-loop-check.ts"
|
||||
- "extensions/qa-lab/src/**"
|
||||
- "extensions/signal/src/daemon.ts"
|
||||
- "extensions/tts-local-cli/speech-provider.ts"
|
||||
- "extensions/voice-call/src/**"
|
||||
- "packages/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
@@ -73,6 +115,11 @@ jobs:
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: process-exec-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-process-exec-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
@@ -437,8 +437,17 @@ jobs:
|
||||
echo "::warning::Could not generate motion-trimmed desktop previews; continuing with screenshots and full MP4 links."
|
||||
fi
|
||||
|
||||
baseline_status="$(jq -r '.scenarios[0].status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[0].status' "$root/candidate/discord-qa-summary.json")"
|
||||
read_discord_status_reaction_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[0].result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[0].status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_status_reaction_status baseline)"
|
||||
candidate_status="$(read_discord_status_reaction_status candidate)"
|
||||
|
||||
jq -n \
|
||||
--arg baseline_status "$baseline_status" \
|
||||
|
||||
@@ -451,8 +451,17 @@ jobs:
|
||||
|
||||
capture_candidate_discord_web
|
||||
|
||||
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
|
||||
read_discord_thread_attachment_status() {
|
||||
local lane="$1"
|
||||
if [[ -f "$root/$lane/qa-evidence.json" ]]; then
|
||||
jq -r '.entries[] | select(.test.id == "discord-thread-reply-filepath-attachment") | .result.status' "$root/$lane/qa-evidence.json"
|
||||
return
|
||||
fi
|
||||
jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/$lane/discord-qa-summary.json"
|
||||
}
|
||||
|
||||
baseline_status="$(read_discord_thread_attachment_status baseline)"
|
||||
candidate_status="$(read_discord_thread_attachment_status candidate)"
|
||||
comparison_status="fail"
|
||||
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
|
||||
comparison_status="pass"
|
||||
|
||||
4
.github/workflows/mantis-telegram-live.yml
vendored
4
.github/workflows/mantis-telegram-live.yml
vendored
@@ -445,8 +445,8 @@ jobs:
|
||||
telegram_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce a summary." >&2
|
||||
if [[ ! -f "$root/qa-evidence.json" && ! -f "$root/telegram-qa-summary.json" ]]; then
|
||||
echo "Telegram live QA did not produce an evidence summary." >&2
|
||||
exit "$telegram_exit"
|
||||
fi
|
||||
echo "telegram_exit=${telegram_exit}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1748,6 +1748,7 @@ jobs:
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
@@ -1836,7 +1837,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
all_providers=(anthropic google minimax openai opencode-go openrouter xai zai fireworks)
|
||||
all_providers=(anthropic google minimax moonshot openai opencode-go openrouter xai zai fireworks)
|
||||
|
||||
normalize_provider() {
|
||||
local value="${1,,}"
|
||||
@@ -1922,6 +1923,7 @@ jobs:
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
openai) require_any OpenAI OPENAI_API_KEY ;;
|
||||
opencode-go) require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY ;;
|
||||
openrouter) require_any OpenRouter OPENROUTER_API_KEY ;;
|
||||
|
||||
9
.github/workflows/openclaw-performance.yml
vendored
9
.github/workflows/openclaw-performance.yml
vendored
@@ -527,6 +527,13 @@ jobs:
|
||||
cleanup_gateway
|
||||
trap - EXIT
|
||||
|
||||
if node -e "const fs=require('node:fs'); const scripts=require('./package.json').scripts||{}; process.exit(scripts['test:sqlite:perf:smoke'] && fs.existsSync('scripts/bench-sqlite-state.ts') ? 0 : 1)"; then
|
||||
pnpm test:sqlite:perf:smoke
|
||||
cp .artifacts/sqlite-perf/smoke.json "$SOURCE_PERF_DIR/sqlite-perf-smoke.json"
|
||||
else
|
||||
echo "SQLite state smoke probe is not available in ${TESTED_REF}; continuing with the remaining source probes." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
summary_args=(node "$PERFORMANCE_HELPER_DIR/scripts/openclaw-performance-source-summary.mjs" \
|
||||
--source-dir "$SOURCE_PERF_DIR" \
|
||||
--output "$SOURCE_PERF_DIR/index.md")
|
||||
@@ -604,7 +611,7 @@ jobs:
|
||||
|
||||
## Source probes
|
||||
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, and CLI startup numbers are in [source/index.md](source/index.md).
|
||||
Additional gateway boot, memory, plugin pressure, mock hello-loop, CLI startup, and SQLite state smoke numbers are in [source/index.md](source/index.md).
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
305
.github/workflows/openclaw-release-publish.yml
vendored
305
.github/workflows/openclaw-release-publish.yml
vendored
@@ -387,7 +387,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dispatch_workflow() {
|
||||
dispatch_workflow_at_ref() {
|
||||
local workflow_ref="$1"
|
||||
shift
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
@@ -397,7 +399,7 @@ jobs:
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
run_id="$(
|
||||
printf '%s\n' "$dispatch_output" |
|
||||
@@ -432,6 +434,10 @@ jobs:
|
||||
printf '%s\n' "${run_id}"
|
||||
}
|
||||
|
||||
dispatch_workflow() {
|
||||
dispatch_workflow_at_ref "$CHILD_WORKFLOW_REF" "$@"
|
||||
}
|
||||
|
||||
print_pending_deployments() {
|
||||
local workflow="$1"
|
||||
local run_id="$2"
|
||||
@@ -653,6 +659,128 @@ jobs:
|
||||
done
|
||||
}
|
||||
|
||||
guard_existing_public_release() {
|
||||
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
|
||||
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
|
||||
if [[ "${is_draft}" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
asset_name="openclaw-${release_version}-dependency-evidence.zip"
|
||||
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
|
||||
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
|
||||
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
|
||||
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
|
||||
|
||||
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
{
|
||||
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
|
||||
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
|
||||
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
|
||||
} >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
guard_openclaw_npm_not_already_published() {
|
||||
local release_version release_url
|
||||
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
|
||||
{
|
||||
echo "openclaw@${release_version} is already published on npm."
|
||||
echo "Refusing to dispatch publish child workflows for an already-published version."
|
||||
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
|
||||
echo "Release page, if present: ${release_url}"
|
||||
} >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_clawhub_release_plan() {
|
||||
local -a plan_args
|
||||
|
||||
clawhub_plan_path="${RUNNER_TEMP}/openclaw-release-clawhub-plan.json"
|
||||
plan_args=(
|
||||
--release-tag "${RELEASE_TAG}"
|
||||
--release-publish-branch "${CHILD_WORKFLOW_REF}"
|
||||
--release-publish-run-id "${GITHUB_RUN_ID}"
|
||||
--plugin-publish-scope "${PLUGIN_PUBLISH_SCOPE}"
|
||||
)
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
plan_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
|
||||
CLAWHUB_REGISTRY="${CLAWHUB_REGISTRY:-https://clawhub.ai}" \
|
||||
node --import tsx scripts/openclaw-release-clawhub-plan.ts "${plan_args[@]}" > "${clawhub_plan_path}"
|
||||
|
||||
echo "Resolved OpenClaw release ClawHub dispatch plan:"
|
||||
cat "${clawhub_plan_path}"
|
||||
|
||||
clawhub_workflow_ref="$(jq -r '.clawHubWorkflowRef' "${clawhub_plan_path}")"
|
||||
normal_plugins="$(jq -r '.summary.normalPlugins' "${clawhub_plan_path}")"
|
||||
bootstrap_plugins="$(jq -r '.summary.bootstrapPlugins' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugins="$(jq -r '.summary.missingTrustedPlugins' "${clawhub_plan_path}")"
|
||||
normal_plugin_count="$(jq -r '.summary.normalCount' "${clawhub_plan_path}")"
|
||||
bootstrap_plugin_count="$(jq -r '.summary.bootstrapCount' "${clawhub_plan_path}")"
|
||||
missing_trusted_plugin_count="$(jq -r '.summary.missingTrustedPublisherCount' "${clawhub_plan_path}")"
|
||||
|
||||
{
|
||||
echo "### ClawHub release plan"
|
||||
echo
|
||||
echo "- Normal OIDC candidates: \`${normal_plugin_count}\`"
|
||||
echo "- Bootstrap/repair candidates: \`${bootstrap_plugin_count}\`"
|
||||
echo "- Existing-package trusted-publisher repairs: \`${missing_trusted_plugin_count}\`"
|
||||
if [[ -n "${normal_plugins}" ]]; then
|
||||
echo "- Normal plugins: \`${normal_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${bootstrap_plugins}" ]]; then
|
||||
echo "- Bootstrap/repair plugins: \`${bootstrap_plugins}\`"
|
||||
fi
|
||||
if [[ -n "${missing_trusted_plugins}" ]]; then
|
||||
echo "- Trusted-publisher repair plugins: \`${missing_trusted_plugins}\`"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
append_clawhub_dispatch_args() {
|
||||
local target="$1"
|
||||
while IFS=$'\t' read -r key value; do
|
||||
clawhub_dispatch_args+=(-f "${key}=${value}")
|
||||
done < <(jq -r --arg target "${target}" '.[$target].inputs | to_entries[] | [.key, .value] | @tsv' "${clawhub_plan_path}")
|
||||
}
|
||||
|
||||
write_clawhub_runtime_state() {
|
||||
local force_skip_clawhub="$1"
|
||||
local output_path="$2"
|
||||
node --import tsx scripts/openclaw-release-clawhub-runtime-state.ts \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--wait-for-clawhub "${WAIT_FOR_CLAWHUB}" \
|
||||
--force-skip-clawhub "${force_skip_clawhub}" \
|
||||
--normal-run-id "${plugin_clawhub_run_id:-}" \
|
||||
--bootstrap-run-id "${plugin_clawhub_bootstrap_run_id:-}" \
|
||||
--bootstrap-completed "${plugin_clawhub_bootstrap_completed:-false}" > "${output_path}"
|
||||
}
|
||||
|
||||
create_or_update_github_release() {
|
||||
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
@@ -698,11 +826,17 @@ jobs:
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
|
||||
--verify-tag \
|
||||
--draft \
|
||||
--title "${title}" \
|
||||
--notes-file "${notes_file}" \
|
||||
"${prerelease_args[@]}" \
|
||||
"${latest_arg}"
|
||||
fi
|
||||
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
publish_github_release() {
|
||||
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
@@ -735,9 +869,11 @@ jobs:
|
||||
}
|
||||
|
||||
verify_published_release() {
|
||||
local release_version evidence_path
|
||||
local release_version evidence_path skip_clawhub clawhub_runtime_state_path
|
||||
local -a verify_args
|
||||
|
||||
skip_clawhub="${1:-false}"
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
|
||||
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
|
||||
@@ -750,16 +886,18 @@ jobs:
|
||||
--dist-tag "${RELEASE_NPM_DIST_TAG}"
|
||||
--repo "${GITHUB_REPOSITORY}"
|
||||
--workflow-ref "${CHILD_WORKFLOW_REF}"
|
||||
--clawhub-workflow-ref "${clawhub_workflow_ref}"
|
||||
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
|
||||
--plugin-npm-run "${plugin_npm_run_id}"
|
||||
--openclaw-npm-run "${openclaw_npm_run_id}"
|
||||
--evidence-out "${evidence_path}"
|
||||
--skip-github-release
|
||||
)
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
|
||||
else
|
||||
verify_args+=(--skip-clawhub)
|
||||
fi
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-verify.json"
|
||||
write_clawhub_runtime_state "${skip_clawhub}" "${clawhub_runtime_state_path}"
|
||||
while IFS= read -r arg; do
|
||||
verify_args+=("${arg}")
|
||||
done < <(jq -r '.verifierArgs[]' "${clawhub_runtime_state_path}")
|
||||
if [[ -n "${PLUGINS// }" ]]; then
|
||||
verify_args+=(--plugins "${PLUGINS}")
|
||||
fi
|
||||
@@ -775,7 +913,7 @@ jobs:
|
||||
}
|
||||
|
||||
append_release_proof_to_github_release() {
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
|
||||
local release_version body_file notes_file tarball integrity telegram_line clawhub_line clawhub_bootstrap_line clawhub_runtime_state_path
|
||||
|
||||
release_version="${RELEASE_TAG#v}"
|
||||
body_file="${RUNNER_TEMP}/release-body.md"
|
||||
@@ -789,16 +927,16 @@ jobs:
|
||||
else
|
||||
telegram_line="- npm Telegram beta E2E: not supplied"
|
||||
fi
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
else
|
||||
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
|
||||
fi
|
||||
clawhub_runtime_state_path="${RUNNER_TEMP}/openclaw-release-clawhub-runtime-state-proof.json"
|
||||
write_clawhub_runtime_state false "${clawhub_runtime_state_path}"
|
||||
clawhub_line="$(jq -r '.proofLines.normal' "${clawhub_runtime_state_path}")"
|
||||
clawhub_bootstrap_line="$(jq -r '.proofLines.bootstrap' "${clawhub_runtime_state_path}")"
|
||||
|
||||
RELEASE_BODY_FILE="${body_file}" \
|
||||
RELEASE_NOTES_FILE="${notes_file}" \
|
||||
RELEASE_VERSION="${release_version}" \
|
||||
RELEASE_TAG="${RELEASE_TAG}" \
|
||||
RELEASE_SHA="${TARGET_SHA}" \
|
||||
RELEASE_REPO="${GITHUB_REPOSITORY}" \
|
||||
RELEASE_TARBALL="${tarball}" \
|
||||
RELEASE_INTEGRITY="${integrity}" \
|
||||
@@ -808,6 +946,7 @@ jobs:
|
||||
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
|
||||
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
|
||||
CLAWHUB_LINE="${clawhub_line}" \
|
||||
CLAWHUB_BOOTSTRAP_LINE="${clawhub_bootstrap_line}" \
|
||||
TELEGRAM_LINE="${telegram_line}" \
|
||||
node --input-type=module <<'NODE'
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -825,12 +964,14 @@ jobs:
|
||||
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
|
||||
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
|
||||
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
|
||||
`- release SHA: \`${process.env.RELEASE_SHA}\``,
|
||||
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
|
||||
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
|
||||
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
|
||||
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
|
||||
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
|
||||
process.env.CLAWHUB_LINE,
|
||||
process.env.CLAWHUB_BOOTSTRAP_LINE,
|
||||
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
|
||||
process.env.TELEGRAM_LINE,
|
||||
].join("\n");
|
||||
@@ -847,6 +988,7 @@ jobs:
|
||||
echo "### Publish sequence"
|
||||
echo
|
||||
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
|
||||
echo "- ClawHub workflow ref: release tag \`${RELEASE_TAG}\`"
|
||||
echo "- Release tag: \`${RELEASE_TAG}\`"
|
||||
echo "- Release SHA: \`${TARGET_SHA}\`"
|
||||
echo "- Release approval: this workflow job"
|
||||
@@ -863,26 +1005,68 @@ jobs:
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
guard_existing_public_release
|
||||
guard_openclaw_npm_not_already_published
|
||||
resolve_clawhub_release_plan
|
||||
|
||||
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
|
||||
if [[ -n "${PLUGINS}" ]]; then
|
||||
npm_args+=(-f plugins="${PLUGINS}")
|
||||
clawhub_args+=(-f plugins="${PLUGINS}")
|
||||
fi
|
||||
|
||||
plugin_npm_run_id="$(dispatch_workflow plugin-npm-release.yml "${npm_args[@]}")"
|
||||
plugin_clawhub_run_id="$(dispatch_workflow plugin-clawhub-release.yml "${clawhub_args[@]}")"
|
||||
plugin_clawhub_run_id=""
|
||||
if [[ "$(jq -r '.normal.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args normal
|
||||
plugin_clawhub_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.normal.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.normal.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
plugin_clawhub_bootstrap_run_id=""
|
||||
plugin_clawhub_bootstrap_completed="false"
|
||||
if [[ "$(jq -r '.bootstrap.shouldDispatch' "${clawhub_plan_path}")" == "true" ]]; then
|
||||
clawhub_dispatch_args=()
|
||||
append_clawhub_dispatch_args bootstrap
|
||||
plugin_clawhub_bootstrap_run_id="$(dispatch_workflow_at_ref \
|
||||
"$(jq -r '.bootstrap.ref' "${clawhub_plan_path}")" \
|
||||
"$(jq -r '.bootstrap.workflow' "${clawhub_plan_path}")" \
|
||||
"${clawhub_dispatch_args[@]}")"
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap candidates" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
{
|
||||
echo "- Plugin npm run ID: \`${plugin_npm_run_id}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id}\`"
|
||||
echo "- Plugin ClawHub run ID: \`${plugin_clawhub_run_id:-none}\`"
|
||||
echo "- Plugin ClawHub bootstrap run ID: \`${plugin_clawhub_bootstrap_run_id:-none}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if ! wait_for_run plugin-npm-release.yml "${plugin_npm_run_id}"; then
|
||||
echo "Plugin npm publish failed; cancelling ClawHub publish child ${plugin_clawhub_run_id}." >&2
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
echo "Plugin npm publish failed; cancelling dispatched ClawHub child workflows." >&2
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_bootstrap_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" && "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
echo "Waiting for plugin-clawhub-new.yml bootstrap to finish before continuing release publish."
|
||||
if wait_for_run plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
plugin_clawhub_bootstrap_completed="true"
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
gh run cancel --repo "$GITHUB_REPOSITORY" "${plugin_clawhub_run_id}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
openclaw_npm_run_id=""
|
||||
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
|
||||
openclaw_npm_run_id="$(dispatch_workflow openclaw-npm-release.yml \
|
||||
@@ -899,19 +1083,52 @@ jobs:
|
||||
|
||||
clawhub_result=""
|
||||
clawhub_pid=""
|
||||
clawhub_bootstrap_result=""
|
||||
clawhub_bootstrap_pid=""
|
||||
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
clawhub_result="$RUNNER_TEMP/clawhub-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
|
||||
clawhub_pid="${wait_run_pid}"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
clawhub_bootstrap_result="$RUNNER_TEMP/clawhub-bootstrap-result.txt"
|
||||
wait_run_pid=""
|
||||
wait_for_run_background plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "${clawhub_bootstrap_result}"
|
||||
clawhub_bootstrap_pid="${wait_run_pid}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [[ -n "${plugin_clawhub_run_id}" ]]; then
|
||||
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "- plugin-clawhub-release.yml: no normal OIDC publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [[ -n "${plugin_clawhub_bootstrap_run_id}" ]]; then
|
||||
if [[ "${plugin_clawhub_bootstrap_completed}" == "true" ]]; then
|
||||
echo "- plugin-clawhub-new.yml: bootstrap already completed before continuing" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
wait_for_job_success plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}" "Validate release publish approval"
|
||||
if approve_child_publish_environment plugin-clawhub-new.yml "${plugin_clawhub_bootstrap_run_id}"; then
|
||||
:
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: child environment gate not ready; bootstrap was left dispatched (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-new.yml: bootstrap not awaited (${plugin_clawhub_bootstrap_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
else
|
||||
echo "- plugin-clawhub-new.yml: no bootstrap publish to await" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
openclaw_result=""
|
||||
@@ -934,21 +1151,33 @@ jobs:
|
||||
openclaw_failed=1
|
||||
fi
|
||||
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
fi
|
||||
|
||||
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -n "${clawhub_bootstrap_pid}" ]] && ! wait "${clawhub_bootstrap_pid}"; then
|
||||
failed=1
|
||||
fi
|
||||
if [[ -f "${clawhub_bootstrap_result}" && "$(cat "${clawhub_bootstrap_result}")" != "success" ]]; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
|
||||
verify_published_release
|
||||
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
verify_published_release
|
||||
else
|
||||
verify_published_release true
|
||||
fi
|
||||
create_or_update_github_release
|
||||
upload_dependency_evidence_release_asset
|
||||
append_release_proof_to_github_release
|
||||
if [[ "${failed}" == "0" ]]; then
|
||||
publish_github_release
|
||||
else
|
||||
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
if [[ "${failed}" != "0" ]]; then
|
||||
exit 1
|
||||
|
||||
504
.github/workflows/plugin-clawhub-new.yml
vendored
Normal file
504
.github/workflows/plugin-clawhub-new.yml
vendored
Normal file
@@ -0,0 +1,504 @@
|
||||
name: Plugin ClawHub New
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
plugins:
|
||||
description: Comma-separated plugin package names to bootstrap on ClawHub
|
||||
required: true
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_publish_run_id:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the token-gated ClawHub bootstrap handoff without publishing.
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: plugin-clawhub-new-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
|
||||
jobs:
|
||||
resolve_bootstrap_plan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve checked-out ref
|
||||
id: ref
|
||||
env:
|
||||
TARGET_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
if [[ -n "${TARGET_REF}" ]]; then
|
||||
if git rev-parse --verify --quiet "${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "${TARGET_REF}^{commit}")"
|
||||
elif git rev-parse --verify --quiet "origin/${TARGET_REF}^{commit}" >/dev/null; then
|
||||
target_sha="$(git rev-parse "origin/${TARGET_REF}^{commit}")"
|
||||
else
|
||||
echo "Unable to resolve requested publish ref: ${TARGET_REF}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git checkout --detach "${target_sha}"
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r release_ref; do
|
||||
if git merge-base --is-ancestor HEAD "${release_ref}"; then
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "Plugin ClawHub bootstraps must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
|
||||
exit 1
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "false"
|
||||
|
||||
- name: Validate publishable plugin metadata
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PLUGINS// }" ]]; then
|
||||
echo "Plugin ClawHub bootstrap requires at least one package name in plugins." >&2
|
||||
exit 1
|
||||
fi
|
||||
pnpm release:plugins:clawhub:check -- --selection-mode selected --plugins "${RELEASE_PLUGINS}"
|
||||
|
||||
- name: Resolve plugin bootstrap plan
|
||||
id: plan
|
||||
env:
|
||||
RELEASE_PLUGINS: ${{ inputs.plugins }}
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .local
|
||||
node --import tsx scripts/plugin-clawhub-release-plan.ts \
|
||||
--selection-mode selected \
|
||||
--plugins "${RELEASE_PLUGINS}" > .local/plugin-clawhub-release-plan.json
|
||||
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
bootstrap_candidate_count="$(jq -r '(.bootstrapCandidates | length) + (.missingTrustedPublisher | length)' .local/plugin-clawhub-release-plan.json)"
|
||||
selected_count="$(jq -r '.all | length' .local/plugin-clawhub-release-plan.json)"
|
||||
matrix_json="$(
|
||||
jq -c '
|
||||
[
|
||||
.bootstrapCandidates[]? + {
|
||||
bootstrapMode: "publish",
|
||||
requiresManualOverride: false
|
||||
},
|
||||
.missingTrustedPublisher[]? + {
|
||||
bootstrapMode: (if .alreadyPublished then "configure-only" else "publish" end),
|
||||
requiresManualOverride: true
|
||||
}
|
||||
]
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
|
||||
invalid_scope="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.packageName | startswith("@openclaw/") | not)
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid_scope}" ]]; then
|
||||
echo "Plugin ClawHub bootstrap only supports @openclaw/* packages." >&2
|
||||
printf '%s\n' "${invalid_scope}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
not_bootstrap="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates | map(.packageName)) as $bootstrapNames
|
||||
| (.missingTrustedPublisher | map(.packageName)) as $repairNames
|
||||
| .all[]?
|
||||
| select(.packageName as $name | ($bootstrapNames + $repairNames | index($name) | not))
|
||||
| "- \(.packageName)@\(.version)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${not_bootstrap}" ]]; then
|
||||
echo "Selected packages must all be first-publish bootstrap candidates or trusted-publisher repair candidates." >&2
|
||||
printf '%s\n' "${not_bootstrap}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${selected_count}" == "0" || "${bootstrap_candidate_count}" == "0" ]]; then
|
||||
echo "No selected packages require ClawHub bootstrap." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "matrix=${matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "ClawHub bootstrap candidates:"
|
||||
jq -r '
|
||||
.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
echo "ClawHub trusted-publisher repair candidates:"
|
||||
jq -r '
|
||||
.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir), alreadyPublished=\(.alreadyPublished)"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '
|
||||
(.bootstrapCandidates[]?, .missingTrustedPublisher[]?)
|
||||
| select(.publishTag != "alpha" or .channel != "alpha")
|
||||
| "- \(.packageName)@\(.version) [\(.publishTag)]"
|
||||
' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
if [[ -n "${invalid}" ]]; then
|
||||
echo "Tideclaw alpha ClawHub bootstraps may only publish alpha plugin versions." >&2
|
||||
printf '%s\n' "${invalid}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: resolve_bootstrap_plan
|
||||
if: github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Validate release publish approval run
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
|
||||
echo "Plugin ClawHub bootstrap dispatched by another workflow must include release_publish_run_id." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Direct Plugin ClawHub New dispatch; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
exit 0
|
||||
fi
|
||||
direct_recovery=false
|
||||
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
|
||||
direct_recovery=true
|
||||
echo "Direct Plugin ClawHub New recovery with release_publish_run_id; relying on this workflow's clawhub-plugin-bootstrap environment approval."
|
||||
fi
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
validate_bootstrap_trusted_publisher_cli:
|
||||
needs: [resolve_bootstrap_plan, validate_release_publish_approval]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate pinned ClawHub trusted publisher CLI support
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
help_output="$(
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set --help 2>&1 || true
|
||||
)"
|
||||
printf '%s\n' "${help_output}"
|
||||
if ! grep -Fq "Usage: clawhub package trusted-publisher set" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} to expose 'package trusted-publisher set' before token bootstrap publish can run. The pinned CLI returned parent help or no set command, so this workflow is stopping before creating a ClawHub package row."
|
||||
exit 1
|
||||
fi
|
||||
for required_flag in --repository --workflow-filename; do
|
||||
if ! grep -Fq -- "${required_flag}" <<<"${help_output}"; then
|
||||
echo "::error::CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows requires ${CLAWHUB_CLI_PACKAGE} trusted-publisher set help to include ${required_flag}."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
publish_bootstrap_plugins:
|
||||
needs:
|
||||
[
|
||||
resolve_bootstrap_plan,
|
||||
validate_release_publish_approval,
|
||||
validate_bootstrap_trusted_publisher_cli,
|
||||
]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true' && needs.validate_release_publish_approval.result == 'success' && (inputs.dry_run == true || needs.validate_bootstrap_trusted_publisher_cli.result == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-bootstrap
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
EOF
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Write ClawHub token config
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
config_path="${RUNNER_TEMP}/clawhub-config.json"
|
||||
CONFIG_PATH="${config_path}" node --input-type=module <<'NODE'
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
const registry = process.env.CLAWHUB_REGISTRY?.trim();
|
||||
const token = process.env.CLAWHUB_TOKEN?.trim();
|
||||
const configPath = process.env.CONFIG_PATH;
|
||||
if (!registry) {
|
||||
throw new Error("CLAWHUB_REGISTRY is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!token) {
|
||||
throw new Error("CLAWHUB_TOKEN is required for token-gated ClawHub bootstrap.");
|
||||
}
|
||||
if (!configPath) {
|
||||
throw new Error("CONFIG_PATH is required.");
|
||||
}
|
||||
|
||||
writeFileSync(configPath, `${JSON.stringify({ registry, token }, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
NODE
|
||||
echo "CLAWHUB_CONFIG_PATH=${config_path}" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Publish ClawHub bootstrap package
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.resolve_bootstrap_plan.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
BOOTSTRAP_MODE: ${{ matrix.plugin.bootstrapMode }}
|
||||
REQUIRES_MANUAL_OVERRIDE: ${{ matrix.plugin.requiresManualOverride && 'true' || 'false' }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD: "0"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${BOOTSTRAP_MODE}" == "configure-only" ]]; then
|
||||
echo "Skipping bootstrap publish because ${PACKAGE_DIR} version is already present on ClawHub; configuring trusted publisher only."
|
||||
elif [[ "${DRY_RUN}" == "true" ]]; then
|
||||
bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
else
|
||||
if [[ "${REQUIRES_MANUAL_OVERRIDE}" == "true" ]]; then
|
||||
export OPENCLAW_CLAWHUB_MANUAL_OVERRIDE_REASON="GitHub Actions trusted publisher repair before OIDC migration"
|
||||
fi
|
||||
bash scripts/plugin-clawhub-publish.sh --publish "${PACKAGE_DIR}"
|
||||
fi
|
||||
|
||||
- name: Configure trusted publisher for normal OIDC releases
|
||||
if: inputs.dry_run != true
|
||||
env:
|
||||
CLAWHUB_CLI_PACKAGE: ${{ env.CLAWHUB_CLI_PACKAGE }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- \
|
||||
clawhub package trusted-publisher set "${PACKAGE_NAME}" \
|
||||
--repository openclaw/openclaw \
|
||||
--workflow-filename plugin-clawhub-release.yml
|
||||
|
||||
verify_bootstrap_clawhub_package:
|
||||
needs: [resolve_bootstrap_plan, publish_bootstrap_plugins]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.resolve_bootstrap_plan.outputs.has_bootstrap_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.resolve_bootstrap_plan.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Verify bootstrap ClawHub package and trusted publisher
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
|
||||
PACKAGE_VERSION: ${{ matrix.plugin.version }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --input-type=module <<'EOF'
|
||||
const registry = (process.env.CLAWHUB_REGISTRY ?? "https://clawhub.ai").replace(/\/+$/, "");
|
||||
const packageName = process.env.PACKAGE_NAME;
|
||||
const packageVersion = process.env.PACKAGE_VERSION;
|
||||
const packageTag = process.env.PACKAGE_TAG;
|
||||
if (!packageName || !packageVersion || !packageTag) {
|
||||
throw new Error("Missing ClawHub bootstrap verification env.");
|
||||
}
|
||||
const encodedName = encodeURIComponent(packageName);
|
||||
const encodedVersion = encodeURIComponent(packageVersion);
|
||||
const detailUrl = `${registry}/api/v1/packages/${encodedName}`;
|
||||
const trustedPublisherUrl = `${detailUrl}/trusted-publisher`;
|
||||
const versionUrl = `${detailUrl}/versions/${encodedVersion}`;
|
||||
const artifactUrl = `${versionUrl}/artifact/download`;
|
||||
|
||||
async function fetchWithRetry(url, options = {}) {
|
||||
let lastStatus = "unknown";
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url, { redirect: "manual", ...options });
|
||||
lastStatus = response.status;
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
lastStatus = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
|
||||
}
|
||||
throw new Error(`${url} did not stabilize; last status ${lastStatus}.`);
|
||||
}
|
||||
|
||||
const detailResponse = await fetchWithRetry(detailUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
throw new Error(`${detailUrl} returned HTTP ${detailResponse.status}.`);
|
||||
}
|
||||
const detail = await detailResponse.json();
|
||||
const tags = detail?.package?.tags ?? {};
|
||||
if (tags[packageTag] !== packageVersion) {
|
||||
throw new Error(
|
||||
`${packageName}: ClawHub tag ${packageTag} points to ${tags[packageTag] ?? "<missing>"}, expected ${packageVersion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const trustedPublisherResponse = await fetchWithRetry(trustedPublisherUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!trustedPublisherResponse.ok) {
|
||||
throw new Error(`${trustedPublisherUrl} returned HTTP ${trustedPublisherResponse.status}.`);
|
||||
}
|
||||
const trustedPublisherDetail = await trustedPublisherResponse.json();
|
||||
const trustedPublisher = trustedPublisherDetail?.trustedPublisher;
|
||||
if (
|
||||
trustedPublisher?.repository !== "openclaw/openclaw" ||
|
||||
trustedPublisher?.workflowFilename !== "plugin-clawhub-release.yml" ||
|
||||
trustedPublisher?.environment != null
|
||||
) {
|
||||
throw new Error(
|
||||
`${packageName}: trusted publisher config did not match openclaw/openclaw plugin-clawhub-release.yml without an environment pin.`,
|
||||
);
|
||||
}
|
||||
|
||||
const versionResponse = await fetchWithRetry(versionUrl);
|
||||
if (!versionResponse.ok) {
|
||||
throw new Error(`${versionUrl} returned HTTP ${versionResponse.status}.`);
|
||||
}
|
||||
const artifactResponse = await fetchWithRetry(artifactUrl, { method: "HEAD" });
|
||||
if (artifactResponse.status < 200 || artifactResponse.status >= 400) {
|
||||
throw new Error(`${artifactUrl} returned HTTP ${artifactResponse.status}.`);
|
||||
}
|
||||
console.log(`${packageName}@${packageVersion} bootstrap verified on ClawHub.`);
|
||||
EOF
|
||||
258
.github/workflows/plugin-clawhub-release.yml
vendored
258
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -16,7 +16,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
ref:
|
||||
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
|
||||
description: Dry-run target ref to validate; real OIDC publishes must dispatch the workflow with --ref set to the target release tag/ref
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -24,6 +24,10 @@ on:
|
||||
description: Approved OpenClaw Release Publish workflow run id
|
||||
required: false
|
||||
type: string
|
||||
release_publish_branch:
|
||||
description: Branch name of the approving OpenClaw Release Publish workflow run
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Validate the full ClawHub artifact handoff without publishing.
|
||||
required: false
|
||||
@@ -38,9 +42,7 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.15.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
CLAWHUB_REF: "c9bb13023598dcc547fdf4a93b9d42512b8c8854"
|
||||
CLAWHUB_CLI_PACKAGE: "clawhub@0.21.0"
|
||||
|
||||
jobs:
|
||||
preview_plugins_clawhub:
|
||||
@@ -50,9 +52,15 @@ jobs:
|
||||
outputs:
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
has_bootstrap_candidates: ${{ steps.plan.outputs.has_bootstrap_candidates }}
|
||||
has_missing_trusted_publisher: ${{ steps.plan.outputs.has_missing_trusted_publisher }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
bootstrap_candidate_count: ${{ steps.plan.outputs.bootstrap_candidate_count }}
|
||||
missing_trusted_publisher_count: ${{ steps.plan.outputs.missing_trusted_publisher_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
bootstrap_matrix: ${{ steps.plan.outputs.bootstrap_matrix }}
|
||||
missing_trusted_publisher_matrix: ${{ steps.plan.outputs.missing_trusted_publisher_matrix }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -83,9 +91,27 @@ jobs:
|
||||
fi
|
||||
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate OIDC source matches workflow ref
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.ref.outputs.sha }}
|
||||
WORKFLOW_SHA: ${{ github.sha }}
|
||||
DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${TARGET_SHA}" != "${WORKFLOW_SHA}" ]]; then
|
||||
if [[ "${DRY_RUN}" == "true" ]]; then
|
||||
echo "Dry-run publish target differs from workflow ref; allowing validation-only dispatch."
|
||||
exit 0
|
||||
fi
|
||||
echo "Plugin ClawHub OIDC publishes must run from the same ref that is being published." >&2
|
||||
echo "The ref input is only supported for dry_run=true." >&2
|
||||
echo "For real publishes, dispatch this workflow with --ref pointing at the target release tag/ref and omit the ref input." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate ref is on a trusted publish branch
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git merge-base --is-ancestor HEAD origin/main; then
|
||||
@@ -96,8 +122,8 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
|
||||
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${WORKFLOW_REF#refs/heads/}"
|
||||
if [[ "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
alpha_branch="${TRUSTED_PUBLISH_BRANCH}"
|
||||
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
|
||||
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
|
||||
exit 0
|
||||
@@ -158,36 +184,78 @@ jobs:
|
||||
cat .local/plugin-clawhub-release-plan.json
|
||||
|
||||
candidate_count="$(jq -r '.candidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_candidate_count="$(jq -r '.bootstrapCandidates | length' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_count="$(jq -r '.missingTrustedPublisher | length' .local/plugin-clawhub-release-plan.json)"
|
||||
skipped_published_count="$(jq -r '.skippedPublished | length' .local/plugin-clawhub-release-plan.json)"
|
||||
has_candidates="false"
|
||||
if [[ "${candidate_count}" != "0" ]]; then
|
||||
has_candidates="true"
|
||||
fi
|
||||
has_bootstrap_candidates="false"
|
||||
if [[ "${bootstrap_candidate_count}" != "0" ]]; then
|
||||
has_bootstrap_candidates="true"
|
||||
fi
|
||||
has_missing_trusted_publisher="false"
|
||||
if [[ "${missing_trusted_publisher_count}" != "0" ]]; then
|
||||
has_missing_trusted_publisher="true"
|
||||
fi
|
||||
matrix_json="$(jq -c '.candidates' .local/plugin-clawhub-release-plan.json)"
|
||||
bootstrap_matrix_json="$(jq -c '.bootstrapCandidates' .local/plugin-clawhub-release-plan.json)"
|
||||
missing_trusted_publisher_matrix_json="$(jq -c '.missingTrustedPublisher' .local/plugin-clawhub-release-plan.json)"
|
||||
|
||||
{
|
||||
echo "candidate_count=${candidate_count}"
|
||||
echo "bootstrap_candidate_count=${bootstrap_candidate_count}"
|
||||
echo "missing_trusted_publisher_count=${missing_trusted_publisher_count}"
|
||||
echo "skipped_published_count=${skipped_published_count}"
|
||||
echo "has_candidates=${has_candidates}"
|
||||
echo "has_bootstrap_candidates=${has_bootstrap_candidates}"
|
||||
echo "has_missing_trusted_publisher=${has_missing_trusted_publisher}"
|
||||
echo "matrix=${matrix_json}"
|
||||
echo "bootstrap_matrix=${bootstrap_matrix_json}"
|
||||
echo "missing_trusted_publisher_matrix=${missing_trusted_publisher_matrix_json}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Plugin release candidates:"
|
||||
jq -r '.candidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Bootstrap candidates requiring token bootstrap:"
|
||||
jq -r '.bootstrapCandidates[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Missing trusted publisher candidates:"
|
||||
jq -r '.missingTrustedPublisher[]? | "- \(.packageName)@\(.version) [\(.publishTag)] from \(.packageDir)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
echo "Already published / skipped:"
|
||||
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-clawhub-release-plan.json
|
||||
|
||||
- name: Fail when trusted publisher is missing
|
||||
if: steps.plan.outputs.missing_trusted_publisher_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages exist but do not have trusted publishing configured. Configure trusted publishing before running the normal OIDC publish workflow."
|
||||
jq -r '.missingTrustedPublisher[]? | "::error::Missing trusted publisher: \(.packageName)@\(.version). Configure trusted publishing for openclaw/openclaw, workflow plugin-clawhub-release.yml."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail normal publish when bootstrap is required
|
||||
if: steps.plan.outputs.bootstrap_candidate_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more ClawHub packages do not exist yet and require the token-gated Plugin ClawHub New bootstrap workflow before normal OIDC publish can run."
|
||||
jq -r '.bootstrapCandidates[]? | "::error::Bootstrap required: \(.packageName)@\(.version). Dispatch plugin-clawhub-new.yml for this package, then rerun the normal release."' .local/plugin-clawhub-release-plan.json
|
||||
exit 1
|
||||
|
||||
- name: Fail manual publish when target versions already exist
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && inputs.publish_scope == 'selected' && steps.plan.outputs.skipped_published_count != '0'
|
||||
run: |
|
||||
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
|
||||
exit 1
|
||||
|
||||
- name: Validate Tideclaw alpha plugin channels
|
||||
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
|
||||
env:
|
||||
TRUSTED_PUBLISH_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${TRUSTED_PUBLISH_BRANCH}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
invalid="$(
|
||||
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
|
||||
)"
|
||||
@@ -197,12 +265,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify OpenClaw ClawHub package ownership
|
||||
if: steps.plan.outputs.has_candidates == 'true'
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
|
||||
|
||||
validate_release_publish_approval:
|
||||
name: Validate release publish approval
|
||||
needs: preview_plugins_clawhub
|
||||
@@ -221,7 +283,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
|
||||
EXPECTED_WORKFLOW_BRANCH: ${{ inputs.release_publish_branch || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
|
||||
@@ -240,99 +302,8 @@ jobs:
|
||||
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
|
||||
printf '%s' "$RUN_JSON" | DIRECT_RELEASE_RECOVERY="${direct_recovery}" node scripts/validate-release-publish-approval.mjs
|
||||
|
||||
preview_plugin_pack:
|
||||
needs: preview_plugins_clawhub
|
||||
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout target revision
|
||||
env:
|
||||
TARGET_SHA: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags origin \
|
||||
+refs/heads/main:refs/remotes/origin/main \
|
||||
'+refs/heads/release/*:refs/remotes/origin/release/*'
|
||||
git checkout --detach "${TARGET_SHA}"
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Preview publish command
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
|
||||
|
||||
pack_plugins_clawhub_artifacts:
|
||||
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
|
||||
needs: [preview_plugins_clawhub, validate_release_publish_approval]
|
||||
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -367,47 +338,19 @@ jobs:
|
||||
install-bun: "true"
|
||||
install-deps: "true"
|
||||
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: main
|
||||
path: clawhub-source
|
||||
fetch-depth: 0
|
||||
- name: Verify package-local runtime build
|
||||
run: node scripts/check-plugin-npm-runtime-builds.mjs --package "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Checkout pinned ClawHub CLI revision
|
||||
working-directory: clawhub-source
|
||||
env:
|
||||
CLAWHUB_REF: ${{ env.CLAWHUB_REF }}
|
||||
run: git checkout --detach "${CLAWHUB_REF}"
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
- name: Install pinned ClawHub CLI wrapper
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if bun install --frozen-lockfile; then
|
||||
exit 0
|
||||
fi
|
||||
status="$?"
|
||||
if [[ "${attempt}" == "3" ]]; then
|
||||
exit "${status}"
|
||||
fi
|
||||
echo "bun install failed while preparing ClawHub CLI; retrying (${attempt}/3)."
|
||||
rm -rf node_modules "${RUNNER_TEMP}/bun-install-cache" || true
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Bootstrap ClawHub CLI
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/clawhub" <<'EOF'
|
||||
cat > "${RUNNER_TEMP}/clawhub" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
exec bun "$GITHUB_WORKSPACE/clawhub-source/packages/clawhub/src/cli.ts" "$@"
|
||||
exec npm exec --yes --package "${CLAWHUB_CLI_PACKAGE}" -- clawhub "$@"
|
||||
EOF
|
||||
chmod +x "$RUNNER_TEMP/clawhub"
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
chmod +x "${RUNNER_TEMP}/clawhub"
|
||||
echo "${RUNNER_TEMP}" >> "${GITHUB_PATH}"
|
||||
|
||||
- name: Pack ClawHub package artifact
|
||||
env:
|
||||
@@ -428,19 +371,23 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
approve_plugin_clawhub_release:
|
||||
approve_plugins_clawhub_release:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts]
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
|
||||
if: always() && github.event_name == 'workflow_dispatch' && inputs.dry_run != true && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
environment: clawhub-plugin-release
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Approve ClawHub package publish
|
||||
run: echo "ClawHub package publish approved."
|
||||
- name: Approve Plugin ClawHub release publish
|
||||
run: |
|
||||
echo "Approved CLAW-277 03 - Split OpenClaw plugin ClawHub publishing into OIDC release and token bootstrap workflows release publish gate."
|
||||
|
||||
publish_plugins_clawhub:
|
||||
needs: [preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugin_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugin_clawhub_release.result == 'success')
|
||||
needs:
|
||||
[preview_plugins_clawhub, pack_plugins_clawhub_artifacts, approve_plugins_clawhub_release]
|
||||
if: always() && github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true' && needs.pack_plugins_clawhub_artifacts.result == 'success' && (inputs.dry_run == true || needs.approve_plugins_clawhub_release.result == 'success')
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
@@ -450,19 +397,18 @@ jobs:
|
||||
max-parallel: 32
|
||||
matrix:
|
||||
plugin: ${{ fromJson(needs.preview_plugins_clawhub.outputs.matrix) }}
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@c9bb13023598dcc547fdf4a93b9d42512b8c8854
|
||||
with:
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
json: true
|
||||
package_artifact_name: ${{ matrix.plugin.artifactName }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
registry: https://clawhub.ai
|
||||
site: https://clawhub.ai
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
source_repo: ${{ github.repository }}
|
||||
source_commit: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
source_ref: ${{ github.ref }}
|
||||
tags: ${{ matrix.plugin.publishTag }}
|
||||
secrets:
|
||||
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
|
||||
source_path: ${{ matrix.plugin.packageDir }}
|
||||
inspector_artifact_name: ${{ matrix.plugin.artifactName }}-inspector
|
||||
publish_json_artifact_name: ${{ matrix.plugin.artifactName }}-publish-json
|
||||
|
||||
verify_published_clawhub_package:
|
||||
needs: [preview_plugins_clawhub, publish_plugins_clawhub]
|
||||
|
||||
1
.github/workflows/plugin-npm-release.yml
vendored
1
.github/workflows/plugin-npm-release.yml
vendored
@@ -288,6 +288,7 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
|
||||
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
|
||||
|
||||
- name: Verify published runtime
|
||||
|
||||
12
Dockerfile
12
Dockerfile
@@ -116,11 +116,19 @@ RUN pnpm_config_verify_deps_before_run=false pnpm canvas:a2ui:bundle || \
|
||||
echo "/* A2UI bundle unavailable in this build */" > extensions/canvas/src/host/a2ui/a2ui.bundle.js && \
|
||||
echo "stub" > extensions/canvas/src/host/a2ui/.bundle.hash && \
|
||||
rm -rf vendor/a2ui apps/shared/OpenClawKit/Tools/CanvasA2UI)
|
||||
RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
export OPENCLAW_BUILD_PRIVATE_QA=1 OPENCLAW_ENABLE_PRIVATE_QA_CLI=1; \
|
||||
fi && \
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm_config_verify_deps_before_run=false pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
|
||||
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
|
||||
RUN if printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'qa-lab'; then \
|
||||
pnpm_config_verify_deps_before_run=false pnpm qa:lab:build && \
|
||||
mkdir -p dist/extensions/qa-lab/web && \
|
||||
rm -rf dist/extensions/qa-lab/web/dist && \
|
||||
cp -R extensions/qa-lab/web/dist dist/extensions/qa-lab/web/dist; \
|
||||
fi
|
||||
|
||||
# Prune dev dependencies, omitted plugin runtime packages, and build-only
|
||||
# metadata before copying runtime assets into the final image.
|
||||
|
||||
@@ -72,7 +72,7 @@ final class CronJobsStore {
|
||||
do {
|
||||
if let status = try? await GatewayConnection.shared.cronStatus() {
|
||||
self.schedulerEnabled = status.enabled
|
||||
self.schedulerStorePath = status.storePath
|
||||
self.schedulerStorePath = status.sqlitePath ?? status.storePath
|
||||
self.schedulerNextWakeAtMs = status.nextWakeAtMs
|
||||
}
|
||||
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)
|
||||
|
||||
@@ -775,6 +775,7 @@ extension GatewayConnection {
|
||||
struct CronSchedulerStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let sqlitePath: String?
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
8a2769df428906990ee0d1bf8b0423f2a099b053c64c816d092ff84d61e11633 plugin-sdk-api-baseline.json
|
||||
28b798973f3fb2a5b33ccbb6e3c1ac0453fa234a3a1c6cdc27935c27639bd104 plugin-sdk-api-baseline.jsonl
|
||||
2c783beea6b3cda3d79060739a923f9f39e7e8b5942123dd6b08a09143a587ca plugin-sdk-api-baseline.json
|
||||
0b33af2cffb42abb46682fb71c8f214da220793f13d10a34d332e75ff99e8ce9 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -183,7 +183,7 @@ The workflow installs OCM from a pinned release and Kova from `openclaw/Kova` at
|
||||
- `mock-deep-profile`: CPU/heap/trace profiling for startup, gateway, and agent-turn hotspots.
|
||||
- `live-openai-candidate`: a real OpenAI `openai/gpt-5.5` agent turn, skipped when `OPENAI_API_KEY` is unavailable.
|
||||
|
||||
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, and CLI startup commands against the booted gateway. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
|
||||
The mock-provider lane also runs OpenClaw-native source probes after the Kova pass: gateway boot timing and memory across default, hook, and 50-plugin startup cases; bundled plugin import RSS, repeated mock-OpenAI `channel-chat-baseline` hello loops, CLI startup commands against the booted gateway, and the SQLite state smoke performance probe. When the previous published mock-provider source report is available for the tested ref, the source summary compares current RSS and heap values against that baseline and marks large RSS increases as `watch`. The source probe Markdown summary lives at `source/index.md` in the report bundle, with raw JSON beside it.
|
||||
|
||||
Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured, the workflow also commits `report.json`, `report.md`, bundles, `index.md`, and source-probe artifacts into `openclaw/clawgrit-reports` under `openclaw-performance/<tested-ref>/<run-id>-<attempt>/<lane>/`. The current tested-ref pointer is written as `openclaw-performance/<tested-ref>/latest-<lane>.json`.
|
||||
|
||||
@@ -452,7 +452,7 @@ For normal PRs, follow scoped CI/check evidence instead of treating parity as a
|
||||
|
||||
The `CodeQL` workflow is intentionally a narrow first-pass security scanner, not the full repository sweep. Daily, manual, and non-draft pull request guard runs scan Actions workflow code plus the highest-risk JavaScript/TypeScript surfaces with high-confidence security queries filtered to high/critical `security-severity`.
|
||||
|
||||
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, or `src`, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
|
||||
The pull request guard stays light: it only starts for changes under `.github/actions`, `.github/codeql`, `.github/workflows`, `packages`, `scripts`, `src`, or process-owning bundled plugin runtime paths, and it runs the same high-confidence security matrix as the scheduled workflow. Android and macOS CodeQL stay out of PR defaults.
|
||||
|
||||
### Security categories
|
||||
|
||||
@@ -462,6 +462,7 @@ The pull request guard stays light: it only starts for changes under `.github/ac
|
||||
| `/codeql-security-high/channel-runtime-boundary` | Core channel implementation contracts plus the channel plugin runtime, gateway, Plugin SDK, secrets, audit touchpoints |
|
||||
| `/codeql-security-high/network-ssrf-boundary` | Core SSRF, IP parsing, network guard, web-fetch, and Plugin SDK SSRF policy surfaces |
|
||||
| `/codeql-security-high/mcp-process-tool-boundary` | MCP servers, process execution helpers, outbound delivery, and agent tool-execution gates |
|
||||
| `/codeql-security-high/process-exec-boundary` | Local shell, process spawn helpers, subprocess-owning bundled plugin runtimes, and workflow script glue |
|
||||
| `/codeql-security-high/plugin-trust-boundary` | Plugin install, loader, manifest, registry, package-manager install, source-loading, and Plugin SDK package contract trust surfaces |
|
||||
|
||||
### Platform-specific security shards
|
||||
|
||||
@@ -35,6 +35,7 @@ openclaw wiki status
|
||||
openclaw wiki doctor
|
||||
openclaw wiki init
|
||||
openclaw wiki ingest ./notes/alpha.md
|
||||
openclaw wiki okf import ./knowledge-catalog/okf/bundles/ga4
|
||||
openclaw wiki compile
|
||||
openclaw wiki lint
|
||||
openclaw wiki search "alpha"
|
||||
@@ -104,6 +105,31 @@ Notes:
|
||||
- imported source pages keep provenance in frontmatter
|
||||
- auto-compile can run after ingest when enabled
|
||||
|
||||
### `wiki okf import <path>`
|
||||
|
||||
Import an unpacked Open Knowledge Format bundle into wiki concept pages.
|
||||
|
||||
The importer reads every non-reserved `.md` concept document in the OKF
|
||||
directory tree, requires a non-empty `type` field, and treats unknown OKF
|
||||
`type` values as generic concepts. Reserved OKF `index.md` and `log.md` files
|
||||
are not imported as concepts.
|
||||
|
||||
Imported pages are flattened under `concepts/` so existing wiki compile,
|
||||
search, get, digest, and dashboard flows see them immediately. The original OKF
|
||||
concept ID, `type`, `resource`, `tags`, timestamp, source path, and full
|
||||
frontmatter are preserved in the page frontmatter. Internal OKF markdown links
|
||||
are rewritten to the generated wiki pages; broken or external links are left
|
||||
unchanged.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw wiki okf import ./bundles/ga4
|
||||
openclaw wiki okf import ./bundles/ga4 --json
|
||||
openclaw wiki search "BigQuery Table" --mode source-evidence --json
|
||||
openclaw wiki get <path-from-json-result>
|
||||
```
|
||||
|
||||
### `wiki compile`
|
||||
|
||||
Rebuild indexes, related blocks, dashboards, and compiled digests.
|
||||
@@ -233,6 +259,8 @@ These require the official `obsidian` CLI on `PATH` when
|
||||
- Use `wiki lint` before trusting contradictory or low-confidence content.
|
||||
- Use `wiki compile` after bulk imports or source changes when you want fresh
|
||||
dashboards and compiled digests immediately.
|
||||
- Use `wiki okf import` when a data catalog, documentation export, or agent
|
||||
enrichment pipeline already emits OKF markdown bundles.
|
||||
- Use `wiki bridge import` when bridge mode depends on newly exported memory
|
||||
artifacts.
|
||||
|
||||
|
||||
@@ -368,6 +368,7 @@ Kimi K2 model IDs:
|
||||
[//]: # "moonshot-kimi-k2-model-refs:start"
|
||||
|
||||
- `moonshot/kimi-k2.6`
|
||||
- `moonshot/kimi-k2.7-code`
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
|
||||
@@ -374,7 +374,7 @@ The implicit default set always covers canary, mention gating, native command re
|
||||
Output artifacts:
|
||||
|
||||
- `telegram-qa-report.md`
|
||||
- `telegram-qa-summary.json` - includes per-reply RTT (driver send → observed SUT reply) starting with the canary.
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks, including profile, coverage, provider, channel, artifacts, result, and RTT fields.
|
||||
- `telegram-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT=1`.
|
||||
|
||||
Package RTT comparison uses the same Telegram credential contract while keeping
|
||||
@@ -447,7 +447,7 @@ pnpm openclaw qa discord \
|
||||
Output artifacts:
|
||||
|
||||
- `discord-qa-report.md`
|
||||
- `discord-qa-summary.json`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `discord-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_DISCORD_CAPTURE_CONTENT=1`.
|
||||
- `discord-qa-reaction-timelines.json` and `discord-status-reactions-tool-only-timeline.png` when the status-reaction scenario runs.
|
||||
|
||||
@@ -495,7 +495,7 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts`):
|
||||
Output artifacts:
|
||||
|
||||
- `slack-qa-report.md`
|
||||
- `slack-qa-summary.json`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `slack-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_SLACK_CAPTURE_CONTENT=1`.
|
||||
- `approval-checkpoints/` - only when Mantis sets
|
||||
`OPENCLAW_QA_SLACK_APPROVAL_CHECKPOINT_DIR`; contains checkpoint JSON,
|
||||
@@ -740,7 +740,7 @@ poll and upload-file coverage run through deterministic gateway `poll` and
|
||||
Output artifacts:
|
||||
|
||||
- `whatsapp-qa-report.md`
|
||||
- `whatsapp-qa-summary.json`
|
||||
- `qa-evidence.json` - evidence entries for the live transport checks.
|
||||
- `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`.
|
||||
|
||||
### Convex credential pool
|
||||
@@ -787,9 +787,10 @@ the source of truth for one test run and should define:
|
||||
- docs and code refs
|
||||
- optional plugin requirements
|
||||
- optional gateway config patch
|
||||
- the executable `qa-flow`
|
||||
- an executable `qa-flow` block for flow scenarios, or `execution.kind`/`execution.path`
|
||||
for Vitest and Playwright scenarios
|
||||
|
||||
The reusable runtime surface that backs `qa-flow` is allowed to stay generic
|
||||
The reusable runtime surface that backs `qa-flow` blocks is allowed to stay generic
|
||||
and cross-cutting. For example, markdown scenarios can combine transport-side
|
||||
helpers with browser-side helpers that drive the embedded Control UI through the
|
||||
Gateway `browser.request` seam without adding a special-case runner.
|
||||
@@ -915,6 +916,7 @@ The report should answer:
|
||||
For the inventory of available scenarios - useful when sizing follow-up work or wiring a new transport - run `pnpm openclaw qa coverage` (add `--json` for machine-readable output).
|
||||
When choosing focused proof for a touched behavior or file path, run `pnpm openclaw qa coverage --match <query>`.
|
||||
The match report searches scenario metadata, docs refs, code refs, coverage IDs, plugins, and provider requirements, then prints matching `qa suite --scenario ...` targets.
|
||||
Every `qa suite` scenario execution writes a `qa-evidence.json` artifact. Flow scenarios also write `qa-suite-summary.json` for existing suite/report tooling; scenarios that declare `execution.kind: vitest` or `execution.kind: playwright` run the matching test path and write `qa-vitest-report.md` or `qa-playwright-report.md` plus per-scenario logs.
|
||||
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
|
||||
|
||||
For character and style checks, run the same scenario across multiple live model
|
||||
|
||||
@@ -30,6 +30,23 @@ title: "Usage tracking"
|
||||
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: "Usage" section under Context (only if available).
|
||||
|
||||
## Custom `/usage full` footer
|
||||
|
||||
Set `messages.usageTemplate` to customize the per-response `/usage full`
|
||||
footer. The value can be an inline template object or a JSON file path:
|
||||
|
||||
```json
|
||||
{
|
||||
"messages": {
|
||||
"usageTemplate": "~/.openclaw/usage-footer.json"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Templates read the `openclaw.usageLine.v1` contract and can use `scales`,
|
||||
`aliases`, and `output.surfaces` to render channel-specific footers. Missing,
|
||||
unreadable, invalid, or empty templates fall back to the built-in usage line.
|
||||
|
||||
## Providers + credentials
|
||||
|
||||
- **Anthropic (Claude)**: OAuth tokens in auth profiles.
|
||||
|
||||
@@ -1374,9 +1374,7 @@
|
||||
"pages": [
|
||||
"clawhub/cli",
|
||||
"clawhub/publishing",
|
||||
"clawhub/plugin-validation-fixes",
|
||||
"clawhub/skill-format",
|
||||
"clawhub/soul-format",
|
||||
"clawhub/auth",
|
||||
"clawhub/telemetry",
|
||||
"clawhub/troubleshooting"
|
||||
|
||||
@@ -493,6 +493,8 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
|
||||
- `extraDirs` scans the sibling repo as an explicit skill root.
|
||||
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
|
||||
real target root without allowing arbitrary symlink escapes.
|
||||
- To let Skill Workshop apply write through the same trusted symlink target,
|
||||
set `skills.workshop.allowSymlinkTargetWrites: true`.
|
||||
|
||||
## Common patterns
|
||||
|
||||
|
||||
@@ -200,6 +200,9 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
nodeManager: "npm", // npm | pnpm | yarn | bun
|
||||
allowUploadedArchives: false,
|
||||
},
|
||||
workshop: {
|
||||
allowSymlinkTargetWrites: false,
|
||||
},
|
||||
entries: {
|
||||
"image-lab": {
|
||||
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
|
||||
@@ -216,6 +219,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
- `load.extraDirs`: extra shared skill roots (lowest precedence).
|
||||
- `load.allowSymlinkTargets`: trusted real target roots that skill symlinks may
|
||||
resolve into when the link lives outside its configured source root.
|
||||
- `workshop.allowSymlinkTargetWrites`: allows Skill Workshop apply to write
|
||||
through already-trusted symlink targets (default: false).
|
||||
- `install.preferBrew`: when true, prefer Homebrew installers when `brew` is
|
||||
available before falling back to other installer kinds.
|
||||
- `install.nodeManager`: node installer preference for `metadata.openclaw.install`
|
||||
|
||||
@@ -42,6 +42,21 @@ health commands above for live connectivity checks.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
|
||||
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
|
||||
|
||||
## Uptime monitoring
|
||||
|
||||
External uptime monitoring services should use the dedicated `/health` endpoint, not `/v1/chat/completions`.
|
||||
|
||||
- **DO use:** `GET /health` — instant response, no session created, no LLM call, returns `{"ok":true,"status":"live"}`
|
||||
- **DON'T use:** `/v1/chat/completions` for health checks — each request creates a full agent session with skill snapshot, context assembly, and LLM calls
|
||||
|
||||
When no `x-openclaw-session-key` header or `user` field is provided, `/v1/chat/completions` generates a new random session for each request. Monitoring services that ping every 15 minutes create ~96 sessions/day, each consuming 4–22KB. Over time this causes session store bloat and can lead to context window overflow.
|
||||
|
||||
### Monitoring service setup examples
|
||||
|
||||
- **BetterStack:** Set health check URL to `https://<your-gateway-host>:<port>/health`
|
||||
- **UptimeRobot:** Add a new HTTP monitor with URL `https://<your-gateway-host>:<port>/health`
|
||||
- **Generic:** Any HTTP GET to `/health` returns 200 with `{"ok":true}` when the gateway is healthy
|
||||
|
||||
## When something fails
|
||||
|
||||
- `logged out` or status 409–515 → relink with `openclaw channels logout` then `openclaw channels login`.
|
||||
|
||||
@@ -75,6 +75,7 @@ Auth matrix:
|
||||
- honor `x-openclaw-scopes` when the header is present
|
||||
- fall back to the normal operator default scope set when the header is absent
|
||||
- only lose owner semantics when the caller explicitly narrows scopes and omits `operator.admin`
|
||||
- require `operator.admin` for owner-level request controls such as `x-openclaw-model`
|
||||
|
||||
See [Security](/gateway/security) and [Remote access](/gateway/remote).
|
||||
|
||||
@@ -96,7 +97,7 @@ OpenClaw treats the OpenAI `model` field as an **agent target**, not a raw provi
|
||||
|
||||
Optional request headers:
|
||||
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent.
|
||||
- `x-openclaw-model: <provider/model-or-bare-id>` overrides the backend model for the selected agent. Shared-secret bearer callers can use this header. Identity-bearing callers, such as trusted-proxy or private no-auth ingress requests with `x-openclaw-scopes`, need `operator.admin`; write-only callers get `403 missing scope: operator.admin`.
|
||||
- `x-openclaw-agent-id: <agentId>` remains supported as a compatibility override.
|
||||
- `x-openclaw-session-key: <sessionKey>` fully controls session routing.
|
||||
- `x-openclaw-message-channel: <channel>` sets the synthetic ingress channel context for channel-aware prompts and policies.
|
||||
@@ -178,7 +179,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="How do I override the backend model?">
|
||||
Use `x-openclaw-model`.
|
||||
Use `x-openclaw-model`. This is an owner-level override: it works with the Gateway shared-secret bearer token/password path, and it requires `operator.admin` on identity-bearing HTTP paths such as trusted proxy auth.
|
||||
|
||||
Examples:
|
||||
`x-openclaw-model: openai/gpt-5.4`
|
||||
@@ -191,7 +192,7 @@ This is the highest-leverage compatibility set for self-hosted frontends and too
|
||||
`/v1/embeddings` uses the same agent-target `model` ids.
|
||||
|
||||
Use `model: "openclaw/default"` or `model: "openclaw/<agentId>"`.
|
||||
When you need a specific embedding model, send it in `x-openclaw-model`.
|
||||
When you need a specific embedding model, send it in `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`.
|
||||
Without that header, the request passes through to the selected agent's normal embedding setup.
|
||||
|
||||
</Accordion>
|
||||
@@ -285,7 +286,7 @@ Expected behavior:
|
||||
|
||||
- `GET /v1/models` should list `openclaw/default`
|
||||
- Open WebUI should use `openclaw/default` as the chat model id
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model`
|
||||
- If you want a specific backend provider/model for that agent, set the agent's normal default model or send `x-openclaw-model` from a shared-secret caller or an identity-bearing caller with `operator.admin`
|
||||
|
||||
Quick smoke:
|
||||
|
||||
@@ -370,7 +371,7 @@ Notes:
|
||||
|
||||
- `/v1/models` returns OpenClaw agent targets, not raw provider catalogs.
|
||||
- `openclaw/default` is always present so one stable id works across environments.
|
||||
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field.
|
||||
- Backend provider/model overrides belong in `x-openclaw-model`, not the OpenAI `model` field. On identity-bearing HTTP auth paths, this header requires `operator.admin`.
|
||||
- `/v1/embeddings` supports `input` as a string or array of strings.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -951,7 +951,7 @@ Important boundary note:
|
||||
- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, plugin routes such as `/api/v1/admin/rpc`, or `/api/channels/*` as full-access operator secrets for that gateway.
|
||||
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
|
||||
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
|
||||
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
|
||||
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set. Owner-level OpenAI-compatible headers such as `x-openclaw-model` require `operator.admin` when scopes are narrowed.
|
||||
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
|
||||
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
|
||||
|
||||
|
||||
@@ -154,6 +154,10 @@ Do not use broad targets such as `~`, `/`, or a whole synced project folder.
|
||||
Keep `allowSymlinkTargets` scoped to the real skill root that contains trusted
|
||||
`SKILL.md` directories.
|
||||
|
||||
If Skill Workshop apply should also write through those trusted symlinked
|
||||
workspace skill paths, enable `skills.workshop.allowSymlinkTargetWrites`. Keep
|
||||
it disabled for read-only shared skill roots.
|
||||
|
||||
Related:
|
||||
|
||||
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
|
||||
|
||||
@@ -18,11 +18,13 @@ most Linux-compatible Gateway runtime.
|
||||
Windows Hub is the native WinUI companion app for Windows 10 20H2+ and Windows 11. It installs without administrator privileges and is published with signed
|
||||
x64 and ARM64 installers on OpenClaw releases.
|
||||
|
||||
Download the latest stable installer:
|
||||
Download the latest stable installer from the [OpenClaw releases page](https://github.com/openclaw/openclaw/releases):
|
||||
|
||||
- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-x64.exe)
|
||||
- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-Setup-arm64.exe)
|
||||
- [Checksums](https://github.com/openclaw/openclaw/releases/latest/download/OpenClawCompanion-SHA256SUMS.txt)
|
||||
- [OpenClawCompanion-Setup-x64.exe](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-Setup-x64.exe)
|
||||
- [OpenClawCompanion-Setup-arm64.exe](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-Setup-arm64.exe)
|
||||
- [Checksums](https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClawCompanion-SHA256SUMS.txt)
|
||||
|
||||
If a download link above returns a 404, visit the [releases page](https://github.com/openclaw/openclaw/releases) and look for the `OpenClawCompanion-Setup-*` assets on the latest release.
|
||||
|
||||
After install, launch **OpenClaw Companion** from the Start menu or the system
|
||||
tray. The installer also adds shortcuts for Gateway Setup, Chat, Settings,
|
||||
|
||||
@@ -128,6 +128,10 @@ Current compatibility records include:
|
||||
- legacy runtime aliases such as `api.runtime.taskFlow`,
|
||||
`api.runtime.subagent.getSession`, `api.runtime.stt`, and deprecated
|
||||
`api.runtime.config.loadConfig()` / `api.runtime.config.writeConfigFile(...)`
|
||||
- WhatsApp `WebInboundMessage` flat callback fields such as `body`, `chatId`,
|
||||
`reply(...)`, and `mediaPath` while callback consumers migrate to the nested
|
||||
`WebInboundCallbackMessage` `event`, `payload`, `quote`, `group`, and
|
||||
`platform` contexts
|
||||
- legacy memory-plugin split registration while memory plugins move to
|
||||
`registerMemoryCapability`
|
||||
- legacy memory-specific embedding provider registration while embedding
|
||||
@@ -160,6 +164,33 @@ New plugin code should prefer the replacement listed in the registry and in the
|
||||
specific migration guide. Existing plugins can keep using a compatibility path
|
||||
until the docs, diagnostics, and release notes announce a removal window.
|
||||
|
||||
### WhatsApp Inbound Callback Flat Aliases
|
||||
|
||||
WhatsApp runtime callbacks deliver `WebInboundMessage`: the canonical nested
|
||||
`event`, `payload`, `quote`, `group`, and `platform` contexts plus deprecated
|
||||
flat aliases for the shipped callback fields. New callback code should read the
|
||||
nested contexts. Code that constructs clean nested callback messages can use
|
||||
`WebInboundCallbackMessage`; compatibility listeners that still inject old flat
|
||||
test or plugin messages should use `LegacyFlatWebInboundMessage` or
|
||||
`WebInboundMessageInput`.
|
||||
|
||||
The flat aliases remain available until **2026-08-30**. That removal window
|
||||
applies only to flat alias access; the nested callback shape is the canonical
|
||||
runtime contract. The TypeScript `@deprecated` annotations on each flat alias
|
||||
name its exact nested replacement. Common examples:
|
||||
|
||||
- `id`, `timestamp`, and `isBatched` move under `event`.
|
||||
- `body`, `mediaPath`, `mediaType`, `mediaFileName`, `mediaUrl`, `location`, and
|
||||
`untrustedStructuredContext` move under `payload`.
|
||||
- `to`, `chatId`, sender/self fields, `sendComposing`, `reply(...)`, and
|
||||
`sendMedia(...)` move under `platform`.
|
||||
- `replyTo*` fields move under `quote`, and group subject/participant/mention
|
||||
fields move under `group`.
|
||||
|
||||
`payload.untrustedStructuredContext` is extracted from inbound provider payloads.
|
||||
Plugins should inspect the `label`, `source`, and `type` before treating its
|
||||
`payload` as authoritative.
|
||||
|
||||
## Release notes
|
||||
|
||||
Release notes should include upcoming plugin deprecations with target dates and
|
||||
|
||||
@@ -425,6 +425,10 @@ even when the channel payload has no visible text/caption. Rewriting that
|
||||
`content` updates the hook-visible transcript only; it is not rendered as a
|
||||
media caption.
|
||||
|
||||
`reply_payload_sending` events may include `usageState`, a best-effort live
|
||||
per-turn model/usage/context snapshot. Durable delivery, recovered replay, and
|
||||
replies without exact run correlation omit it.
|
||||
|
||||
Message hook contexts expose stable correlation fields when available:
|
||||
`ctx.sessionKey`, `ctx.runId`, `ctx.messageId`, `ctx.senderId`, `ctx.trace`,
|
||||
`ctx.traceId`, `ctx.spanId`, `ctx.parentSpanId`, and `ctx.callDepth`. Inbound
|
||||
|
||||
@@ -25,6 +25,7 @@ less like a pile of Markdown files.
|
||||
- Page-level provenance, confidence, contradictions, and open questions
|
||||
- Compiled digests for agent/runtime consumers
|
||||
- Wiki-native search/get/apply/lint tools
|
||||
- Open Knowledge Format imports into compiled wiki concepts
|
||||
- Optional bridge mode that imports public artifacts from the active memory plugin
|
||||
- Optional Obsidian-friendly render mode and CLI integration
|
||||
|
||||
@@ -135,6 +136,34 @@ The main page groups are:
|
||||
- `syntheses/` for compiled summaries and maintained rollups
|
||||
- `reports/` for generated dashboards
|
||||
|
||||
## Open Knowledge Format imports
|
||||
|
||||
`memory-wiki` can import unpacked Open Knowledge Format bundles with:
|
||||
|
||||
```bash
|
||||
openclaw wiki okf import ./bundles/ga4
|
||||
```
|
||||
|
||||
This is the cleanest fit when a data catalog, documentation crawler, or
|
||||
enrichment agent already produces OKF: keep OKF as the portable exchange
|
||||
artifact, then let `memory-wiki` turn it into OpenClaw-native concept pages and
|
||||
compiled digests.
|
||||
|
||||
The importer follows the OKF v0.1 shape:
|
||||
|
||||
- non-reserved `.md` files are concept documents
|
||||
- each imported concept needs a non-empty `type` frontmatter field
|
||||
- unknown OKF `type` values are accepted
|
||||
- reserved `index.md` and `log.md` files are not imported as concepts
|
||||
- broken or external markdown links are preserved
|
||||
|
||||
Imported concept pages are flattened under `concepts/` so the existing compile,
|
||||
search, get, dashboard, and prompt-digest paths see them without adding a second
|
||||
wiki tree. Each page keeps the original OKF concept ID, source path, `type`,
|
||||
`resource`, `tags`, timestamp, and full producer frontmatter. Internal OKF links
|
||||
are rewritten to the generated wiki concept pages and also emitted as structured
|
||||
`relationships` entries with `kind: okf-link`.
|
||||
|
||||
## Structured claims and evidence
|
||||
|
||||
Pages can carry structured `claims` frontmatter, not just freeform text.
|
||||
|
||||
@@ -137,7 +137,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[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 file-backed memory search tools.
|
||||
- **[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.
|
||||
|
||||
@@ -267,10 +267,10 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[googlechat](/plugins/reference/googlechat)** (`@openclaw/googlechat`) - npm; ClawHub. OpenClaw Google Chat channel plugin for spaces and direct messages.
|
||||
|
||||
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. OpenClaw llama.cpp embedding provider plugin.
|
||||
|
||||
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
|
||||
|
||||
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. Local GGUF embeddings through node-llama-cpp.
|
||||
|
||||
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.
|
||||
|
||||
- **[matrix](/plugins/reference/matrix)** (`@openclaw/matrix`) - ClawHub: `clawhub:@openclaw/matrix`; npm. OpenClaw Matrix channel plugin for rooms and direct messages.
|
||||
|
||||
@@ -18,8 +18,12 @@ OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.
|
||||
|
||||
providers: anthropic-vertex
|
||||
|
||||
<!-- openclaw-plugin-reference:manual-start -->
|
||||
|
||||
## Claude Fable 5
|
||||
|
||||
Use `anthropic-vertex/claude-fable-5` where the model is available in your Google Cloud region.
|
||||
Fable 5 always uses adaptive thinking and defaults to `high` effort. `/think off` and
|
||||
`/think minimal` use `low` effort because the model does not support disabling thinking.
|
||||
|
||||
<!-- openclaw-plugin-reference:manual-end -->
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
summary: "OpenClaw llama.cpp embedding provider plugin."
|
||||
summary: "Local GGUF embeddings through node-llama-cpp."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the llama-cpp plugin
|
||||
title: "llama-cpp plugin"
|
||||
title: "Llama Cpp plugin"
|
||||
---
|
||||
|
||||
# llama-cpp plugin
|
||||
# Llama Cpp plugin
|
||||
|
||||
OpenClaw llama.cpp embedding provider plugin.
|
||||
Local GGUF embeddings through node-llama-cpp.
|
||||
|
||||
## Distribution
|
||||
|
||||
@@ -20,4 +20,4 @@ contracts: embeddingProviders
|
||||
|
||||
## Related docs
|
||||
|
||||
- [llama.cpp Provider](/plugins/llama-cpp)
|
||||
- [llama-cpp](/plugins/llama-cpp)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Adds file-backed memory search tools."
|
||||
summary: "Adds agent-callable tools."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the memory-core plugin
|
||||
title: "Memory Core plugin"
|
||||
@@ -7,7 +7,7 @@ title: "Memory Core plugin"
|
||||
|
||||
# Memory Core plugin
|
||||
|
||||
Adds file-backed memory search tools.
|
||||
Adds agent-callable tools.
|
||||
|
||||
## Distribution
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Use Microsoft Foundry chat and MAI image deployments from OpenClaw."
|
||||
summary: "Adds Microsoft Foundry model provider support to OpenClaw."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the microsoft-foundry plugin
|
||||
title: "Microsoft Foundry plugin"
|
||||
@@ -7,9 +7,7 @@ title: "Microsoft Foundry plugin"
|
||||
|
||||
# Microsoft Foundry plugin
|
||||
|
||||
Use Microsoft Foundry deployments from OpenClaw with API-key auth or Microsoft
|
||||
Entra ID through the Azure CLI. The plugin owns Microsoft Foundry model
|
||||
discovery, runtime token refresh, and MAI image generation.
|
||||
Adds Microsoft Foundry model provider support to OpenClaw.
|
||||
|
||||
## Distribution
|
||||
|
||||
@@ -18,7 +16,10 @@ discovery, runtime token refresh, and MAI image generation.
|
||||
|
||||
## Surface
|
||||
|
||||
- Model provider: `microsoft-foundry`
|
||||
providers: microsoft-foundry; contracts: imageGenerationProviders
|
||||
|
||||
<!-- openclaw-plugin-reference:manual-start -->
|
||||
|
||||
- Image-generation provider: `microsoft-foundry`
|
||||
|
||||
## Requirements
|
||||
@@ -108,3 +109,5 @@ MAI image constraints:
|
||||
Foundry deployment through onboarding or add `models.providers.microsoft-foundry.baseUrl`.
|
||||
- `supports MAI image deployments only`: the selected image model points at a
|
||||
non-MAI deployment. Use a deployed MAI image model for `image_generate`.
|
||||
|
||||
<!-- openclaw-plugin-reference:manual-end -->
|
||||
|
||||
@@ -22,6 +22,7 @@ Moonshot and Kimi Coding are **separate providers**. Keys are not interchangeabl
|
||||
| Model ref | Name | Reasoning | Input | Context | Max output |
|
||||
| --------------------------------- | ---------------------- | --------- | ----------- | ------- | ---------- |
|
||||
| `moonshot/kimi-k2.6` | Kimi K2.6 | No | text, image | 262,144 | 262,144 |
|
||||
| `moonshot/kimi-k2.7-code` | Kimi K2.7 Code | Always on | text, image | 262,144 | 262,144 |
|
||||
| `moonshot/kimi-k2.5` | Kimi K2.5 | No | text, image | 262,144 | 262,144 |
|
||||
| `moonshot/kimi-k2-thinking` | Kimi K2 Thinking | Yes | text | 262,144 | 262,144 |
|
||||
| `moonshot/kimi-k2-thinking-turbo` | Kimi K2 Thinking Turbo | Yes | text | 262,144 | 262,144 |
|
||||
@@ -30,11 +31,18 @@ Moonshot and Kimi Coding are **separate providers**. Keys are not interchangeabl
|
||||
[//]: # "moonshot-kimi-k2-ids:end"
|
||||
|
||||
Bundled cost estimates for current Moonshot-hosted K2 models use Moonshot's
|
||||
published pay-as-you-go rates: Kimi K2.6 is $0.16/MTok cache hit,
|
||||
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,
|
||||
$0.60/MTok input, and $3.00/MTok output. Other legacy catalog entries keep
|
||||
zero-cost placeholders unless you override them in config.
|
||||
|
||||
Kimi K2.7 Code always uses native thinking. OpenClaw exposes only the `on`
|
||||
thinking state for this model and omits outbound `thinking` and
|
||||
`reasoning_effort` controls, as required by Moonshot. OpenClaw also omits
|
||||
sampling overrides that K2.7 fixes to provider defaults. Kimi K2.6 remains the
|
||||
onboarding default.
|
||||
|
||||
## Getting started
|
||||
|
||||
Choose your provider and follow the setup steps.
|
||||
@@ -109,6 +117,7 @@ Choose your provider and follow the setup steps.
|
||||
models: {
|
||||
// moonshot-kimi-k2-aliases:start
|
||||
"moonshot/kimi-k2.6": { alias: "Kimi K2.6" },
|
||||
"moonshot/kimi-k2.7-code": { alias: "Kimi K2.7 Code" },
|
||||
"moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
|
||||
"moonshot/kimi-k2-thinking": { alias: "Kimi K2 Thinking" },
|
||||
"moonshot/kimi-k2-thinking-turbo": { alias: "Kimi K2 Thinking Turbo" },
|
||||
@@ -135,6 +144,15 @@ Choose your provider and follow the setup steps.
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "Kimi K2.5",
|
||||
@@ -288,7 +306,13 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Native thinking mode">
|
||||
Moonshot Kimi supports binary native thinking:
|
||||
Kimi K2.7 Code always uses native thinking. Moonshot requires clients to
|
||||
omit the `thinking` field for this model, so OpenClaw exposes only `on` and
|
||||
ignores stale `off` settings. K2.7 also fixes `temperature`, `top_p`, `n`,
|
||||
`presence_penalty`, and `frequency_penalty`; OpenClaw omits configured
|
||||
overrides for those fields.
|
||||
|
||||
Other Moonshot Kimi models support binary native thinking:
|
||||
|
||||
- `thinking: { type: "enabled" }`
|
||||
- `thinking: { type: "disabled" }`
|
||||
@@ -311,7 +335,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw also maps runtime `/think` levels for Moonshot:
|
||||
OpenClaw maps runtime `/think` levels for those models:
|
||||
|
||||
| `/think` level | Moonshot behavior |
|
||||
| -------------------- | -------------------------- |
|
||||
@@ -319,14 +343,16 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
|
||||
| Any non-off level | `thinking.type=enabled` |
|
||||
|
||||
<Warning>
|
||||
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible `tool_choice` values to `auto` for compatibility.
|
||||
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible values to `auto`. This includes Kimi K2.7 Code, whose thinking mode cannot be disabled to preserve a pinned tool choice.
|
||||
</Warning>
|
||||
|
||||
Kimi K2.6 also accepts an optional `thinking.keep` field that controls
|
||||
multi-turn retention of `reasoning_content`. Set it to `"all"` to keep full
|
||||
reasoning across turns; omit it (or leave it `null`) to use the server
|
||||
default strategy. OpenClaw only forwards `thinking.keep` for
|
||||
`moonshot/kimi-k2.6` and strips it from other models.
|
||||
`moonshot/kimi-k2.6` and strips it from other models. Kimi K2.7 Code
|
||||
preserves full reasoning history by default while OpenClaw omits the entire
|
||||
`thinking` field.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -347,7 +373,7 @@ Config lives under `plugins.entries.moonshot.config.webSearch`:
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tool call id sanitization">
|
||||
Moonshot Kimi serves tool_call ids shaped like `functions.<name>:<index>`. OpenClaw preserves them unchanged so multi-turn tool calls keep working.
|
||||
Moonshot Kimi serves native tool_call ids shaped like `functions.<name>:<index>`. For the OpenAI-completions transport, OpenClaw preserves the first occurrence of each native Kimi id and rewrites later duplicates to deterministic OpenAI-style `call_*` ids. Matching tool results are remapped with the same id so replay remains unique without stripping Kimi's first native id.
|
||||
|
||||
To force strict sanitization on a custom OpenAI-compatible provider, set `sanitizeToolCallIds: true`:
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ agent session or the CLI.
|
||||
autonomous: {
|
||||
enabled: false,
|
||||
},
|
||||
allowSymlinkTargetWrites: false,
|
||||
approvalPolicy: "pending",
|
||||
maxPending: 50,
|
||||
maxSkillBytes: 40000,
|
||||
@@ -200,6 +201,9 @@ agent session or the CLI.
|
||||
|
||||
- `autonomous.enabled`: allows OpenClaw to create pending proposals from durable
|
||||
conversation signals after successful turns. Default: `false`.
|
||||
- `allowSymlinkTargetWrites`: allows apply to write through workspace skill
|
||||
symlinks whose real target is listed in `skills.load.allowSymlinkTargets`.
|
||||
Default: `false`.
|
||||
- `approvalPolicy: "pending"`: requires an approval prompt before
|
||||
agent-initiated `apply`, `reject`, or `quarantine`.
|
||||
- `approvalPolicy: "auto"`: skips that approval prompt. The agent must still
|
||||
@@ -265,6 +269,7 @@ Default state directory: `~/.openclaw`.
|
||||
| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. |
|
||||
| `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. |
|
||||
| `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. |
|
||||
| `untrusted symlink target` | Configure `skills.load.allowSymlinkTargets` and enable `skills.workshop.allowSymlinkTargetWrites` only for intentional shared skill roots. |
|
||||
| `Support file paths must be under one of...` | Move support files under `assets/`, `examples/`, `references/`, `scripts/`, or `templates/`. |
|
||||
| Proposal does not show in list | Check the selected `--agent` workspace and `OPENCLAW_STATE_DIR`. |
|
||||
| Agent cannot call `skill_workshop` | Check the active tool policy and run mode. `coding` includes the tool; restrictive `tools.allow` policies must list it explicitly, and sandboxed runs must use a normal host-side agent session or the CLI. |
|
||||
|
||||
@@ -29,6 +29,7 @@ Most skills configuration lives under `skills` in
|
||||
},
|
||||
workshop: {
|
||||
autonomous: { enabled: false },
|
||||
allowSymlinkTargetWrites: false,
|
||||
approvalPolicy: "pending",
|
||||
maxPending: 50,
|
||||
maxSkillBytes: 40000,
|
||||
@@ -333,6 +334,13 @@ different visible skill set per agent.
|
||||
quarantine. `auto` allows those actions without approval.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="skills.workshop.allowSymlinkTargetWrites" type="boolean" default="false">
|
||||
Allow Skill Workshop apply to write through workspace skill symlinks whose
|
||||
real target is already trusted by `skills.load.allowSymlinkTargets`. Keep this
|
||||
disabled unless generated proposal applies should mutate that shared skill
|
||||
root.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="skills.workshop.maxPending" type="number" default="50">
|
||||
Maximum pending and quarantined proposals retained per workspace.
|
||||
</ParamField>
|
||||
@@ -365,6 +373,23 @@ With this config, `<workspace>/skills/manager -> ~/Projects/manager/skills` is
|
||||
accepted after realpath resolution. `extraDirs` scans the sibling repo directly;
|
||||
`allowSymlinkTargets` preserves the symlinked path for existing layouts.
|
||||
|
||||
Skill Workshop apply does not write through those symlinks by default. To let
|
||||
Workshop apply mutate skills under already-trusted symlink targets, opt in
|
||||
separately:
|
||||
|
||||
```json5
|
||||
{
|
||||
skills: {
|
||||
load: {
|
||||
allowSymlinkTargets: ["~/Projects/manager/skills"],
|
||||
},
|
||||
workshop: {
|
||||
allowSymlinkTargetWrites: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Managed `~/.openclaw/skills` and personal `~/.agents/skills` directories
|
||||
already accept skill-directory symlinks (per-skill `SKILL.md` containment still
|
||||
applies).
|
||||
|
||||
@@ -204,6 +204,8 @@ publish and sync.
|
||||
Workspace, project-agent, and extra-dir skill discovery only accepts skill
|
||||
roots whose resolved realpath stays inside the configured root, unless
|
||||
`skills.load.allowSymlinkTargets` explicitly trusts a target root.
|
||||
Skill Workshop writes through those trusted targets only when
|
||||
`skills.workshop.allowSymlinkTargetWrites` is enabled.
|
||||
Managed `~/.openclaw/skills` and personal `~/.agents/skills` may contain
|
||||
symlinked skill folders, but every `SKILL.md` realpath must still stay
|
||||
inside its resolved skill directory.
|
||||
@@ -533,6 +535,8 @@ aligned.
|
||||
Use `allowSymlinkTargets` for intentional symlinked layouts where a skill
|
||||
root symlink points outside the configured root, for example
|
||||
`<workspace>/skills/manager -> ~/Projects/manager/skills`.
|
||||
Enable `skills.workshop.allowSymlinkTargetWrites` only when Skill Workshop
|
||||
should also apply proposals through those trusted symlinked paths.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Remote macOS nodes (Linux gateway)">
|
||||
|
||||
@@ -35,7 +35,7 @@ title: "Thinking levels"
|
||||
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
|
||||
- MiniMax M2.x (`minimax/MiniMax-M2*`) on the Anthropic-compatible streaming path defaults to `thinking: { type: "disabled" }` unless you explicitly set thinking in model params or request params. This avoids leaked `reasoning_content` deltas from M2.x's non-native Anthropic stream format. MiniMax-M3 (and M3.x) is exempt: M3 emits proper Anthropic thinking blocks and returns empty content when thinking is disabled, so OpenClaw keeps M3 on the provider's omitted/adaptive thinking path.
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
- Moonshot Kimi K2.7 Code (`moonshot/kimi-k2.7-code`) always thinks. Its profile exposes only `on`, and OpenClaw omits the outbound `thinking` field as required by Moonshot. Other `moonshot/*` models map `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
|
||||
## Resolution order
|
||||
|
||||
|
||||
214
extensions/acpx/npm-shrinkwrap.json
generated
214
extensions/acpx/npm-shrinkwrap.json
generated
@@ -224,9 +224,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -240,9 +240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -256,9 +256,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -272,9 +272,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -288,9 +288,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -304,9 +304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -320,9 +320,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -336,9 +336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -352,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -368,9 +368,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -384,9 +384,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -400,9 +400,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -416,9 +416,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -432,9 +432,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -448,9 +448,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -464,9 +464,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -480,9 +480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -496,9 +496,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -512,9 +512,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -528,9 +528,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -544,9 +544,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -560,9 +560,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -576,9 +576,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -592,9 +592,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -608,9 +608,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -624,9 +624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1208,9 +1208,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -1220,32 +1220,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.0",
|
||||
"@esbuild/android-arm": "0.28.0",
|
||||
"@esbuild/android-arm64": "0.28.0",
|
||||
"@esbuild/android-x64": "0.28.0",
|
||||
"@esbuild/darwin-arm64": "0.28.0",
|
||||
"@esbuild/darwin-x64": "0.28.0",
|
||||
"@esbuild/freebsd-arm64": "0.28.0",
|
||||
"@esbuild/freebsd-x64": "0.28.0",
|
||||
"@esbuild/linux-arm": "0.28.0",
|
||||
"@esbuild/linux-arm64": "0.28.0",
|
||||
"@esbuild/linux-ia32": "0.28.0",
|
||||
"@esbuild/linux-loong64": "0.28.0",
|
||||
"@esbuild/linux-mips64el": "0.28.0",
|
||||
"@esbuild/linux-ppc64": "0.28.0",
|
||||
"@esbuild/linux-riscv64": "0.28.0",
|
||||
"@esbuild/linux-s390x": "0.28.0",
|
||||
"@esbuild/linux-x64": "0.28.0",
|
||||
"@esbuild/netbsd-arm64": "0.28.0",
|
||||
"@esbuild/netbsd-x64": "0.28.0",
|
||||
"@esbuild/openbsd-arm64": "0.28.0",
|
||||
"@esbuild/openbsd-x64": "0.28.0",
|
||||
"@esbuild/openharmony-arm64": "0.28.0",
|
||||
"@esbuild/sunos-x64": "0.28.0",
|
||||
"@esbuild/win32-arm64": "0.28.0",
|
||||
"@esbuild/win32-ia32": "0.28.0",
|
||||
"@esbuild/win32-x64": "0.28.0"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
|
||||
@@ -3,8 +3,6 @@ import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-s
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { AnthropicVertexStreamDeps } from "./stream-runtime.js";
|
||||
|
||||
const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
|
||||
|
||||
function createStreamDeps(): {
|
||||
deps: AnthropicVertexStreamDeps;
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>;
|
||||
@@ -50,8 +48,6 @@ function makeModel(params: {
|
||||
} as Model<"anthropic-messages">;
|
||||
}
|
||||
|
||||
const CACHE_BOUNDARY_PROMPT = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
|
||||
|
||||
type PayloadHook = (payload: unknown, payloadModel: unknown) => Promise<unknown>;
|
||||
|
||||
function streamAnthropicCall(streamAnthropicMock: ReturnType<typeof vi.fn>): unknown[] {
|
||||
@@ -72,8 +68,8 @@ function streamTransportOptions(
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function captureCacheBoundaryPayloadHook(
|
||||
onPayload: PayloadHook,
|
||||
function captureTransportPayloadHook(
|
||||
onPayload: PayloadHook | undefined,
|
||||
deps: AnthropicVertexStreamDeps,
|
||||
streamAnthropicMock: ReturnType<typeof vi.fn>,
|
||||
) {
|
||||
@@ -82,14 +78,8 @@ function captureCacheBoundaryPayloadHook(
|
||||
|
||||
void streamFn(
|
||||
model,
|
||||
{
|
||||
systemPrompt: CACHE_BOUNDARY_PROMPT,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
} as never,
|
||||
{
|
||||
cacheRetention: "short",
|
||||
onPayload,
|
||||
} as never,
|
||||
{ messages: [{ role: "user", content: "Hello" }] } as never,
|
||||
{ cacheRetention: "short", ...(onPayload ? { onPayload } : {}) } as never,
|
||||
);
|
||||
|
||||
const transportOptions = streamTransportOptions(streamAnthropicMock);
|
||||
@@ -97,26 +87,30 @@ function captureCacheBoundaryPayloadHook(
|
||||
return { model, onPayload: transportOptions.onPayload as PayloadHook | undefined };
|
||||
}
|
||||
|
||||
function buildExpectedCacheBoundaryPayload(messageText: string) {
|
||||
// Mirrors the shared anthropic-messages transport output: cache boundary already
|
||||
// split (uncached dynamic suffix) and all four cache_control markers allocated.
|
||||
function buildBudgetedTransportPayload() {
|
||||
return {
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Stable prefix",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Dynamic suffix",
|
||||
},
|
||||
{ type: "text", text: "Stable prefix", cache_control: { type: "ephemeral" } },
|
||||
{ type: "text", text: "Dynamic suffix" },
|
||||
],
|
||||
tools: [
|
||||
{ name: "exec", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "t1", name: "exec", input: {} }] },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: messageText,
|
||||
type: "tool_result",
|
||||
tool_use_id: "t1",
|
||||
content: [],
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
@@ -125,6 +119,29 @@ function buildExpectedCacheBoundaryPayload(messageText: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function countCacheControlMarkers(payload: unknown): number {
|
||||
let count = 0;
|
||||
const visit = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(visit);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.cache_control !== undefined) {
|
||||
count += 1;
|
||||
}
|
||||
visit(record.content);
|
||||
};
|
||||
const record = payload as Record<string, unknown>;
|
||||
visit(record.system);
|
||||
visit(record.tools);
|
||||
visit(record.messages);
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("createAnthropicVertexStreamFn", () => {
|
||||
beforeAll(async () => {
|
||||
({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } =
|
||||
@@ -343,63 +360,35 @@ describe("createAnthropicVertexStreamFn", () => {
|
||||
expect(transportOptions).not.toHaveProperty("temperature");
|
||||
});
|
||||
|
||||
it("applies Anthropic cache-boundary shaping before forwarding payload hooks", async () => {
|
||||
it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async (payload: unknown) => payload);
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
|
||||
const { model, onPayload: transportPayloadHook } = captureTransportPayloadHook(
|
||||
onPayload,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
const payload = {
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: CACHE_BOUNDARY_PROMPT,
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
};
|
||||
const payload = buildBudgetedTransportPayload();
|
||||
|
||||
const nextPayload = await transportPayloadHook?.(payload, model);
|
||||
|
||||
const expectedPayload = buildExpectedCacheBoundaryPayload("Hello");
|
||||
expect(onPayload).toHaveBeenCalledWith(expectedPayload, model);
|
||||
expect(nextPayload).toEqual(expectedPayload);
|
||||
expect(onPayload).toHaveBeenCalledWith(payload, model);
|
||||
expect(countCacheControlMarkers(nextPayload)).toBe(4);
|
||||
expect((nextPayload as ReturnType<typeof buildBudgetedTransportPayload>).system[1]).toEqual({
|
||||
type: "text",
|
||||
text: "Dynamic suffix",
|
||||
});
|
||||
});
|
||||
|
||||
it("reapplies Anthropic cache-boundary shaping when payload hooks return a fresh payload", async () => {
|
||||
it("omits the transport payload hook when the caller provides none", () => {
|
||||
const { deps, streamAnthropicMock } = createStreamDeps();
|
||||
const onPayload = vi.fn(async () => ({
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: CACHE_BOUNDARY_PROMPT,
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "Hello again" }],
|
||||
}));
|
||||
const { model, onPayload: transportPayloadHook } = captureCacheBoundaryPayloadHook(
|
||||
onPayload,
|
||||
const { onPayload: transportPayloadHook } = captureTransportPayloadHook(
|
||||
undefined,
|
||||
deps,
|
||||
streamAnthropicMock,
|
||||
);
|
||||
|
||||
const nextPayload = await transportPayloadHook?.(
|
||||
{
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: CACHE_BOUNDARY_PROMPT,
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
model,
|
||||
);
|
||||
|
||||
expect(nextPayload).toEqual(buildExpectedCacheBoundaryPayload("Hello again"));
|
||||
expect(transportPayloadHook).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits maxTokens when neither the model nor request provide a finite limit", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Anthropic Vertex stream runtime. It constructs Vertex SDK clients and adapts
|
||||
* OpenClaw stream options into Anthropic Messages payload policy.
|
||||
* OpenClaw stream options for the shared Anthropic Messages transport.
|
||||
*/
|
||||
import { AnthropicVertex as AnthropicVertexSdk } from "@anthropic-ai/vertex-sdk";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
@@ -18,10 +18,6 @@ import {
|
||||
supportsClaudeNativeMaxEffort,
|
||||
supportsClaudeNativeXhighEffort,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js";
|
||||
|
||||
type AnthropicVertexTransportOptions = ProviderStreamOptions & {
|
||||
@@ -120,36 +116,6 @@ function resolveAnthropicVertexMaxTokens(params: {
|
||||
return requested ?? modelMax;
|
||||
}
|
||||
|
||||
function createAnthropicVertexOnPayload(params: {
|
||||
model: { api: string; baseUrl?: string; provider: string };
|
||||
cacheRetention: ProviderStreamOptions["cacheRetention"] | undefined;
|
||||
onPayload: ProviderStreamOptions["onPayload"] | undefined;
|
||||
}): NonNullable<ProviderStreamOptions["onPayload"]> {
|
||||
const policy = resolveAnthropicPayloadPolicy({
|
||||
provider: params.model.provider,
|
||||
api: params.model.api,
|
||||
baseUrl: params.model.baseUrl,
|
||||
cacheRetention: params.cacheRetention,
|
||||
enableCacheControl: true,
|
||||
});
|
||||
|
||||
function applyPolicy(payload: unknown): unknown {
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
applyAnthropicPayloadPolicyToParams(payload as Record<string, unknown>, policy);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return async (payload, model) => {
|
||||
const shapedPayload = applyPolicy(payload);
|
||||
const nextPayload = await params.onPayload?.(shapedPayload, model);
|
||||
if (nextPayload === undefined || nextPayload === shapedPayload) {
|
||||
return shapedPayload;
|
||||
}
|
||||
return applyPolicy(nextPayload);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a StreamFn that routes through OpenClaw's generic model stream with an
|
||||
* injected `AnthropicVertex` client. All streaming, message conversion, and
|
||||
@@ -200,11 +166,10 @@ export function createAnthropicVertexStreamFn(
|
||||
cacheRetention: options?.cacheRetention,
|
||||
sessionId: options?.sessionId,
|
||||
headers: options?.headers,
|
||||
onPayload: createAnthropicVertexOnPayload({
|
||||
model: transportModel,
|
||||
cacheRetention: options?.cacheRetention,
|
||||
onPayload: options?.onPayload,
|
||||
}),
|
||||
// The shared anthropic-messages transport already splits the system prompt
|
||||
// cache boundary and budgets all cache_control markers; re-applying the
|
||||
// payload policy here marked the uncached suffix and breached the 4-marker cap.
|
||||
onPayload: options?.onPayload,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
@@ -101,6 +101,28 @@
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5",
|
||||
"name": "Claude Haiku 4.5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-haiku-4-5-20251001",
|
||||
"name": "Claude Haiku 4.5",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"mediaInput": {
|
||||
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
|
||||
},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 64000
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"name": "Claude Sonnet 4.6",
|
||||
|
||||
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
57
extensions/anthropic/openclaw.plugin.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Anthropic tests cover provider manifest model catalog behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type AnthropicManifest = {
|
||||
modelCatalog?: {
|
||||
providers?: {
|
||||
anthropic?: {
|
||||
models?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
input?: string[];
|
||||
mediaInput?: {
|
||||
image?: {
|
||||
maxSidePx?: number;
|
||||
preferredSidePx?: number;
|
||||
tokenMode?: string;
|
||||
};
|
||||
};
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
discovery?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8"),
|
||||
) as AnthropicManifest;
|
||||
|
||||
describe("Anthropic plugin manifest", () => {
|
||||
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
|
||||
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("static");
|
||||
|
||||
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
|
||||
for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) {
|
||||
expect(models.find((model) => model.id === id)).toEqual({
|
||||
id,
|
||||
name: "Claude Haiku 4.5",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
mediaInput: {
|
||||
image: {
|
||||
maxSidePx: 1568,
|
||||
preferredSidePx: 1568,
|
||||
tokenMode: "provider",
|
||||
},
|
||||
},
|
||||
contextWindow: 200000,
|
||||
maxTokens: 64000,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -461,4 +461,24 @@ describe("browser manage output", () => {
|
||||
expect(output).toContain("OK gateway: browser control endpoint reachable");
|
||||
expect(output).toContain("OK tabs: 1 visible, use tab reference t1");
|
||||
});
|
||||
|
||||
it("prints a readable browser doctor failure when gateway auth SecretRefs are unavailable", async () => {
|
||||
const error = Object.assign(new Error("gateway.auth.password unavailable"), {
|
||||
code: "GATEWAY_SECRET_REF_UNAVAILABLE",
|
||||
name: "GatewaySecretRefUnavailableError",
|
||||
});
|
||||
getBrowserManageCallBrowserRequestMock().mockRejectedValueOnce(error);
|
||||
|
||||
const program = createBrowserManageProgram();
|
||||
await expect(program.parseAsync(["browser", "doctor"], { from: "user" })).rejects.toThrow(
|
||||
"__exit__:1",
|
||||
);
|
||||
|
||||
const output = lastRuntimeLog();
|
||||
expect(output).toContain(
|
||||
"FAIL gateway: Gateway auth SecretRef is unavailable in this command path",
|
||||
);
|
||||
expect(output).toContain("OPENCLAW_GATEWAY_TOKEN");
|
||||
expect(output).not.toContain("GatewaySecretRefUnavailableError");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,6 +152,24 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
|
||||
return `${check.ok ? "OK" : "FAIL"} ${check.name}${check.detail ? `: ${check.detail}` : ""}`;
|
||||
}
|
||||
|
||||
function isGatewaySecretRefUnavailableErrorShape(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const errorRecord = error as Error & { code?: unknown };
|
||||
return (
|
||||
errorRecord.name === "GatewaySecretRefUnavailableError" ||
|
||||
errorRecord.code === "GATEWAY_SECRET_REF_UNAVAILABLE"
|
||||
);
|
||||
}
|
||||
|
||||
function formatBrowserDoctorGatewayError(error: unknown): string {
|
||||
if (!isGatewaySecretRefUnavailableErrorShape(error)) {
|
||||
return String(error);
|
||||
}
|
||||
return "Gateway auth SecretRef is unavailable in this command path; browser doctor cannot reach the admin-scoped browser.request endpoint. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD, then retry.";
|
||||
}
|
||||
|
||||
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
|
||||
const checks: BrowserDoctorCheck[] = [];
|
||||
let status: BrowserStatus | null;
|
||||
@@ -167,7 +185,7 @@ async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, dee
|
||||
checks.push({
|
||||
name: "gateway",
|
||||
ok: false,
|
||||
detail: String(err),
|
||||
detail: formatBrowserDoctorGatewayError(err),
|
||||
});
|
||||
return { ok: false, checks };
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ describe("canvas host", () => {
|
||||
};
|
||||
let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler;
|
||||
let startCanvasHost: typeof import("./server.js").startCanvasHost;
|
||||
let canvasLiveReloadMaxInboundMessageBytes = 0;
|
||||
let WebSocketServerClass: typeof import("ws").WebSocketServer;
|
||||
let watcherState: ReturnType<typeof createMockWatcherState>;
|
||||
let fixtureRoot = "";
|
||||
@@ -162,7 +163,10 @@ describe("canvas host", () => {
|
||||
};
|
||||
});
|
||||
vi.resetModules();
|
||||
({ createCanvasHostHandler, startCanvasHost } = await import("./server.js"));
|
||||
const serverModule = await import("./server.js");
|
||||
({ createCanvasHostHandler, startCanvasHost } = serverModule);
|
||||
canvasLiveReloadMaxInboundMessageBytes =
|
||||
serverModule.CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES;
|
||||
const wsModule = await vi.importActual<typeof import("ws")>("ws");
|
||||
WebSocketServerClass = wsModule.WebSocketServer;
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
|
||||
@@ -221,6 +225,54 @@ describe("canvas host", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("caps live reload WebSocket inbound payloads", async () => {
|
||||
const dir = await createCaseDir();
|
||||
const constructorOptions: unknown[] = [];
|
||||
let connectionHandler: ((socket: TrackingWebSocket) => void) | undefined;
|
||||
class CapturingWebSocketServer {
|
||||
on(event: string, cb: (socket: TrackingWebSocket) => void) {
|
||||
if (event === "connection") {
|
||||
connectionHandler = cb;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
close(cb?: () => void) {
|
||||
cb?.();
|
||||
}
|
||||
|
||||
constructor(options: unknown) {
|
||||
constructorOptions.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
const handler = await createTestCanvasHostHandler(dir, {
|
||||
webSocketServerClass:
|
||||
CapturingWebSocketServer as unknown as typeof import("ws").WebSocketServer,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(constructorOptions[0]).toMatchObject({
|
||||
noServer: true,
|
||||
maxPayload: canvasLiveReloadMaxInboundMessageBytes,
|
||||
});
|
||||
const socketHandlers: string[] = [];
|
||||
const socket: TrackingWebSocket = {
|
||||
sent: [],
|
||||
on: (event) => {
|
||||
socketHandlers.push(event);
|
||||
return socket;
|
||||
},
|
||||
send: vi.fn(),
|
||||
};
|
||||
expect(connectionHandler).toBeDefined();
|
||||
connectionHandler?.(socket);
|
||||
expect(socketHandlers).toEqual(expect.arrayContaining(["error", "close"]));
|
||||
} finally {
|
||||
await handler.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the default mount when the configured base path is malformed", async () => {
|
||||
const dir = await createCaseDir();
|
||||
await fs.writeFile(path.join(dir, "index.html"), "<html><body>fallback</body></html>", "utf8");
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
} from "./a2ui-shared.js";
|
||||
import { normalizeUrlPath, resolveFileWithinRoot } from "./file-resolver.js";
|
||||
|
||||
export const CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES = 64 * 1024;
|
||||
|
||||
type ChokidarWatch = typeof import("chokidar").watch;
|
||||
|
||||
/** Options for Canvas host creation. */
|
||||
@@ -276,11 +278,22 @@ export async function createCanvasHostHandler(
|
||||
const writeStabilityThresholdMs = testMode ? 12 : 75;
|
||||
const writePollIntervalMs = testMode ? 5 : 10;
|
||||
const WebSocketServerClass = opts.webSocketServerClass ?? WebSocketServer;
|
||||
const wss = liveReload ? new WebSocketServerClass({ noServer: true }) : null;
|
||||
const wss = liveReload
|
||||
? new WebSocketServerClass({
|
||||
noServer: true,
|
||||
// Live reload clients never need to send application payloads; cap frames
|
||||
// before ws buffers oversized input on this long-lived upgrade route.
|
||||
maxPayload: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES,
|
||||
})
|
||||
: null;
|
||||
const sockets = new Set<WebSocket>();
|
||||
if (wss) {
|
||||
wss.on("connection", (ws) => {
|
||||
sockets.add(ws);
|
||||
// ws emits error for maxPayload rejections; close handles final cleanup.
|
||||
ws.on("error", () => {
|
||||
sockets.delete(ws);
|
||||
});
|
||||
ws.on("close", () => sockets.delete(ws));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type EmbeddedRunAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
import type { CodexDynamicToolSpec, JsonValue } from "./protocol.js";
|
||||
import { isJsonObject } from "./protocol.js";
|
||||
import type { CodexAppServerThreadBinding } from "./session-binding.js";
|
||||
@@ -249,9 +250,11 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
|
||||
turnScopedDeveloperInstructionFiles,
|
||||
),
|
||||
memoryCollaborationInstructions: shouldInjectCodexOpenClawPromptContext(params.params)
|
||||
? renderCodexWorkspaceMemoryReference({
|
||||
? renderCodexWorkspaceMemoryCollaborationInstructions({
|
||||
files: memoryReferenceFiles,
|
||||
toolNames: params.memoryToolNames,
|
||||
memoryToolRouted: memoryToolsAvailable,
|
||||
citationsMode: params.params.config?.memory?.citations,
|
||||
})
|
||||
: undefined,
|
||||
heartbeatCollaborationInstructions:
|
||||
@@ -805,6 +808,55 @@ export function renderCodexWorkspaceMemoryReference(params: {
|
||||
return lines.join("\n").trim();
|
||||
}
|
||||
|
||||
function renderCodexWorkspaceMemoryCollaborationInstructions(params: {
|
||||
files: EmbeddedContextFile[];
|
||||
toolNames: readonly string[];
|
||||
memoryToolRouted: boolean;
|
||||
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
|
||||
}): string | undefined {
|
||||
const memoryRecallInstructions = params.memoryToolRouted
|
||||
? renderCodexMemoryRecallInstructions({
|
||||
toolNames: params.toolNames,
|
||||
citationsMode: params.citationsMode,
|
||||
})
|
||||
: undefined;
|
||||
const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({
|
||||
files: params.files,
|
||||
toolNames: params.toolNames,
|
||||
});
|
||||
const sections = [memoryRecallInstructions, memoryReferenceInstructions].filter(isNonEmptyString);
|
||||
return sections.length > 0 ? sections.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
function renderCodexMemoryRecallInstructions(params: {
|
||||
toolNames: readonly string[];
|
||||
citationsMode?: Parameters<typeof buildMemorySystemPromptAddition>[0]["citationsMode"];
|
||||
}): string | undefined {
|
||||
const availableTools = new Set(params.toolNames);
|
||||
const memoryPrompt = buildMemorySystemPromptAddition({
|
||||
availableTools,
|
||||
citationsMode: params.citationsMode,
|
||||
});
|
||||
if (!memoryPrompt) {
|
||||
// Memory recall policy belongs to the active memory plugin.
|
||||
// Codex-side fallback text can mask plugin lifecycle bugs or misdescribe third-party memory tools.
|
||||
return undefined;
|
||||
}
|
||||
const toolSearchBridge = renderCodexMemoryToolSearchBridge(params.toolNames);
|
||||
return [memoryPrompt, toolSearchBridge].filter(isNonEmptyString).join("\n").trim();
|
||||
}
|
||||
|
||||
function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string | undefined {
|
||||
const memoryToolNames = toolNames
|
||||
.map((name) => normalizeCodexDynamicToolName(name))
|
||||
.filter((name) => CODEX_MEMORY_TOOL_NAMES.has(name))
|
||||
.toSorted();
|
||||
if (memoryToolNames.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
|
||||
}
|
||||
|
||||
/** Returns whether the current dynamic tool list can serve workspace memory. */
|
||||
export function hasCodexWorkspaceMemoryTools(tools: readonly { name: string }[]): boolean {
|
||||
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resetDiagnosticEventsForTest } from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { clearInternalHooks, resetGlobalHookRunner } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { clearMemoryPluginState } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { afterEach, beforeEach, expect, vi } from "vitest";
|
||||
@@ -495,6 +496,7 @@ export function setupRunAttemptTestHooks(): void {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
clearInternalHooks();
|
||||
clearMemoryPluginState();
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
|
||||
@@ -512,6 +514,7 @@ export function setupRunAttemptTestHooks(): void {
|
||||
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
clearMemoryPluginState();
|
||||
clearPluginCommands();
|
||||
resetAgentEventsForTest();
|
||||
resetDiagnosticEventsForTest();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type DiagnosticEventPayload,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import { initializeGlobalHookRunner, registerInternalHook } from "openclaw/plugin-sdk/hook-runtime";
|
||||
import { registerMemoryCapability } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -397,6 +398,37 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
|
||||
};
|
||||
}
|
||||
|
||||
function registerMemoryPromptForTest() {
|
||||
registerMemoryCapability("memory-core", {
|
||||
promptBuilder({ availableTools }) {
|
||||
const hasMemorySearch = availableTools.has("memory_search");
|
||||
const hasMemoryGet = availableTools.has("memory_get");
|
||||
if (hasMemorySearch && hasMemoryGet) {
|
||||
return [
|
||||
"## Memory Recall",
|
||||
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts; then use memory_get.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
if (hasMemorySearch) {
|
||||
return [
|
||||
"## Memory Recall",
|
||||
"Test recall: run memory_search on MEMORY.md + memory/*.md + indexed session transcripts.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
if (hasMemoryGet) {
|
||||
return [
|
||||
"## Memory Recall",
|
||||
"Test recall: run memory_get for a specific memory file or note.",
|
||||
"",
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function buildEmptyCodexToolTelemetry(): CodexAppServerToolTelemetry {
|
||||
return {
|
||||
didSendViaMessagingTool: false,
|
||||
@@ -2203,6 +2235,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
await fs.writeFile(path.join(workspaceDir, "TOOLS.md"), toolGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "USER.md"), userProfile);
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
registerMemoryPromptForTest();
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
@@ -2236,12 +2269,20 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(collaborationInstructions).toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
expect(collaborationInstructions).toContain(userProfile);
|
||||
expect(collaborationInstructions).toContain("## Memory Recall");
|
||||
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
|
||||
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
|
||||
expect(collaborationInstructions).toContain(
|
||||
"MEMORY.md exists in the active agent workspace as a memory file, not an instruction file",
|
||||
);
|
||||
expect(collaborationInstructions).toContain("memory_search");
|
||||
expect(collaborationInstructions).toContain("memory_get");
|
||||
expect(collaborationInstructions).toContain(
|
||||
"When the memory guidance above calls for memory recall, use an already-loaded memory tool directly.",
|
||||
);
|
||||
expect(collaborationInstructions).toContain(
|
||||
"If the needed memory tool is deferred and not currently callable, use `tool_search` to load it, then call that memory tool.",
|
||||
);
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
expect(inputText).not.toContain("OpenClaw runtime context for this turn:");
|
||||
expect(inputText).not.toContain("does not override Codex system/developer instructions");
|
||||
@@ -2297,6 +2338,65 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds memory recall guidance when dated memory notes exist without root MEMORY.md", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const datedMemory = "User avoids Chase cards while over 5/24.";
|
||||
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "memory/2026-06-09.md"), datedMemory);
|
||||
registerMemoryPromptForTest();
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
|
||||
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
expect(collaborationInstructions).toContain("## Memory Recall");
|
||||
expect(collaborationInstructions).toContain("MEMORY.md + memory/*.md");
|
||||
expect(collaborationInstructions).toContain("memory_search");
|
||||
expect(collaborationInstructions).toContain("memory_get");
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
|
||||
expect(collaborationInstructions).not.toContain(datedMemory);
|
||||
expect(inputText).toBe("hello");
|
||||
expect(inputText).not.toContain(datedMemory);
|
||||
});
|
||||
|
||||
it("does not synthesize memory recall guidance without a registered memory prompt builder", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const memorySummary = "User avoids Chase cards while over 5/24.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
]);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, workspaceDir);
|
||||
|
||||
const { collaborationInstructions, inputText } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
expect(collaborationInstructions).not.toContain("## Memory Recall");
|
||||
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
|
||||
expect(collaborationInstructions).not.toContain("Use `tool_search` first");
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
expect(inputText).toBe("hello");
|
||||
expect(inputText).not.toContain(memorySummary);
|
||||
});
|
||||
|
||||
it("sends workspace bootstrap instructions through Codex app-server payloads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -2405,6 +2505,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const memorySummary = "Memory summary goes here.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
registerMemoryPromptForTest();
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("memory_get")]);
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
@@ -2417,6 +2518,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).not.toContain("memory_get");
|
||||
expect(inputText).not.toContain("memory_search");
|
||||
expect(inputText).not.toContain(memorySummary);
|
||||
expect(collaborationInstructions).toContain("## Memory Recall");
|
||||
expect(collaborationInstructions).toContain("OpenClaw Workspace Memory");
|
||||
expect(collaborationInstructions).toContain("memory_get");
|
||||
expect(collaborationInstructions).not.toContain("memory_search");
|
||||
@@ -2595,6 +2697,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const memorySummary = "Memory summary goes here.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), memorySummary);
|
||||
registerMemoryPromptForTest();
|
||||
testing.setOpenClawCodingToolsFactoryForTests(() => [
|
||||
createRuntimeDynamicTool("memory_search"),
|
||||
createRuntimeDynamicTool("memory_get"),
|
||||
@@ -2604,10 +2707,10 @@ describe("runCodexAppServerAttempt", () => {
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
setAgentWorkspaceForTest(params, path.join(tempDir, "memory-workspace"));
|
||||
|
||||
const { inputText, systemPromptReport } = await buildCodexTurnContextForTest(
|
||||
params,
|
||||
workspaceDir,
|
||||
);
|
||||
const { collaborationInstructions, inputText, systemPromptReport } =
|
||||
await buildCodexTurnContextForTest(params, workspaceDir);
|
||||
expect(collaborationInstructions).not.toContain("## Memory Recall");
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).not.toContain("OpenClaw Workspace Memory");
|
||||
expect(inputText).toContain(memorySummary);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Codex tests cover sandbox exec server plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES,
|
||||
closeCodexSandboxExecServersForTests,
|
||||
ensureCodexSandboxExecServerEnvironment,
|
||||
releaseCodexSandboxExecServerEnvironment,
|
||||
@@ -191,6 +192,22 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("closes oversized sandbox exec-server frames before JSON-RPC parsing", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
|
||||
await ensureCodexSandboxExecServerEnvironment({
|
||||
client: client as never,
|
||||
sandbox,
|
||||
});
|
||||
const socket = await openSocket(execServerUrlFromClient(client));
|
||||
const closed = waitForSocketClose(socket);
|
||||
|
||||
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
|
||||
|
||||
await expect(closed).resolves.toEqual({ code: 1009 });
|
||||
});
|
||||
|
||||
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
|
||||
const buildExecSpec = vi.fn(async () => ({
|
||||
argv: [process.execPath, "-e", ""],
|
||||
@@ -441,6 +458,26 @@ describe("OpenClaw Codex sandbox exec-server", () => {
|
||||
await expect(waitForSocketClose(socket)).resolves.toEqual({ code: 1008 });
|
||||
});
|
||||
|
||||
it("handles oversized frames from unauthorized WebSocket clients", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
await ensureCodexSandboxExecServerEnvironment({
|
||||
client: client as never,
|
||||
sandbox,
|
||||
});
|
||||
const unauthorizedUrl = execServerUrlFromClient(client).replace(
|
||||
/\/openclaw-[^/?#]+/u,
|
||||
"/wrong",
|
||||
);
|
||||
const socket = await openSocket(unauthorizedUrl);
|
||||
const closed = waitForSocketClose(socket);
|
||||
|
||||
socket.send(Buffer.alloc(CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES + 1));
|
||||
|
||||
const closeResult = await closed;
|
||||
expect([1008, 1009]).toContain(closeResult.code);
|
||||
});
|
||||
|
||||
it("closes the exec-server when its sandbox environment is released", async () => {
|
||||
const sandbox = createSandboxContext({});
|
||||
const client = createClient();
|
||||
|
||||
@@ -48,6 +48,7 @@ export type CodexSandboxExecEnvironment = {
|
||||
};
|
||||
|
||||
const SANDBOX_EXEC_SERVERS = new Map<string, Promise<OpenClawExecServer>>();
|
||||
export const CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
/** Closes all cached sandbox exec-server instances for deterministic tests. */
|
||||
export async function closeCodexSandboxExecServersForTests(): Promise<void> {
|
||||
@@ -193,7 +194,13 @@ function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise<Op
|
||||
}
|
||||
|
||||
async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenClawExecServer> {
|
||||
const server = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
||||
const server = new WebSocketServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
// Match ws' historical default: Codex fs/writeFile sends one base64 JSON-RPC
|
||||
// frame, while the socket error handler below makes oversize frames nonfatal.
|
||||
maxPayload: CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES,
|
||||
});
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
@@ -212,6 +219,8 @@ async function startOpenClawExecServer(sandbox: SandboxContext): Promise<OpenCla
|
||||
server,
|
||||
};
|
||||
server.on("connection", (socket, request) => {
|
||||
// ws emits error for maxPayload rejections before auth or JSON-RPC sees the frame.
|
||||
socket.on("error", handleExecServerSocketError);
|
||||
if (!isAuthorizedExecServerRequest(execServer, request)) {
|
||||
socket.close(1008, "unauthorized");
|
||||
return;
|
||||
@@ -286,6 +295,10 @@ function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): vo
|
||||
});
|
||||
}
|
||||
|
||||
function handleExecServerSocketError(error: unknown): void {
|
||||
embeddedAgentLog.debug("codex sandbox exec-server websocket failed", { error });
|
||||
}
|
||||
|
||||
async function handleMessage(
|
||||
execServer: OpenClawExecServer,
|
||||
processes: Map<string, ManagedProcess>,
|
||||
|
||||
@@ -171,6 +171,7 @@ import {
|
||||
type DiagnosticEventPrivateData,
|
||||
} from "openclaw/plugin-sdk/diagnostic-runtime";
|
||||
import {
|
||||
emitDiagnosticEventWithTrustedTraceContext,
|
||||
emitInternalDiagnosticEventForTest,
|
||||
logMessageDispatchStarted,
|
||||
logMessageProcessed,
|
||||
@@ -362,7 +363,11 @@ function histogramCreateOptions(name: string) {
|
||||
|
||||
async function emitAndCaptureLog(
|
||||
event: Omit<Extract<Parameters<typeof emitDiagnosticEvent>[0], { type: "log.record" }>, "type">,
|
||||
options: { captureContent?: OtelContextFlags["captureContent"]; trusted?: boolean } = {},
|
||||
options: {
|
||||
captureContent?: OtelContextFlags["captureContent"];
|
||||
trusted?: boolean;
|
||||
trustedTraceContext?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, {
|
||||
@@ -370,7 +375,11 @@ async function emitAndCaptureLog(
|
||||
...(options.captureContent !== undefined ? { captureContent: options.captureContent } : {}),
|
||||
});
|
||||
await service.start(ctx);
|
||||
const emit = options.trusted ? emitTrustedDiagnosticEvent : emitDiagnosticEvent;
|
||||
const emit = options.trusted
|
||||
? emitTrustedDiagnosticEvent
|
||||
: options.trustedTraceContext
|
||||
? emitDiagnosticEventWithTrustedTraceContext
|
||||
: emitDiagnosticEvent;
|
||||
emit({
|
||||
type: "log.record",
|
||||
...event,
|
||||
@@ -1391,6 +1400,28 @@ describe("diagnostics-otel service", () => {
|
||||
expect(emitCall?.context).toBeUndefined();
|
||||
});
|
||||
|
||||
test("attaches trace-only trusted context to exported logs", async () => {
|
||||
const emitCall = await emitAndCaptureLog(
|
||||
{
|
||||
level: "INFO",
|
||||
message: "traceable log",
|
||||
trace: {
|
||||
traceId: TRACE_ID,
|
||||
spanId: SPAN_ID,
|
||||
traceFlags: "01",
|
||||
},
|
||||
},
|
||||
{ trustedTraceContext: true },
|
||||
);
|
||||
|
||||
expect(emitCall?.body).toBe("log");
|
||||
expect(telemetryState.tracer.setSpanContext).toHaveBeenCalledTimes(1);
|
||||
const emitContext = emitCall?.context as { spanContext?: Record<string, unknown> } | undefined;
|
||||
const emitSpanContext = emitContext?.spanContext;
|
||||
expect(emitSpanContext?.traceId).toBe(TRACE_ID);
|
||||
expect(emitSpanContext?.spanId).toBe(SPAN_ID);
|
||||
});
|
||||
|
||||
test("attaches trusted diagnostic trace context to exported logs", async () => {
|
||||
const emitCall = await emitAndCaptureLog(
|
||||
{
|
||||
|
||||
@@ -1031,7 +1031,9 @@ function contextForTrustedTraceContext(
|
||||
evt: DiagnosticEventPayload,
|
||||
metadata: DiagnosticEventMetadata,
|
||||
) {
|
||||
return metadata.trusted ? contextForTraceContext(evt.trace) : undefined;
|
||||
return metadata.trusted || metadata.trustedTraceContext === true
|
||||
? contextForTraceContext(evt.trace)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function addTraceAttributes(
|
||||
@@ -1626,7 +1628,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
if (evt.code?.functionName) {
|
||||
assignOtelLogAttribute(attributes, "code.function", evt.code.functionName);
|
||||
}
|
||||
if (metadata.trusted) {
|
||||
if (metadata.trusted || metadata.trustedTraceContext === true) {
|
||||
addTraceAttributes(attributes, evt.trace);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,75 @@ function hasDiscordComponentObjectKeys(value: unknown): value is Record<string,
|
||||
);
|
||||
}
|
||||
|
||||
function readDiscordThreadArchiveTimestamp(thread: unknown): string | undefined {
|
||||
if (!thread || typeof thread !== "object" || Array.isArray(thread)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = thread as Record<string, unknown>;
|
||||
const metadata = record.thread_metadata;
|
||||
if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
|
||||
const archiveTimestamp = (metadata as Record<string, unknown>).archive_timestamp;
|
||||
if (typeof archiveTimestamp === "string" && archiveTimestamp.trim()) {
|
||||
return archiveTimestamp;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type DiscordThreadListActionResult = {
|
||||
ok: true;
|
||||
threads: unknown;
|
||||
complete: boolean;
|
||||
hasMore: boolean;
|
||||
returnedCount: number;
|
||||
source: "discord.threadList.archived" | "discord.threadList.active";
|
||||
query: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
includeArchived: boolean;
|
||||
before?: string;
|
||||
limit?: number;
|
||||
};
|
||||
nextBefore?: string;
|
||||
};
|
||||
|
||||
function normalizeDiscordThreadListActionResult(params: {
|
||||
value: unknown;
|
||||
includeArchived: boolean;
|
||||
channelId?: string;
|
||||
guildId: string;
|
||||
limit?: number;
|
||||
before?: string;
|
||||
}): DiscordThreadListActionResult {
|
||||
const record =
|
||||
params.value && typeof params.value === "object" && !Array.isArray(params.value)
|
||||
? (params.value as Record<string, unknown>)
|
||||
: undefined;
|
||||
const threadItems = Array.isArray(record?.threads) ? record.threads : [];
|
||||
const hasMore = record?.has_more === true;
|
||||
const nextBefore =
|
||||
params.includeArchived && hasMore
|
||||
? readDiscordThreadArchiveTimestamp(threadItems[threadItems.length - 1])
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
threads: params.value,
|
||||
complete: !hasMore,
|
||||
hasMore,
|
||||
returnedCount: threadItems.length,
|
||||
source: params.includeArchived ? "discord.threadList.archived" : "discord.threadList.active",
|
||||
query: {
|
||||
guildId: params.guildId,
|
||||
...(params.channelId ? { channelId: params.channelId } : {}),
|
||||
includeArchived: params.includeArchived,
|
||||
...(params.before ? { before: params.before } : {}),
|
||||
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
||||
},
|
||||
...(nextBefore ? { nextBefore } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function appendDiscordThreadRenameResult(
|
||||
ctx: DiscordMessagingActionContext,
|
||||
params: {
|
||||
@@ -306,7 +375,16 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
|
||||
},
|
||||
ctx.withOpts(),
|
||||
);
|
||||
return jsonResult({ ok: true, threads });
|
||||
return jsonResult(
|
||||
normalizeDiscordThreadListActionResult({
|
||||
value: threads,
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived: includeArchived === true,
|
||||
before,
|
||||
limit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case "threadReply": {
|
||||
if (!ctx.isActionEnabled("threads")) {
|
||||
|
||||
@@ -101,6 +101,7 @@ const {
|
||||
kickMemberDiscord,
|
||||
listGuildChannelsDiscord,
|
||||
listPinsDiscord,
|
||||
listThreadsDiscord,
|
||||
moveChannelDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
@@ -271,6 +272,138 @@ describe("handleDiscordMessagingAction", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces incomplete archived thread pages at the action boundary", async () => {
|
||||
listThreadsDiscord.mockResolvedValueOnce({
|
||||
threads: [
|
||||
{
|
||||
id: "thread-1",
|
||||
name: "Old project",
|
||||
thread_metadata: {
|
||||
archive_timestamp: "2026-05-25T17:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
has_more: true,
|
||||
});
|
||||
|
||||
const result = await handleMessagingAction(
|
||||
"threadList",
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
includeArchived: true,
|
||||
before: "2026-05-26T17:00:00.000Z",
|
||||
limit: 1,
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(mockCall(listThreadsDiscord, "listThreadsDiscord")).toEqual([
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
includeArchived: true,
|
||||
before: "2026-05-26T17:00:00.000Z",
|
||||
limit: 1,
|
||||
},
|
||||
{ cfg: DISCORD_TEST_CFG },
|
||||
]);
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
complete: false,
|
||||
hasMore: true,
|
||||
returnedCount: 1,
|
||||
source: "discord.threadList.archived",
|
||||
nextBefore: "2026-05-25T17:00:00.000Z",
|
||||
query: {
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
includeArchived: true,
|
||||
before: "2026-05-26T17:00:00.000Z",
|
||||
limit: 1,
|
||||
},
|
||||
});
|
||||
expect((result.details as { threads?: unknown }).threads).toEqual({
|
||||
threads: [
|
||||
{
|
||||
id: "thread-1",
|
||||
name: "Old project",
|
||||
thread_metadata: {
|
||||
archive_timestamp: "2026-05-25T17:00:00.000Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
has_more: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("omits archived thread pagination cursors when Discord omits archive timestamps", async () => {
|
||||
listThreadsDiscord.mockResolvedValueOnce({
|
||||
threads: [
|
||||
{
|
||||
id: "thread-without-archive-timestamp",
|
||||
name: "Legacy project",
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
has_more: true,
|
||||
});
|
||||
|
||||
const result = await handleMessagingAction(
|
||||
"threadList",
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
includeArchived: true,
|
||||
limit: 1,
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
complete: false,
|
||||
hasMore: true,
|
||||
returnedCount: 1,
|
||||
source: "discord.threadList.archived",
|
||||
});
|
||||
expect(result.details).not.toHaveProperty("nextBefore");
|
||||
});
|
||||
|
||||
it("marks active thread results complete when Discord returns no pagination state", async () => {
|
||||
listThreadsDiscord.mockResolvedValueOnce({
|
||||
threads: [{ id: "thread-active", name: "Current project" }],
|
||||
members: [{ id: "member-1" }],
|
||||
});
|
||||
|
||||
const result = await handleMessagingAction(
|
||||
"threadList",
|
||||
{
|
||||
guildId: "G1",
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(result.details).toMatchObject({
|
||||
ok: true,
|
||||
complete: true,
|
||||
hasMore: false,
|
||||
returnedCount: 1,
|
||||
source: "discord.threadList.active",
|
||||
query: {
|
||||
guildId: "G1",
|
||||
includeArchived: false,
|
||||
},
|
||||
});
|
||||
expect((result.details as { threads?: unknown }).threads).toEqual({
|
||||
threads: [{ id: "thread-active", name: "Current project" }],
|
||||
members: [{ id: "member-1" }],
|
||||
});
|
||||
expect(result.details).not.toHaveProperty("nextBefore");
|
||||
});
|
||||
|
||||
it("resolves Discord DM targets for reaction adds", async () => {
|
||||
const resolveReactionTarget = vi.fn(async () => "DM1");
|
||||
discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget;
|
||||
|
||||
@@ -193,7 +193,7 @@ describe("Discord model picker preference migration", () => {
|
||||
expect(plan.pluginId).toBe("discord");
|
||||
expect(plan.namespace).toBe("thread-bindings");
|
||||
const entries = await plan.readEntries();
|
||||
expect(entries).toEqual([
|
||||
expect(entries).toStrictEqual([
|
||||
{
|
||||
key: "default:legacy-thread",
|
||||
value: {
|
||||
|
||||
@@ -195,7 +195,10 @@ export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrat
|
||||
)) {
|
||||
const normalized = normalizePersistedBinding(rawKey, rawEntry);
|
||||
if (normalized) {
|
||||
out.push({ key: toBindingRecordKey(normalized), value: normalized });
|
||||
out.push({
|
||||
key: toBindingRecordKey(normalized),
|
||||
value: normalized,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -215,23 +215,36 @@ export function normalizePersistedBinding(
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const record: ThreadBindingRecord = {
|
||||
accountId,
|
||||
channelId,
|
||||
threadId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
webhookId,
|
||||
webhookToken,
|
||||
boundBy,
|
||||
boundAt,
|
||||
lastActivityAt,
|
||||
idleTimeoutMs: migratedIdleTimeoutMs,
|
||||
maxAgeMs: migratedMaxAgeMs,
|
||||
metadata,
|
||||
};
|
||||
if (label !== undefined) {
|
||||
record.label = label;
|
||||
}
|
||||
if (webhookId !== undefined) {
|
||||
record.webhookId = webhookId;
|
||||
}
|
||||
if (webhookToken !== undefined) {
|
||||
record.webhookToken = webhookToken;
|
||||
}
|
||||
if (migratedIdleTimeoutMs !== undefined) {
|
||||
record.idleTimeoutMs = migratedIdleTimeoutMs;
|
||||
}
|
||||
if (migratedMaxAgeMs !== undefined) {
|
||||
record.maxAgeMs = migratedMaxAgeMs;
|
||||
}
|
||||
if (metadata !== undefined) {
|
||||
record.metadata = metadata;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function normalizeThreadBindingDurationMs(raw: unknown, defaultsTo: number): number {
|
||||
|
||||
@@ -525,6 +525,40 @@ describe("handleFeishuMessage ACP routing", () => {
|
||||
expect(message.text).toContain("runtime unavailable");
|
||||
});
|
||||
|
||||
it("surfaces configured ACP initialization failures inside P2P direct-message threads", async () => {
|
||||
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
|
||||
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
|
||||
createConfiguredBindingReadiness(false, "runtime unavailable"),
|
||||
);
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
||||
},
|
||||
event: {
|
||||
sender: { sender_id: { open_id: "ou_sender_1" } },
|
||||
message: {
|
||||
message_id: "msg-thread-child",
|
||||
root_id: "msg-thread-root",
|
||||
thread_id: "omt-acp-dm-thread",
|
||||
chat_id: "oc_dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc_dm",
|
||||
replyToMessageId: "msg-thread-root",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Feishu topic messages through active bound conversations", async () => {
|
||||
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
|
||||
|
||||
@@ -3359,6 +3393,79 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
expect(dispatcherOptions.rootId).toBe("om_topic_sender_root");
|
||||
});
|
||||
|
||||
it("keeps P2P replies inside a direct-message thread when Feishu supplies thread_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-thread-dm" } },
|
||||
message: {
|
||||
message_id: "om_dm_thread_child",
|
||||
root_id: "om_dm_thread_root",
|
||||
thread_id: "omt_dm_thread",
|
||||
chat_id: "oc-dm-thread",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello inside a DM thread" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_dm_thread_root",
|
||||
rootId: "om_dm_thread_root",
|
||||
skipReplyToInMessages: false,
|
||||
replyInThread: true,
|
||||
threadReply: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps root_id-only P2P replies as quote replies outside thread mode", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-quote-dm" } },
|
||||
message: {
|
||||
message_id: "om_dm_quote_reply",
|
||||
root_id: "om_dm_quote_root",
|
||||
chat_id: "oc-dm-quote",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "quoted DM reply" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_dm_quote_reply",
|
||||
rootId: "om_dm_quote_root",
|
||||
skipReplyToInMessages: true,
|
||||
replyInThread: false,
|
||||
threadReply: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forces thread replies when inbound message contains thread_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -802,7 +802,14 @@ export async function handleFeishuMessage(params: {
|
||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
||||
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
||||
const directThreadReply = !isGroup && Boolean(ctx.threadId?.trim());
|
||||
const defaultReplyTargetMessageId =
|
||||
ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId);
|
||||
const directThreadRootId = directThreadReply ? ctx.rootId?.trim() || undefined : undefined;
|
||||
const directThreadReplyTargetMessageId = directThreadReply
|
||||
? (directThreadRootId ?? defaultReplyTargetMessageId)
|
||||
: undefined;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadReply;
|
||||
const feishuAcpConversationSupported =
|
||||
!isGroup ||
|
||||
groupSession?.groupSessionScope === "group_topic" ||
|
||||
@@ -906,10 +913,13 @@ export async function handleFeishuMessage(params: {
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
const replyTargetMessageId =
|
||||
const acpTopicReply =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender")
|
||||
groupSession?.groupSessionScope === "group_topic_sender");
|
||||
const replyTargetMessageId = directThreadReply
|
||||
? directThreadReplyTargetMessageId
|
||||
: acpTopicReply
|
||||
? (ctx.rootId ?? ctx.messageId)
|
||||
: ctx.messageId;
|
||||
await sendMessageFeishu({
|
||||
@@ -917,7 +927,7 @@ export async function handleFeishuMessage(params: {
|
||||
to: `chat:${ctx.chatId}`,
|
||||
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
|
||||
replyInThread,
|
||||
accountId: account.accountId,
|
||||
}).catch((err: unknown) => {
|
||||
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
|
||||
@@ -1387,13 +1397,13 @@ export async function handleFeishuMessage(params: {
|
||||
const configReplyInThread =
|
||||
isGroup &&
|
||||
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
||||
const replyTargetMessageId =
|
||||
isTopicSession || configReplyInThread
|
||||
? (ctx.rootId ??
|
||||
ctx.replyTargetMessageId ??
|
||||
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
|
||||
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
|
||||
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
||||
const topicReplyTargetMessageId = ctx.rootId ?? defaultReplyTargetMessageId;
|
||||
const replyTargetMessageId = directThreadReply
|
||||
? directThreadReplyTargetMessageId
|
||||
: isTopicSession || configReplyInThread
|
||||
? topicReplyTargetMessageId
|
||||
: defaultReplyTargetMessageId;
|
||||
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadReply;
|
||||
const lastRouteThreadId =
|
||||
isGroup && (isTopicSession || configReplyInThread || threadReply)
|
||||
? replyTargetMessageId
|
||||
@@ -1518,7 +1528,7 @@ export async function handleFeishuMessage(params: {
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
skipReplyToInMessages: !isGroup && !directThreadReply,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
@@ -1694,7 +1704,7 @@ export async function handleFeishuMessage(params: {
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
skipReplyToInMessages: !isGroup && !directThreadReply,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
|
||||
@@ -144,19 +144,19 @@ describe("fireworks provider plugin", () => {
|
||||
expect(resolved?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
it("disables reasoning metadata for Fireworks Kimi k2.6 dynamic models", async () => {
|
||||
it("defers manifest catalog models to core static-catalog resolution", async () => {
|
||||
const provider = await registerSingleProviderPlugin(fireworksPlugin);
|
||||
const resolved = provider.resolveDynamicModel?.(
|
||||
createProviderDynamicModelContext({
|
||||
provider: "fireworks",
|
||||
modelId: "accounts/fireworks/models/kimi-k2p6",
|
||||
models: [createFireworksDefaultRuntimeModel({ reasoning: false })],
|
||||
}),
|
||||
);
|
||||
for (const modelId of [FIREWORKS_K2_6_MODEL_ID, FIREWORKS_DEFAULT_MODEL_ID]) {
|
||||
const resolved = provider.resolveDynamicModel?.(
|
||||
createProviderDynamicModelContext({
|
||||
provider: "fireworks",
|
||||
modelId,
|
||||
models: [createFireworksDefaultRuntimeModel({ reasoning: false })],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolved?.provider).toBe("fireworks");
|
||||
expect(resolved?.id).toBe("accounts/fireworks/models/kimi-k2p6");
|
||||
expect(resolved?.reasoning).toBe(false);
|
||||
expect(resolved).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes off-only thinking policy for Fireworks Kimi models", async () => {
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
FIREWORKS_DEFAULT_CONTEXT_WINDOW,
|
||||
FIREWORKS_DEFAULT_MAX_TOKENS,
|
||||
FIREWORKS_DEFAULT_MODEL_ID,
|
||||
isFireworksCatalogModelId,
|
||||
} from "./provider-catalog.js";
|
||||
import { wrapFireworksProviderStream } from "./stream.js";
|
||||
import { resolveFireworksThinkingProfile } from "./thinking-policy.js";
|
||||
|
||||
const PROVIDER_ID = "fireworks";
|
||||
|
||||
function isFireworksGlmModelId(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
const lastSegment = normalized.split("/").pop() ?? normalized;
|
||||
@@ -35,6 +37,11 @@ function resolveFireworksDynamicModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
if (!modelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isFireworksCatalogModelId(modelId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isKimiModel = isFireworksKimiModelId(modelId);
|
||||
const input = resolveFireworksDynamicInput(modelId);
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
{
|
||||
"id": "accounts/fireworks/models/kimi-k2p6",
|
||||
"name": "Kimi K2.6",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
@@ -50,6 +51,7 @@
|
||||
{
|
||||
"id": "accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
"name": "Kimi K2.5 Turbo (Fire Pass)",
|
||||
"reasoning": false,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 256000,
|
||||
"maxTokens": 256000,
|
||||
|
||||
@@ -31,16 +31,12 @@ export const FIREWORKS_DEFAULT_MAX_TOKENS = FIREWORKS_DEFAULT_MODEL.maxTokens;
|
||||
export const FIREWORKS_K2_6_CONTEXT_WINDOW = FIREWORKS_K2_6_MODEL.contextWindow;
|
||||
export const FIREWORKS_K2_6_MAX_TOKENS = FIREWORKS_K2_6_MODEL.maxTokens;
|
||||
|
||||
function cloneFireworksCatalogModel(model: ModelDefinitionConfig): ModelDefinitionConfig {
|
||||
return {
|
||||
...model,
|
||||
input: [...model.input],
|
||||
cost: { ...model.cost },
|
||||
};
|
||||
export function isFireworksCatalogModelId(modelId: string): boolean {
|
||||
return FIREWORKS_MANIFEST_PROVIDER.models.some((model) => model.id === modelId);
|
||||
}
|
||||
|
||||
export function buildFireworksCatalogModels(): ModelDefinitionConfig[] {
|
||||
return FIREWORKS_MANIFEST_PROVIDER.models.map(cloneFireworksCatalogModel);
|
||||
return FIREWORKS_MANIFEST_PROVIDER.models.map((model) => structuredClone(model));
|
||||
}
|
||||
|
||||
export function buildFireworksProvider(): ModelProviderConfig {
|
||||
|
||||
@@ -56,6 +56,10 @@ function isCopilotGeminiModelId(modelId: string): boolean {
|
||||
return /(?:^|[-_.])gemini(?:$|[-_.])/.test(modelId);
|
||||
}
|
||||
|
||||
function isCopilotClaude45ModelId(modelId: string): boolean {
|
||||
return /^claude-(?:haiku|opus|sonnet)-4[.-]5(?:$|[-.])/.test(modelId);
|
||||
}
|
||||
|
||||
export function resolveCopilotTransportApi(modelId: string): CopilotRuntimeApi {
|
||||
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
|
||||
if (normalized.includes("claude")) {
|
||||
@@ -71,7 +75,15 @@ export function resolveCopilotModelCompat(
|
||||
modelId: string,
|
||||
): ModelDefinitionConfig["compat"] | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(modelId) ?? "";
|
||||
return isCopilotGeminiModelId(normalized) ? { ...COPILOT_CHAT_COMPLETIONS_COMPAT } : undefined;
|
||||
if (isCopilotGeminiModelId(normalized)) {
|
||||
return { ...COPILOT_CHAT_COMPLETIONS_COMPAT };
|
||||
}
|
||||
// Copilot's Claude 4.5 endpoints reject Anthropic's eager tool extension,
|
||||
// while current Claude 4.6+ endpoints accept it.
|
||||
if (isCopilotClaude45ModelId(normalized)) {
|
||||
return { supportsEagerToolInputStreaming: false };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compatSupportsEffort(
|
||||
|
||||
@@ -90,8 +90,18 @@ describe("github-copilot model defaults", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("anthropic-messages");
|
||||
expect(def.compat).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["claude-haiku-4.5", "claude-sonnet-4-5"])(
|
||||
"disables eager tool streaming for Copilot Claude 4.5 model %s",
|
||||
(modelId) => {
|
||||
expect(buildCopilotModelDefinition(modelId).compat).toEqual({
|
||||
supportsEagerToolInputStreaming: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("uses static metadata overrides for gpt-5.5 fallback rows", () => {
|
||||
const def = buildCopilotModelDefinition("gpt-5.5");
|
||||
expect(def).toEqual({
|
||||
@@ -243,6 +253,12 @@ describe("resolveCopilotForwardCompatModel", () => {
|
||||
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
|
||||
});
|
||||
|
||||
it("disables eager tool streaming for synthetic Copilot Claude 4.5 models", () => {
|
||||
const result = requireResolvedModel(createMockCtx("claude-haiku-4.5"));
|
||||
expect(result.api).toBe("anthropic-messages");
|
||||
expect(result.compat).toEqual({ supportsEagerToolInputStreaming: false });
|
||||
});
|
||||
|
||||
it("creates synthetic Gemini models with Chat Completions compatibility", () => {
|
||||
const result = requireResolvedModel(createMockCtx("gemini-3.1-pro-preview"));
|
||||
expect((result as unknown as Record<string, unknown>).api).toBe("openai-completions");
|
||||
@@ -620,6 +636,7 @@ describe("fetchCopilotModelCatalog", () => {
|
||||
const opus45 = out.find((m) => m.id === "claude-opus-4-5");
|
||||
expect(opus45?.thinkingLevelMap).toEqual({ xhigh: null, max: null });
|
||||
expect(opus45?.compat).toEqual({
|
||||
supportsEagerToolInputStreaming: false,
|
||||
supportedReasoningEfforts: ["low", "medium", "high", "max"],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,14 @@ const defaultFs: CredentialFs = {
|
||||
};
|
||||
|
||||
let credentialFs: CredentialFs = defaultFs;
|
||||
const GEMINI_CLI_TREE_SEARCH_DEPTH = 10;
|
||||
|
||||
type GeminiCliCredentialExtractDiagnostics = {
|
||||
searchedPaths: string[];
|
||||
recursiveSearchRoots: string[];
|
||||
parseFailures: string[];
|
||||
readErrors: string[];
|
||||
};
|
||||
|
||||
function resolveEnv(keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
@@ -35,9 +43,11 @@ function resolveEnv(keys: string[]): string | undefined {
|
||||
}
|
||||
|
||||
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
|
||||
let geminiCliCredentialExtractError: string | null = null;
|
||||
|
||||
export function clearCredentialsCache(): void {
|
||||
cachedGeminiCliCredentials = null;
|
||||
geminiCliCredentialExtractError = null;
|
||||
}
|
||||
|
||||
export function setOAuthCredentialsFsForTest(overrides?: Partial<CredentialFs>): void {
|
||||
@@ -49,9 +59,19 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
||||
return cachedGeminiCliCredentials;
|
||||
}
|
||||
|
||||
geminiCliCredentialExtractError = null;
|
||||
const diagnostics: GeminiCliCredentialExtractDiagnostics = {
|
||||
searchedPaths: [],
|
||||
recursiveSearchRoots: [],
|
||||
parseFailures: [],
|
||||
readErrors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
const geminiPath = findInPath("gemini");
|
||||
if (!geminiPath) {
|
||||
geminiCliCredentialExtractError =
|
||||
"Gemini CLI binary was not found in PATH during OAuth credential extraction.";
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -59,30 +79,84 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
|
||||
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
|
||||
|
||||
for (const geminiCliDir of geminiCliDirs) {
|
||||
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir);
|
||||
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir, diagnostics);
|
||||
if (directCredentials) {
|
||||
cachedGeminiCliCredentials = directCredentials;
|
||||
return directCredentials;
|
||||
}
|
||||
|
||||
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir);
|
||||
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir, diagnostics);
|
||||
if (bundledCredentials) {
|
||||
cachedGeminiCliCredentials = bundledCredentials;
|
||||
return bundledCredentials;
|
||||
}
|
||||
|
||||
const discoveredCredentials = findGeminiCliCredentialsInTree(geminiCliDir, 10);
|
||||
diagnostics.recursiveSearchRoots.push(geminiCliDir);
|
||||
const discoveredCredentials = findGeminiCliCredentialsInTree(
|
||||
geminiCliDir,
|
||||
GEMINI_CLI_TREE_SEARCH_DEPTH,
|
||||
diagnostics,
|
||||
);
|
||||
if (discoveredCredentials) {
|
||||
cachedGeminiCliCredentials = discoveredCredentials;
|
||||
return discoveredCredentials;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Gemini CLI not installed or extraction failed
|
||||
geminiCliCredentialExtractError = formatGeminiCliCredentialExtractError({
|
||||
geminiPath,
|
||||
resolvedPath,
|
||||
diagnostics,
|
||||
});
|
||||
} catch (error) {
|
||||
geminiCliCredentialExtractError = `Unexpected error while extracting Gemini CLI OAuth credentials: ${formatError(error)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatGeminiCliCredentialExtractError({
|
||||
geminiPath,
|
||||
resolvedPath,
|
||||
diagnostics,
|
||||
}: {
|
||||
geminiPath: string;
|
||||
resolvedPath: string;
|
||||
diagnostics: GeminiCliCredentialExtractDiagnostics;
|
||||
}): string {
|
||||
const prefix = [
|
||||
"Found Gemini CLI in PATH, but could not extract OAuth credentials.",
|
||||
`geminiPath=${geminiPath}`,
|
||||
`resolvedPath=${resolvedPath}`,
|
||||
];
|
||||
|
||||
if (diagnostics.parseFailures.length > 0) {
|
||||
return [
|
||||
...prefix,
|
||||
"Candidate credential files did not contain a parseable OAuth client id/secret.",
|
||||
`candidates=${diagnostics.parseFailures.join(", ")}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
if (diagnostics.readErrors.length > 0) {
|
||||
return [
|
||||
...prefix,
|
||||
"Unexpected errors occurred while reading candidate credential files/directories.",
|
||||
`errors=${diagnostics.readErrors.join(", ")}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
return [
|
||||
...prefix,
|
||||
"Could not locate oauth2.js or bundled credential source.",
|
||||
`searched=${diagnostics.searchedPaths.join(", ") || "(none)"}`,
|
||||
`recursiveSearchRoots=${diagnostics.recursiveSearchRoots.join(", ") || "(none)"}`,
|
||||
`recursiveSearchDepth=${GEMINI_CLI_TREE_SEARCH_DEPTH}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function formatError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] {
|
||||
const binDir = dirname(geminiPath);
|
||||
const candidates = [
|
||||
@@ -142,10 +216,16 @@ function findInPath(name: string): string | null {
|
||||
|
||||
function readGeminiCliCredentialsFile(
|
||||
path: string,
|
||||
diagnostics: GeminiCliCredentialExtractDiagnostics,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
try {
|
||||
return parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
const credentials = parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
|
||||
if (!credentials) {
|
||||
diagnostics.parseFailures.push(path);
|
||||
}
|
||||
return credentials;
|
||||
} catch (error) {
|
||||
diagnostics.readErrors.push(`${path}: ${formatError(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -167,6 +247,7 @@ function parseGeminiCliCredentials(
|
||||
|
||||
function readGeminiCliCredentialsFromKnownPaths(
|
||||
geminiCliDir: string,
|
||||
diagnostics: GeminiCliCredentialExtractDiagnostics,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
const searchPaths = [
|
||||
join(
|
||||
@@ -189,12 +270,13 @@ function readGeminiCliCredentialsFromKnownPaths(
|
||||
"oauth2.js",
|
||||
),
|
||||
];
|
||||
diagnostics.searchedPaths.push(...searchPaths);
|
||||
|
||||
for (const path of searchPaths) {
|
||||
if (!credentialFs.existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
const credentials = readGeminiCliCredentialsFile(path);
|
||||
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
|
||||
if (credentials) {
|
||||
return credentials;
|
||||
}
|
||||
@@ -205,6 +287,7 @@ function readGeminiCliCredentialsFromKnownPaths(
|
||||
|
||||
function readGeminiCliCredentialsFromBundle(
|
||||
geminiCliDir: string,
|
||||
diagnostics: GeminiCliCredentialExtractDiagnostics,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
const bundleDir = join(geminiCliDir, "bundle");
|
||||
if (!credentialFs.existsSync(bundleDir)) {
|
||||
@@ -216,13 +299,14 @@ function readGeminiCliCredentialsFromBundle(
|
||||
if (!entry.isFile() || !entry.name.endsWith(".js")) {
|
||||
continue;
|
||||
}
|
||||
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name));
|
||||
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name), diagnostics);
|
||||
if (credentials) {
|
||||
return credentials;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore bundle traversal failures and fall back to the recursive search.
|
||||
} catch (error) {
|
||||
diagnostics.readErrors.push(`${bundleDir}: ${formatError(error)}`);
|
||||
// Preserve the read error for diagnostics and fall back to the recursive search.
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -231,6 +315,7 @@ function readGeminiCliCredentialsFromBundle(
|
||||
function findGeminiCliCredentialsInTree(
|
||||
dir: string,
|
||||
depth: number,
|
||||
diagnostics: GeminiCliCredentialExtractDiagnostics,
|
||||
): { clientId: string; clientSecret: string } | null {
|
||||
if (depth <= 0) {
|
||||
return null;
|
||||
@@ -239,20 +324,22 @@ function findGeminiCliCredentialsInTree(
|
||||
for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isFile() && entry.name === "oauth2.js") {
|
||||
const credentials = readGeminiCliCredentialsFile(path);
|
||||
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
|
||||
if (credentials) {
|
||||
return credentials;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
||||
const found = findGeminiCliCredentialsInTree(path, depth - 1);
|
||||
const found = findGeminiCliCredentialsInTree(path, depth - 1, diagnostics);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
diagnostics.readErrors.push(`${dir}: ${formatError(error)}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -268,7 +355,10 @@ export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: s
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const detail = geminiCliCredentialExtractError
|
||||
? ` Details: ${geminiCliCredentialExtractError}`
|
||||
: "";
|
||||
throw new Error(
|
||||
"Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
|
||||
`Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,6 +142,12 @@ describe("resolveGeminiCliSelectedAuthType", () => {
|
||||
});
|
||||
|
||||
describe("extractGeminiCliCredentials", () => {
|
||||
const ENV_KEYS = [
|
||||
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
|
||||
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_ID",
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
] as const;
|
||||
const normalizePath = (value: string) =>
|
||||
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
||||
const rootDir = parse(process.cwd()).root || "/";
|
||||
@@ -153,7 +159,9 @@ describe("extractGeminiCliCredentials", () => {
|
||||
`;
|
||||
|
||||
let originalPath: string | undefined;
|
||||
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
|
||||
let extractGeminiCliCredentials: typeof import("./oauth.credentials.js").extractGeminiCliCredentials;
|
||||
let resolveOAuthClientConfig: typeof import("./oauth.credentials.js").resolveOAuthClientConfig;
|
||||
let clearCredentialsCache: typeof import("./oauth.credentials.js").clearCredentialsCache;
|
||||
let setOAuthCredentialsFsForTest: typeof import("./oauth.credentials.js").setOAuthCredentialsFsForTest;
|
||||
|
||||
@@ -446,18 +454,34 @@ describe("extractGeminiCliCredentials", () => {
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ extractGeminiCliCredentials, clearCredentialsCache, setOAuthCredentialsFsForTest } =
|
||||
await import("./oauth.credentials.js"));
|
||||
({
|
||||
extractGeminiCliCredentials,
|
||||
resolveOAuthClientConfig,
|
||||
clearCredentialsCache,
|
||||
setOAuthCredentialsFsForTest,
|
||||
} = await import("./oauth.credentials.js"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
originalPath = process.env.PATH;
|
||||
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
await installMockFs();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.PATH = originalPath;
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = envSnapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
setOAuthCredentialsFsForTest();
|
||||
});
|
||||
|
||||
@@ -469,6 +493,16 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("includes missing binary details when resolving OAuth client config", async () => {
|
||||
process.env.PATH = "/nonexistent";
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(
|
||||
/Details: Gemini CLI binary was not found in PATH/,
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts credentials from oauth2.js in known path", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
@@ -517,6 +551,14 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("includes missing oauth2.js details when resolving OAuth client config", async () => {
|
||||
installGeminiLayout({ oauth2Exists: false, readdir: [] });
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(/Could not locate oauth2\.js/);
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(/recursiveSearchDepth=10/);
|
||||
});
|
||||
|
||||
it("returns null when oauth2.js lacks credentials", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" });
|
||||
|
||||
@@ -524,6 +566,32 @@ describe("extractGeminiCliCredentials", () => {
|
||||
expect(extractGeminiCliCredentials()).toBeNull();
|
||||
});
|
||||
|
||||
it("includes parse failure details when resolving OAuth client config", async () => {
|
||||
installGeminiLayout({
|
||||
oauth2Exists: true,
|
||||
oauth2Content: "// no credentials here",
|
||||
readdir: [],
|
||||
});
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(
|
||||
/Candidate credential files did not contain a parseable OAuth client id\/secret/,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes unexpected extraction exception details when resolving OAuth client config", async () => {
|
||||
installGeminiLayout({ oauth2Exists: true, readdir: [] });
|
||||
mockReadFileSync.mockImplementation(() => {
|
||||
throw new Error("mock read failure");
|
||||
});
|
||||
|
||||
clearCredentialsCache();
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(
|
||||
/Unexpected errors occurred while reading candidate credential files\/directories/,
|
||||
);
|
||||
expect(() => resolveOAuthClientConfig()).toThrow(/mock read failure/);
|
||||
});
|
||||
|
||||
it("caches credentials after first extraction", () => {
|
||||
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ function expectLogExcludes(source: MockCallSource, text: string): void {
|
||||
expect(logIncludes(source, text), `Expected log not to include ${text}`).toBe(false);
|
||||
}
|
||||
|
||||
async function flushNarrativeSettleTimers<T>(operation: Promise<T>): Promise<T> {
|
||||
await vi.runAllTimersAsync();
|
||||
return operation;
|
||||
}
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
const accessResult = await fs
|
||||
.access(targetPath)
|
||||
@@ -683,6 +688,88 @@ describe("generateAndAppendDreamNarrative", () => {
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for persisted assistant text before falling back", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const subagent = createMockSubagent("");
|
||||
subagent.getSessionMessages
|
||||
.mockResolvedValueOnce({
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{ role: "user", content: "prompt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "The delayed diary text finally settled." }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const logger = createMockLogger();
|
||||
|
||||
const operation = generateAndAppendDreamNarrative({
|
||||
subagent,
|
||||
workspaceDir,
|
||||
data: {
|
||||
phase: "light",
|
||||
snippets: ["The narrative assistant persisted after the run completed."],
|
||||
},
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
logger,
|
||||
});
|
||||
await flushNarrativeSettleTimers(operation);
|
||||
|
||||
expect(subagent.getSessionMessages).toHaveBeenCalledTimes(2);
|
||||
expect(subagent.getSessionMessages).toHaveBeenNthCalledWith(1, {
|
||||
sessionKey: expect.stringContaining("dreaming-narrative-light-"),
|
||||
limit: expect.any(Number),
|
||||
});
|
||||
expect(subagent.getSessionMessages).toHaveBeenNthCalledWith(2, {
|
||||
sessionKey: expect.stringContaining("dreaming-narrative-light-"),
|
||||
limit: expect.any(Number),
|
||||
});
|
||||
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
|
||||
expect(content).toContain("The delayed diary text finally settled.");
|
||||
expect(content).not.toContain("A memory trace surfaced");
|
||||
expectLogExcludes(logger.warn, "produced no text");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back after settled assistant text never appears", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const subagent = createMockSubagent("");
|
||||
const logger = createMockLogger();
|
||||
|
||||
const operation = generateAndAppendDreamNarrative({
|
||||
subagent,
|
||||
workspaceDir,
|
||||
data: {
|
||||
phase: "light",
|
||||
snippets: ["The narrative assistant never persisted text."],
|
||||
},
|
||||
nowMs: Date.parse("2026-04-05T03:00:00Z"),
|
||||
timezone: "UTC",
|
||||
logger,
|
||||
});
|
||||
await flushNarrativeSettleTimers(operation);
|
||||
|
||||
expect(subagent.getSessionMessages).toHaveBeenCalledTimes(5);
|
||||
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
|
||||
expect(content).toContain(
|
||||
"A memory trace surfaced, but details were unavailable in this run.",
|
||||
);
|
||||
expectLogIncludes(logger.warn, "produced no text");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("retries with the session default when the configured model cannot start", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
|
||||
const subagent = createMockSubagent("The default model carried the diary home.");
|
||||
|
||||
@@ -99,6 +99,10 @@ const NARRATIVE_SYSTEM_PROMPT = [
|
||||
// worst case at one minute, well below the multi-minute stall the original
|
||||
// comment warned against.
|
||||
const NARRATIVE_TIMEOUT_MS = 60_000;
|
||||
const NARRATIVE_MESSAGE_FETCH_LIMIT = 5;
|
||||
// A completed run can reach the session reader before the final assistant text
|
||||
// is visible, so retry briefly before falling back to synthetic diary text.
|
||||
const NARRATIVE_MESSAGE_SETTLE_DELAYS_MS = [50, 150, 300, 750] as const;
|
||||
const DREAMING_SESSION_KEY_PREFIX = "dreaming-narrative-";
|
||||
const DREAMING_TRANSCRIPT_RUN_MARKER = '"runId":"dreaming-narrative-';
|
||||
const DREAMING_ORPHAN_MIN_AGE_MS = 300_000;
|
||||
@@ -342,6 +346,42 @@ export function extractNarrativeText(messages: unknown[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function waitForNarrativeMessagesToSettle(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function readNarrativeText(params: {
|
||||
subagent: SubagentSurface;
|
||||
sessionKey: string;
|
||||
}): Promise<string | null> {
|
||||
const { messages } = await params.subagent.getSessionMessages({
|
||||
sessionKey: params.sessionKey,
|
||||
limit: NARRATIVE_MESSAGE_FETCH_LIMIT,
|
||||
});
|
||||
return extractNarrativeText(messages);
|
||||
}
|
||||
|
||||
async function readSettledNarrativeText(params: {
|
||||
subagent: SubagentSurface;
|
||||
sessionKey: string;
|
||||
}): Promise<string | null> {
|
||||
const immediateNarrative = await readNarrativeText(params);
|
||||
if (immediateNarrative) {
|
||||
return immediateNarrative;
|
||||
}
|
||||
|
||||
for (const delayMs of NARRATIVE_MESSAGE_SETTLE_DELAYS_MS) {
|
||||
await waitForNarrativeMessagesToSettle(delayMs);
|
||||
const narrative = await readNarrativeText(params);
|
||||
if (narrative) {
|
||||
return narrative;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Date formatting ────────────────────────────────────────────────────
|
||||
|
||||
export function formatNarrativeDate(epochMs: number, timezone?: string): string {
|
||||
@@ -966,12 +1006,10 @@ export async function generateAndAppendDreamNarrative(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const { messages } = await params.subagent.getSessionMessages({
|
||||
const narrative = await readSettledNarrativeText({
|
||||
subagent: params.subagent,
|
||||
sessionKey: successfulSessionKey,
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
const narrative = extractNarrativeText(messages);
|
||||
if (!narrative) {
|
||||
params.logger.warn(
|
||||
`memory-core: narrative generation produced no text for ${params.data.phase} phase; writing fallback diary entry.`,
|
||||
|
||||
@@ -8,6 +8,7 @@ type SearchImpl = (opts?: {
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
signal?: AbortSignal;
|
||||
}) => Promise<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
type MemoryReadResult = {
|
||||
|
||||
@@ -51,13 +51,14 @@ async function renameWithRetry(
|
||||
source: string,
|
||||
target: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
optional = false,
|
||||
): Promise<void> {
|
||||
for (let attempt = 1; attempt <= options.maxRenameAttempts; attempt++) {
|
||||
try {
|
||||
await options.fileOps.rename(source, target);
|
||||
return;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
if (optional && (err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return;
|
||||
}
|
||||
if (!isTransientFileError(err) || attempt === options.maxRenameAttempts) {
|
||||
@@ -79,7 +80,7 @@ export async function moveMemoryIndexFiles(
|
||||
for (const suffix of suffixes) {
|
||||
const source = `${sourceBase}${suffix}`;
|
||||
const target = `${targetBase}${suffix}`;
|
||||
await renameWithRetry(source, target, resolvedOptions);
|
||||
await renameWithRetry(source, target, resolvedOptions, suffix !== "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +113,91 @@ export async function removeMemoryIndexFiles(
|
||||
}
|
||||
}
|
||||
|
||||
async function swapMemoryIndexFiles(targetPath: string, tempPath: string): Promise<void> {
|
||||
const backupPath = `${targetPath}.backup-${randomUUID()}`;
|
||||
await moveMemoryIndexFiles(targetPath, backupPath);
|
||||
async function removeMemoryIndexSidecars(
|
||||
basePath: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
): Promise<void> {
|
||||
await rmWithRetry(`${basePath}-wal`, options);
|
||||
await rmWithRetry(`${basePath}-shm`, options);
|
||||
}
|
||||
|
||||
async function moveMemoryIndexSidecars(
|
||||
sourceBase: string,
|
||||
targetBase: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
): Promise<void> {
|
||||
const suffixes = ["-wal", "-shm"];
|
||||
for (const suffix of suffixes) {
|
||||
await renameWithRetry(`${sourceBase}${suffix}`, `${targetBase}${suffix}`, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMemoryIndexSidecarsWithRollback(
|
||||
sourceBase: string,
|
||||
targetBase: string,
|
||||
options: ResolvedMemoryIndexFileOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await moveMemoryIndexFiles(tempPath, targetPath);
|
||||
await moveMemoryIndexSidecars(sourceBase, targetBase, options);
|
||||
} catch (err) {
|
||||
await moveMemoryIndexFiles(backupPath, targetPath);
|
||||
try {
|
||||
await moveMemoryIndexSidecars(targetBase, sourceBase, options);
|
||||
} catch (rollbackErr) {
|
||||
const aggregateErr = new AggregateError(
|
||||
[err, rollbackErr],
|
||||
"memory index sidecar backup failed and rollback failed",
|
||||
{ cause: rollbackErr },
|
||||
);
|
||||
throw aggregateErr;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
await removeMemoryIndexFiles(backupPath);
|
||||
}
|
||||
|
||||
async function swapMemoryIndexFiles(
|
||||
targetPath: string,
|
||||
tempPath: string,
|
||||
options: MemoryIndexFileOptions = {},
|
||||
): Promise<void> {
|
||||
// On POSIX (Linux/macOS), rename(2) atomically overwrites the target,
|
||||
// so there is no absent-window between removing the old index and
|
||||
// publishing the new one. On Windows, rename fails when the target
|
||||
// exists, so the three-step backup protocol is retained.
|
||||
const resolvedOptions = resolveMemoryIndexFileOptions(options);
|
||||
const backupPath = `${targetPath}.backup-${randomUUID()}`;
|
||||
// The old and temp DBs are checkpointed and closed before swap. Hide target
|
||||
// sidecars before publishing the new main DB, but keep them rollbackable
|
||||
// until the main-file publish succeeds.
|
||||
await moveMemoryIndexSidecarsWithRollback(targetPath, backupPath, resolvedOptions);
|
||||
try {
|
||||
await renameWithRetry(tempPath, targetPath, resolvedOptions);
|
||||
} catch (err) {
|
||||
if (
|
||||
(err as NodeJS.ErrnoException).code === "EPERM" ||
|
||||
(err as NodeJS.ErrnoException).code === "EEXIST"
|
||||
) {
|
||||
// Windows: target exists, use three-step backup protocol with rollback.
|
||||
try {
|
||||
await renameWithRetry(targetPath, backupPath, resolvedOptions);
|
||||
} catch (backupErr) {
|
||||
await moveMemoryIndexSidecars(backupPath, targetPath, resolvedOptions);
|
||||
throw backupErr;
|
||||
}
|
||||
try {
|
||||
await renameWithRetry(tempPath, targetPath, resolvedOptions);
|
||||
} catch (moveErr) {
|
||||
await moveMemoryIndexFiles(backupPath, targetPath, options);
|
||||
throw moveErr;
|
||||
}
|
||||
} else {
|
||||
await moveMemoryIndexSidecars(backupPath, targetPath, resolvedOptions);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
await removeMemoryIndexFiles(backupPath, options);
|
||||
// Closed temp databases should not need sidecars after checkpoint; remove
|
||||
// leftovers at the temp path without touching the published target pair.
|
||||
await removeMemoryIndexSidecars(tempPath, resolvedOptions);
|
||||
}
|
||||
|
||||
export async function runMemoryAtomicReindex<T>(params: {
|
||||
@@ -133,7 +209,7 @@ export async function runMemoryAtomicReindex<T>(params: {
|
||||
}): Promise<T> {
|
||||
try {
|
||||
const result = await params.build();
|
||||
await swapMemoryIndexFiles(params.targetPath, params.tempPath);
|
||||
await swapMemoryIndexFiles(params.targetPath, params.tempPath, params.fileOptions);
|
||||
return result;
|
||||
} catch (err) {
|
||||
try {
|
||||
|
||||
65
extensions/memory-core/src/memory/manager-db-probe.test.ts
Normal file
65
extensions/memory-core/src/memory/manager-db-probe.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { openMemoryDatabaseAtPath } from "./manager-db.js";
|
||||
|
||||
describe("openMemoryDatabaseAtPath readOnly probe", () => {
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-db-probe-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("allows opening when the database file exists", async () => {
|
||||
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
|
||||
const dir = path.dirname(dbPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const seed = new DatabaseSync(dbPath);
|
||||
seed.exec("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT)");
|
||||
seed.close();
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false);
|
||||
expect(db).toBeDefined();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("allows creating a new database when allowCreate is true", async () => {
|
||||
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "new-index.sqlite");
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false, true);
|
||||
expect(db).toBeDefined();
|
||||
db.close();
|
||||
|
||||
const stat = await fs.stat(dbPath);
|
||||
expect(stat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refuses to auto-create an empty database when allowCreate is false", async () => {
|
||||
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "absent-index.sqlite");
|
||||
|
||||
expect(() => openMemoryDatabaseAtPath(dbPath, false, false)).toThrow(
|
||||
/Memory database not found.*refusing to auto-create/,
|
||||
);
|
||||
|
||||
await expect(fs.access(dbPath)).rejects.toThrow("ENOENT");
|
||||
});
|
||||
|
||||
it("allows open with allowCreate=true for temp database creation", async () => {
|
||||
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "temp-index.sqlite");
|
||||
|
||||
const db = openMemoryDatabaseAtPath(dbPath, false, true);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.close();
|
||||
|
||||
const reopen = openMemoryDatabaseAtPath(dbPath, false, false);
|
||||
expect(reopen).toBeDefined();
|
||||
reopen.close();
|
||||
});
|
||||
});
|
||||
@@ -8,15 +8,39 @@ import {
|
||||
requireNodeSqlite,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
|
||||
export function openMemoryDatabaseAtPath(dbPath: string, allowExtension: boolean): DatabaseSync {
|
||||
export function openMemoryDatabaseAtPath(
|
||||
dbPath: string,
|
||||
allowExtension: boolean,
|
||||
allowCreate = true,
|
||||
): DatabaseSync {
|
||||
const dir = path.dirname(dbPath);
|
||||
ensureDir(dir);
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
// When allowCreate is false, probe with readOnly first.
|
||||
// DatabaseSync auto-creates the file in read-write mode, which
|
||||
// produces an empty database with schema but no meta row when the
|
||||
// file is momentarily absent during an index swap. readOnly: true
|
||||
// throws SQLITE_CANTOPEN when the file does not exist, preventing
|
||||
// the auto-create race.
|
||||
if (!allowCreate) {
|
||||
try {
|
||||
const probe = new DatabaseSync(dbPath, { readOnly: true });
|
||||
probe.close();
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message ?? "";
|
||||
if (
|
||||
msg.includes("unable to open database file") ||
|
||||
msg.includes("SQLITE_CANTOPEN")
|
||||
) {
|
||||
throw new Error(
|
||||
`Memory database not found at ${dbPath}; refusing to auto-create an empty database during an index swap window.`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const db = new DatabaseSync(dbPath, { allowExtension });
|
||||
configureMemorySqliteWalMaintenance(db);
|
||||
// busy_timeout is per-connection and resets to 0 on restart.
|
||||
// Set it on every open so concurrent processes retry instead of
|
||||
// failing immediately with SQLITE_BUSY.
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -163,11 +163,16 @@ export function resolveMemoryIndexConcurrency(params: {
|
||||
export async function runEmbeddingOperationWithTimeout<T>(params: {
|
||||
timeoutMs: number;
|
||||
message: string;
|
||||
/** Caller-owned cancellation, merged with the per-call watchdog abort. */
|
||||
signal?: AbortSignal;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const signal = params.signal
|
||||
? AbortSignal.any([params.signal, controller.signal])
|
||||
: controller.signal;
|
||||
if (!Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) {
|
||||
return await params.run(controller.signal);
|
||||
return await params.run(signal);
|
||||
}
|
||||
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 1);
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
@@ -179,7 +184,7 @@ export async function runEmbeddingOperationWithTimeout<T>(params: {
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
const operation = params.run(controller.signal);
|
||||
const operation = params.run(signal);
|
||||
return (await Promise.race([operation, timeoutPromise])) as T;
|
||||
} finally {
|
||||
if (timer) {
|
||||
@@ -493,7 +498,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
});
|
||||
}
|
||||
|
||||
protected async embedQueryWithRetry(text: string): Promise<number[]> {
|
||||
protected async embedQueryWithRetry(text: string, signal?: AbortSignal): Promise<number[]> {
|
||||
const provider = this.provider;
|
||||
if (!provider) {
|
||||
throw new Error("Cannot embed query in FTS-only mode (no embedding provider)");
|
||||
@@ -501,14 +506,17 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
|
||||
try {
|
||||
return await runMemoryEmbeddingRetryLoop({
|
||||
run: async () => {
|
||||
signal?.throwIfAborted();
|
||||
const timeoutMs = this.resolveEmbeddingTimeout("query");
|
||||
log.debug("memory embeddings: query start", { provider: provider.id, timeoutMs });
|
||||
return await runEmbeddingOperationWithTimeout({
|
||||
timeoutMs,
|
||||
message: `memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
|
||||
run: async (signal) => await provider.embedQuery(text, { signal }),
|
||||
signal,
|
||||
run: async (opSignal) => await provider.embedQuery(text, { signal: opSignal }),
|
||||
});
|
||||
},
|
||||
signal,
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
waitForRetry: async (delayMs) => {
|
||||
await this.waitForEmbeddingRetry(delayMs, "retrying query");
|
||||
|
||||
@@ -75,6 +75,30 @@ describe("memory embedding policy", () => {
|
||||
expect(waits).toEqual([500, 1000]);
|
||||
});
|
||||
|
||||
it("stops retrying after the caller signal aborts, even for retryable-looking errors", async () => {
|
||||
const controller = new AbortController();
|
||||
const run = vi.fn(async () => {
|
||||
controller.abort(new Error("memory_search timed out after 15s"));
|
||||
// "timed out" matches the retryable transport pattern; abort must still win.
|
||||
throw new Error("memory embeddings query timed out after 60s");
|
||||
});
|
||||
const waitForRetry = vi.fn(async () => {});
|
||||
|
||||
await expect(
|
||||
runMemoryEmbeddingRetryLoop({
|
||||
run,
|
||||
isRetryable: isRetryableMemoryEmbeddingError,
|
||||
waitForRetry,
|
||||
maxAttempts: 3,
|
||||
baseDelayMs: 500,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings query timed out after 60s");
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(waitForRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries transient socket/network embedding errors", () => {
|
||||
const splittableMessages = [
|
||||
"TypeError: fetch failed | other side closed",
|
||||
|
||||
@@ -125,6 +125,8 @@ export async function runMemoryEmbeddingRetryLoop<T>(params: {
|
||||
waitForRetry: (delayMs: number) => Promise<void>;
|
||||
maxAttempts: number;
|
||||
baseDelayMs: number;
|
||||
/** Caller-owned cancellation; an aborted caller stops the retry loop. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<T> {
|
||||
const attempts = Math.max(1, params.maxAttempts);
|
||||
for (const attempt of Array.from({ length: attempts }, (_, index) => index + 1)) {
|
||||
@@ -132,6 +134,12 @@ export async function runMemoryEmbeddingRetryLoop<T>(params: {
|
||||
try {
|
||||
return await params.run();
|
||||
} catch (err) {
|
||||
// Abort must win over retryable-looking failures: abort reasons often
|
||||
// carry "timed out" messages that match the retryable transport
|
||||
// patterns and would otherwise keep retrying for an absent caller.
|
||||
if (params.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const message = formatErrorMessage(err);
|
||||
if (!params.isRetryable(message) || attempt >= params.maxAttempts) {
|
||||
throw err;
|
||||
|
||||
@@ -135,6 +135,31 @@ describe("memory embedding timeout abort", () => {
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts the provider operation when the caller signal aborts before the watchdog", async () => {
|
||||
const external = new AbortController();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 60_000,
|
||||
message: "memory embeddings query timed out after 60s",
|
||||
signal: external.signal,
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
external.abort(new Error("memory_search timed out after 15s"));
|
||||
await expect(resultPromise).rejects.toThrow("memory_search timed out after 15s");
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
|
||||
vi.useFakeTimers();
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
|
||||
@@ -2322,7 +2322,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
|
||||
const restoreOriginalState = () => {
|
||||
if (originalDbClosed) {
|
||||
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled);
|
||||
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled, false);
|
||||
} else {
|
||||
this.db = originalDb;
|
||||
}
|
||||
@@ -2434,7 +2434,7 @@ export abstract class MemoryManagerSyncOps {
|
||||
},
|
||||
});
|
||||
|
||||
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled);
|
||||
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled, false);
|
||||
this.resetVectorState();
|
||||
this.ensureSchema();
|
||||
this.vector.dims = nextMeta?.vectorDims;
|
||||
|
||||
@@ -24,6 +24,15 @@ async function expectRejectCode(promise: Promise<unknown>, code: string): Promis
|
||||
throw new Error(`Expected rejection with code ${code}`);
|
||||
}
|
||||
|
||||
function normalizeBackupName(filePath: string): string {
|
||||
return path
|
||||
.basename(filePath)
|
||||
.replace(
|
||||
/backup-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/,
|
||||
"backup-<uuid>",
|
||||
);
|
||||
}
|
||||
|
||||
describe("memory manager atomic reindex", () => {
|
||||
let fixtureRoot = "";
|
||||
let caseId = 0;
|
||||
@@ -132,6 +141,25 @@ describe("memory manager atomic reindex", () => {
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the main sqlite file during index moves", async () => {
|
||||
const rename = vi
|
||||
.fn()
|
||||
.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
|
||||
const wait = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expectRejectCode(
|
||||
moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
|
||||
fileOps: { rename, rm: fs.rm, wait },
|
||||
maxRenameAttempts: 3,
|
||||
renameRetryDelayMs: 10,
|
||||
}),
|
||||
"ENOENT",
|
||||
);
|
||||
|
||||
expect(rename).toHaveBeenCalledTimes(1);
|
||||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry non-transient rename failures", async () => {
|
||||
const rename = vi
|
||||
.fn()
|
||||
@@ -247,6 +275,151 @@ describe("memory manager atomic reindex", () => {
|
||||
"rm:index.sqlite.tmp-shm:closed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("atomic swap on POSIX: target file never goes absent during swap", async () => {
|
||||
writeChunkMarker(indexPath, "before");
|
||||
writeChunkMarker(tempIndexPath, "after");
|
||||
|
||||
const existsChecks: boolean[] = [];
|
||||
const realRename = fs.rename;
|
||||
const rename: typeof fs.rename = vi.fn(async (source, target) => {
|
||||
existsChecks.push(
|
||||
await fs.access(indexPath).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
);
|
||||
return realRename(source, target);
|
||||
});
|
||||
|
||||
await runMemoryAtomicReindex({
|
||||
targetPath: indexPath,
|
||||
tempPath: tempIndexPath,
|
||||
fileOptions: {
|
||||
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
build: async () => undefined,
|
||||
});
|
||||
|
||||
expect(readChunkMarker(indexPath)).toBe("after");
|
||||
expect(existsChecks.length).toBeGreaterThan(0);
|
||||
for (const exists of existsChecks) {
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("backs up stale target sidecars before replacing the main index", async () => {
|
||||
writeChunkMarker(indexPath, "before");
|
||||
writeChunkMarker(tempIndexPath, "after");
|
||||
await fs.writeFile(`${indexPath}-wal`, "stale wal");
|
||||
await fs.writeFile(`${indexPath}-shm`, "stale shm");
|
||||
await fs.writeFile(`${tempIndexPath}-wal`, "closed temp wal");
|
||||
await fs.writeFile(`${tempIndexPath}-shm`, "closed temp shm");
|
||||
|
||||
const events: string[] = [];
|
||||
const realRename = fs.rename;
|
||||
const realRm = fs.rm;
|
||||
const rename: typeof fs.rename = vi.fn(async (source, target) => {
|
||||
events.push(
|
||||
`rename:${normalizeBackupName(String(source))}->${normalizeBackupName(String(target))}`,
|
||||
);
|
||||
await realRename(source, target);
|
||||
});
|
||||
const rm: typeof fs.rm = vi.fn(async (filePath, options) => {
|
||||
events.push(`rm:${normalizeBackupName(String(filePath))}:${readChunkMarker(indexPath)}`);
|
||||
await realRm(filePath, options);
|
||||
});
|
||||
|
||||
await runMemoryAtomicReindex({
|
||||
targetPath: indexPath,
|
||||
tempPath: tempIndexPath,
|
||||
fileOptions: {
|
||||
fileOps: { rename, rm, wait: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
build: async () => undefined,
|
||||
});
|
||||
|
||||
expect(readChunkMarker(indexPath)).toBe("after");
|
||||
expect(rename).toHaveBeenCalledTimes(3);
|
||||
expect(events).toEqual([
|
||||
"rename:index.sqlite-wal->index.sqlite.backup-<uuid>-wal",
|
||||
"rename:index.sqlite-shm->index.sqlite.backup-<uuid>-shm",
|
||||
"rename:index.sqlite.tmp->index.sqlite",
|
||||
"rm:index.sqlite.backup-<uuid>:after",
|
||||
"rm:index.sqlite.backup-<uuid>-wal:after",
|
||||
"rm:index.sqlite.backup-<uuid>-shm:after",
|
||||
"rm:index.sqlite.tmp-wal:after",
|
||||
"rm:index.sqlite.tmp-shm:after",
|
||||
]);
|
||||
await expectPathMissing(`${indexPath}-wal`);
|
||||
await expectPathMissing(`${indexPath}-shm`);
|
||||
await expectPathMissing(`${tempIndexPath}-wal`);
|
||||
await expectPathMissing(`${tempIndexPath}-shm`);
|
||||
});
|
||||
|
||||
it("restores backed-up target sidecars when publishing the main index fails", async () => {
|
||||
writeChunkMarker(indexPath, "before");
|
||||
writeChunkMarker(tempIndexPath, "after");
|
||||
await fs.writeFile(`${indexPath}-wal`, "stale wal");
|
||||
await fs.writeFile(`${indexPath}-shm`, "stale shm");
|
||||
|
||||
const realRename = fs.rename;
|
||||
const rename: typeof fs.rename = vi.fn(async (source, target) => {
|
||||
if (String(source) === tempIndexPath && String(target) === indexPath) {
|
||||
throw Object.assign(new Error("locked target"), { code: "EACCES" });
|
||||
}
|
||||
await realRename(source, target);
|
||||
});
|
||||
|
||||
await expectRejectCode(
|
||||
runMemoryAtomicReindex({
|
||||
targetPath: indexPath,
|
||||
tempPath: tempIndexPath,
|
||||
fileOptions: {
|
||||
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
build: async () => undefined,
|
||||
}),
|
||||
"EACCES",
|
||||
);
|
||||
|
||||
await expect(fs.readFile(`${indexPath}-wal`, "utf8")).resolves.toBe("stale wal");
|
||||
await expect(fs.readFile(`${indexPath}-shm`, "utf8")).resolves.toBe("stale shm");
|
||||
expect(readChunkMarker(indexPath)).toBe("before");
|
||||
await expectPathMissing(tempIndexPath);
|
||||
});
|
||||
|
||||
it("restores target sidecars when sidecar backup partially fails", async () => {
|
||||
writeChunkMarker(indexPath, "before");
|
||||
writeChunkMarker(tempIndexPath, "after");
|
||||
await fs.writeFile(`${indexPath}-wal`, "stale wal");
|
||||
await fs.writeFile(`${indexPath}-shm`, "stale shm");
|
||||
|
||||
const realRename = fs.rename;
|
||||
const rename: typeof fs.rename = vi.fn(async (source, target) => {
|
||||
if (String(source) === `${indexPath}-shm` && String(target).includes(".backup-")) {
|
||||
throw Object.assign(new Error("locked shm"), { code: "EACCES" });
|
||||
}
|
||||
await realRename(source, target);
|
||||
});
|
||||
|
||||
await expectRejectCode(
|
||||
runMemoryAtomicReindex({
|
||||
targetPath: indexPath,
|
||||
tempPath: tempIndexPath,
|
||||
fileOptions: {
|
||||
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
|
||||
},
|
||||
build: async () => undefined,
|
||||
}),
|
||||
"EACCES",
|
||||
);
|
||||
|
||||
await expect(fs.readFile(`${indexPath}-wal`, "utf8")).resolves.toBe("stale wal");
|
||||
await expect(fs.readFile(`${indexPath}-shm`, "utf8")).resolves.toBe("stale shm");
|
||||
expect(readChunkMarker(indexPath)).toBe("before");
|
||||
await expectPathMissing(tempIndexPath);
|
||||
});
|
||||
});
|
||||
|
||||
function writeChunkMarker(dbPath: string, marker: string): void {
|
||||
|
||||
@@ -592,6 +592,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
/** When set, only these chunk sources are considered (must be enabled for this manager). */
|
||||
sources?: MemorySource[];
|
||||
/** Caller-owned cancellation; aborts in-flight embedding work when the caller stops waiting. */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<MemorySearchResult[]> {
|
||||
opts?.onDebug?.({ backend: "builtin" });
|
||||
@@ -758,8 +760,13 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
|
||||
let queryVec: number[];
|
||||
try {
|
||||
queryVec = await this.embedQueryWithRetry(cleaned);
|
||||
queryVec = await this.embedQueryWithRetry(cleaned, opts?.signal);
|
||||
} catch (err) {
|
||||
// An aborted caller already stopped waiting; skip fallback-provider
|
||||
// activation so the abandoned search stops instead of re-embedding.
|
||||
if (opts?.signal?.aborted) {
|
||||
throw err;
|
||||
}
|
||||
const message = formatErrorMessage(err);
|
||||
const activatedFallback = this.shouldFallbackOnError(err)
|
||||
? await this.activateFallbackProvider(message).catch((fallbackErr: unknown) => {
|
||||
@@ -778,7 +785,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
return [];
|
||||
}
|
||||
keywordResults = await loadKeywordResults();
|
||||
queryVec = await this.embedQueryWithRetry(cleaned);
|
||||
queryVec = await this.embedQueryWithRetry(cleaned, opts?.signal);
|
||||
} else if (!this.provider && this.fts.enabled && this.fts.available) {
|
||||
log.warn(`memory search: embeddings unavailable; using keyword-only results: ${message}`);
|
||||
return this.selectScoredResults(keywordResults, maxResults, minScore, 0);
|
||||
|
||||
@@ -339,9 +339,11 @@ describe("getMemorySearchManager caching", () => {
|
||||
errorMessage: "qmd query failed",
|
||||
});
|
||||
|
||||
const fallbackResults = await firstManager.search("hello");
|
||||
const controller = new AbortController();
|
||||
const fallbackResults = await firstManager.search("hello", { signal: controller.signal });
|
||||
expect(fallbackResults).toHaveLength(1);
|
||||
expect(fallbackResults[0]?.path).toBe("MEMORY.md");
|
||||
expect(fallbackSearch).toHaveBeenCalledWith("hello", { signal: controller.signal });
|
||||
|
||||
const second = await getMemorySearchManager({ cfg, agentId: retryAgentId });
|
||||
requireManager(second);
|
||||
@@ -366,6 +368,30 @@ describe("getMemorySearchManager caching", () => {
|
||||
expect(searchResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the qmd startup failure when builtin fallback is unavailable", async () => {
|
||||
const cfg = createQmdCfg("missing-qmd-no-builtin");
|
||||
checkQmdBinaryAvailability.mockResolvedValueOnce({
|
||||
available: false,
|
||||
reason: "binary",
|
||||
error: "spawn qmd ENOENT",
|
||||
});
|
||||
mockMemoryIndexGet.mockRejectedValueOnce(
|
||||
new Error(
|
||||
'Memory search unavailable: embedding provider "openai" is configured but unavailable.',
|
||||
),
|
||||
);
|
||||
|
||||
const result = await getMemorySearchManager({ cfg, agentId: "missing-qmd-no-builtin" });
|
||||
|
||||
expect(result.manager).toBeNull();
|
||||
expect(result.error).toContain("qmd binary unavailable (qmd): spawn qmd ENOENT");
|
||||
expect(result.error).toContain(
|
||||
'builtin fallback unavailable: Memory search unavailable: embedding provider "openai" is configured but unavailable.',
|
||||
);
|
||||
expect(createQmdManagerMock).not.toHaveBeenCalled();
|
||||
expect(mockMemoryIndexGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats legacy qmd unavailable results without a reason as binary failures", async () => {
|
||||
const cfg = createQmdCfg("missing-qmd-legacy");
|
||||
checkQmdBinaryAvailability.mockResolvedValueOnce({
|
||||
|
||||
@@ -262,16 +262,18 @@ export async function getMemorySearchManager(params: {
|
||||
}
|
||||
|
||||
if (transient) {
|
||||
const { manager } = await createPrimaryQmdManager(
|
||||
const { manager, failureReason } = await createPrimaryQmdManager(
|
||||
params.purpose === "cli" ? "cli" : "status",
|
||||
);
|
||||
return manager ? { manager } : await getBuiltinMemorySearchManager(params);
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, failureReason);
|
||||
}
|
||||
|
||||
const recentFailure = getActiveQmdManagerOpenFailure(scopeKey, identityKey);
|
||||
if (recentFailure) {
|
||||
log.debug?.(`qmd memory unavailable; using builtin during cooldown: ${recentFailure.reason}`);
|
||||
return await getBuiltinMemorySearchManager(params);
|
||||
return await getBuiltinMemorySearchManagerAfterQmdFailure(params, recentFailure.reason);
|
||||
}
|
||||
|
||||
const pending = PENDING_QMD_MANAGER_CREATES.get(scopeKey);
|
||||
@@ -280,16 +282,14 @@ export async function getMemorySearchManager(params: {
|
||||
return await getMemorySearchManager(params);
|
||||
}
|
||||
|
||||
let pendingFailureReason: string | undefined;
|
||||
const pendingCreate: PendingQmdManagerCreate = {
|
||||
identityKey,
|
||||
promise: (async () => {
|
||||
const created = await createFullQmdManager(identityKey);
|
||||
if (!created.entry) {
|
||||
recordQmdManagerOpenFailure(
|
||||
scopeKey,
|
||||
identityKey,
|
||||
created.failureReason ?? "qmd memory unavailable",
|
||||
);
|
||||
pendingFailureReason = created.failureReason ?? "qmd memory unavailable";
|
||||
recordQmdManagerOpenFailure(scopeKey, identityKey, pendingFailureReason);
|
||||
return null;
|
||||
}
|
||||
QMD_MANAGER_CACHE.set(scopeKey, created.entry);
|
||||
@@ -308,12 +308,35 @@ export async function getMemorySearchManager(params: {
|
||||
};
|
||||
PENDING_QMD_MANAGER_CREATES.set(scopeKey, pendingCreate);
|
||||
const manager = await pendingCreate.promise;
|
||||
return manager ? { manager } : await getBuiltinMemorySearchManager(params);
|
||||
return manager
|
||||
? { manager }
|
||||
: await getBuiltinMemorySearchManagerAfterQmdFailure(params, pendingFailureReason);
|
||||
}
|
||||
|
||||
return await getBuiltinMemorySearchManager(params);
|
||||
}
|
||||
|
||||
async function getBuiltinMemorySearchManagerAfterQmdFailure(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
purpose?: MemorySearchManagerPurpose;
|
||||
},
|
||||
qmdFailureReason: string | undefined,
|
||||
): Promise<MemorySearchManagerResult> {
|
||||
const fallback = await getBuiltinMemorySearchManager(params);
|
||||
if (fallback.manager || !qmdFailureReason) {
|
||||
return fallback;
|
||||
}
|
||||
const fallbackError = fallback.error?.trim();
|
||||
return {
|
||||
manager: null,
|
||||
error: fallbackError
|
||||
? `${qmdFailureReason}; builtin fallback unavailable: ${fallbackError}`
|
||||
: qmdFailureReason,
|
||||
};
|
||||
}
|
||||
|
||||
async function getBuiltinMemorySearchManager(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
@@ -348,6 +371,7 @@ class BorrowedMemoryManager implements MemorySearchManager {
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
sources?: MemorySource[];
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
return await this.inner.search(query, opts);
|
||||
|
||||
@@ -157,8 +157,10 @@ describe("memory_search unavailable payloads", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async () => {
|
||||
let searchSignal: AbortSignal | undefined;
|
||||
setMemorySearchImpl(async (opts) => {
|
||||
searchCalls += 1;
|
||||
searchSignal = opts?.signal;
|
||||
return await new Promise(() => {});
|
||||
});
|
||||
const tool = createMemorySearchToolOrThrow();
|
||||
@@ -172,6 +174,8 @@ describe("memory_search unavailable payloads", () => {
|
||||
warning: "Memory search is unavailable due to an embedding/provider error.",
|
||||
action: "Check embedding provider configuration and retry memory_search.",
|
||||
});
|
||||
// The deadline must abort the orphaned search, not just race past it.
|
||||
expect(searchSignal?.aborted).toBe(true);
|
||||
const cooldownResult = await tool.execute("search-cooldown", { query: "hello again" });
|
||||
expectUnavailableMemorySearchDetails(cooldownResult.details, {
|
||||
error: "memory_search timed out after 15s",
|
||||
@@ -184,6 +188,35 @@ describe("memory_search unavailable payloads", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the timeout result when an abort-aware search rejects on abort", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
setMemorySearchImpl(
|
||||
async (opts) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
opts?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => reject(new Error("openai-compatible embeddings query failed: aborted")),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const tool = createMemorySearchToolOrThrow();
|
||||
|
||||
const resultPromise = tool.execute("abort-aware-timeout", { query: "hello" });
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
const result = await resultPromise;
|
||||
expectUnavailableMemorySearchDetails(result.details, {
|
||||
error: "memory_search timed out after 15s",
|
||||
warning: "Memory search is unavailable due to an embedding/provider error.",
|
||||
action: "Check embedding provider configuration and retry memory_search.",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-resolves the manager once when a cached sqlite handle was closed", async () => {
|
||||
let searchCalls = 0;
|
||||
setMemorySearchImpl(async () => {
|
||||
|
||||
@@ -83,14 +83,26 @@ export const testing = {
|
||||
|
||||
async function runMemorySearchToolWithDeadline<T>(params: {
|
||||
timeoutMs: number;
|
||||
run: () => Promise<T>;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
}): Promise<{ status: "ok"; value: T } | { status: "unavailable"; error: string }> {
|
||||
const timeoutError = () =>
|
||||
new Error(`memory_search timed out after ${Math.round(params.timeoutMs / 1000)}s`);
|
||||
// Abort the losing task when the deadline fires so in-flight embedding work
|
||||
// is cancelled instead of retrying orphaned for minutes after the tool
|
||||
// already returned "timed out" to the agent.
|
||||
const controller = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<"timeout">((resolve) => {
|
||||
timer = setTimeout(() => resolve("timeout"), params.timeoutMs);
|
||||
timer = setTimeout(() => {
|
||||
// Resolve before aborting: abort listeners run synchronously and an
|
||||
// abort-aware search could reject the task first, replacing the stable
|
||||
// timeout result with a provider-wrapped abort error.
|
||||
resolve("timeout");
|
||||
controller.abort(timeoutError());
|
||||
}, params.timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
const task = params.run();
|
||||
const task = params.run(controller.signal);
|
||||
task.catch(() => undefined);
|
||||
|
||||
try {
|
||||
@@ -98,7 +110,7 @@ async function runMemorySearchToolWithDeadline<T>(params: {
|
||||
if (result === "timeout") {
|
||||
return {
|
||||
status: "unavailable",
|
||||
error: `memory_search timed out after ${Math.round(params.timeoutMs / 1000)}s`,
|
||||
error: timeoutError().message,
|
||||
};
|
||||
}
|
||||
return { status: "ok", value: result as T };
|
||||
@@ -382,7 +394,7 @@ export function createMemorySearchTool(options: {
|
||||
|
||||
const outcome = await runMemorySearchToolWithDeadline({
|
||||
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
|
||||
run: async () => {
|
||||
run: async (deadlineSignal) => {
|
||||
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
|
||||
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
|
||||
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
|
||||
@@ -454,6 +466,7 @@ export function createMemorySearchTool(options: {
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
qmdSearchModeOverride,
|
||||
signal: deadlineSignal,
|
||||
onDebug: (debug: MemorySearchRuntimeDebug) => {
|
||||
runtimeDebug.push(debug);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
runWikiChatGptImport,
|
||||
runWikiChatGptRollback,
|
||||
runWikiDoctor,
|
||||
runWikiOkfImport,
|
||||
runWikiStatus,
|
||||
} from "./cli.js";
|
||||
import type { MemoryWikiPluginConfig } from "./config.js";
|
||||
@@ -27,6 +28,7 @@ vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
|
||||
const { createVault } = createMemoryWikiTestHarness();
|
||||
let suiteRoot = "";
|
||||
let caseIndex = 0;
|
||||
let stdoutWriteMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("memory-wiki cli", () => {
|
||||
beforeAll(async () => {
|
||||
@@ -41,8 +43,9 @@ describe("memory-wiki cli", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
callGatewayFromCliMock.mockReset();
|
||||
stdoutWriteMock = vi.fn(() => true);
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(
|
||||
(() => true) as typeof process.stdout.write,
|
||||
stdoutWriteMock as unknown as typeof process.stdout.write,
|
||||
);
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
@@ -174,6 +177,65 @@ describe("memory-wiki cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("registers OKF import and searches imported concepts", async () => {
|
||||
const { rootDir, config } = await createCliVault();
|
||||
const bundlePath = path.join(rootDir, "okf-bundle");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
await fs.writeFile(path.join(bundlePath, "index.md"), "# Sales OKF\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "customers.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Customer rows.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "orders.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Orders
|
||||
description: One row per completed order.
|
||||
---
|
||||
|
||||
Orders join to [customers](/tables/customers.md).
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerWikiCli(program, config);
|
||||
|
||||
await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" });
|
||||
|
||||
const importOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
|
||||
const importResult = JSON.parse(importOutput) as Awaited<ReturnType<typeof runWikiOkfImport>>;
|
||||
expect(importResult.importedCount).toBe(2);
|
||||
expect(importResult.pagePaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
|
||||
]),
|
||||
);
|
||||
|
||||
stdoutWriteMock.mockClear();
|
||||
await program.parseAsync(["wiki", "search", "completed order", "--json"], { from: "user" });
|
||||
|
||||
const searchOutput = String(stdoutWriteMock.mock.calls.at(-1)?.[0] ?? "");
|
||||
const searchResults = JSON.parse(searchOutput) as Array<{ path: string; title: string }>;
|
||||
expect(searchResults).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "Orders",
|
||||
path: expect.stringMatching(/^concepts\/okf-okf-bundle-[0-9a-f]{8}-tables-orders-/),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects apply confidence values outside the documented range", async () => {
|
||||
const { config } = await createCliVault();
|
||||
const program = new Command();
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
runObsidianOpen,
|
||||
runObsidianSearch,
|
||||
} from "./obsidian.js";
|
||||
import { formatOkfImportSummary, importMemoryWikiOkfBundle } from "./okf.js";
|
||||
import {
|
||||
getMemoryWikiPage,
|
||||
searchMemoryWiki,
|
||||
@@ -88,6 +89,10 @@ type WikiIngestCommandOptions = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type WikiOkfImportCommandOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type WikiSearchCommandOptions = {
|
||||
json?: boolean;
|
||||
maxResults?: number;
|
||||
@@ -590,6 +595,24 @@ export async function runWikiIngest(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWikiOkfImport(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
bundlePath: string;
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
return runWikiCommandWithSummary({
|
||||
json: params.json,
|
||||
stdout: params.stdout,
|
||||
run: () =>
|
||||
importMemoryWikiOkfBundle({
|
||||
config: params.config,
|
||||
bundlePath: params.bundlePath,
|
||||
}),
|
||||
render: formatOkfImportSummary,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runWikiSearch(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
appConfig?: OpenClawConfig;
|
||||
@@ -965,6 +988,16 @@ export function registerWikiCli(
|
||||
await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json });
|
||||
});
|
||||
|
||||
const okf = wiki.command("okf").description("Import Open Knowledge Format bundles");
|
||||
okf
|
||||
.command("import")
|
||||
.description("Import an unpacked OKF bundle into wiki concept pages")
|
||||
.argument("<path>", "OKF bundle directory")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => {
|
||||
await runWikiOkfImport({ config, bundlePath, json: opts.json });
|
||||
});
|
||||
|
||||
addWikiSearchConfigOptions(
|
||||
wiki
|
||||
.command("search")
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
||||
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
||||
type MemoryWikiLogEntry = {
|
||||
type: "init" | "ingest" | "compile" | "lint";
|
||||
type: "init" | "ingest" | "okf-import" | "compile" | "lint";
|
||||
timestamp: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
609
extensions/memory-wiki/src/okf.test.ts
Normal file
609
extensions/memory-wiki/src/okf.test.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
// Memory Wiki tests cover Open Knowledge Format import behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWikiMarkdown } from "./markdown.js";
|
||||
import { importMemoryWikiOkfBundle } from "./okf.js";
|
||||
import { searchMemoryWiki } from "./query.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempDir, createVault } = createMemoryWikiTestHarness();
|
||||
|
||||
function getOnlyPagePath(paths: string[]): string {
|
||||
expect(paths).toHaveLength(1);
|
||||
const [pagePath] = paths;
|
||||
if (!pagePath) {
|
||||
throw new Error("Expected OKF import to produce one page path.");
|
||||
}
|
||||
return pagePath;
|
||||
}
|
||||
|
||||
async function writeOkfBundle(rootDir: string) {
|
||||
const bundlePath = path.join(rootDir, "sales-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
await fs.mkdir(path.join(bundlePath, "metrics"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "index.md"),
|
||||
`---
|
||||
id: sales-okf
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Sales Bundle
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(path.join(bundlePath, "log.md"), "# Directory Update Log\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "customers.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
description: Customer table.
|
||||
resource: https://console.cloud.google.com/bigquery?p=acme&d=sales&t=customers
|
||||
tags: [sales, customers]
|
||||
timestamp: 2026-05-28T00:00:00Z
|
||||
producer_field:
|
||||
owner: data
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Customer rows.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "orders.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Orders
|
||||
description: One row per completed order.
|
||||
tags:
|
||||
- sales
|
||||
- orders
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Joined with [Customers](/tables/customers.md) and the [weekly metric](../metrics/weekly-active-users.md).
|
||||
Titled link to [weekly metric](../metrics/weekly-active-users.md "metric docs").
|
||||
|
||||
Inline code keeps \`[customers](/tables/customers.md)\` unchanged.
|
||||
|
||||
\`\`\`markdown
|
||||
[customers](/tables/customers.md)
|
||||
\`\`\`
|
||||
|
||||
External citation stays as [BigQuery](https://cloud.google.com/bigquery).
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "metrics", "weekly-active-users.md"),
|
||||
`---
|
||||
type: Metric
|
||||
title: Weekly Active Users
|
||||
---
|
||||
|
||||
Computed from [orders](../tables/orders.md).
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "draft.md"),
|
||||
`---
|
||||
title: Draft
|
||||
---
|
||||
|
||||
Missing type.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
describe("importMemoryWikiOkfBundle", () => {
|
||||
it("imports OKF concept documents as searchable wiki concept pages", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-");
|
||||
const bundlePath = await writeOkfBundle(rootDir);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const result = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(result.okfVersion).toBe("0.1");
|
||||
expect(result.importedCount).toBe(3);
|
||||
expect(result.skippedCount).toBe(1);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
code: "missing-type",
|
||||
path: "tables/draft.md",
|
||||
});
|
||||
expect(result.pagePaths).toHaveLength(3);
|
||||
const repeat = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 5, 0),
|
||||
});
|
||||
expect(repeat.importedCount).toBe(3);
|
||||
expect(repeat.updatedCount).toBe(0);
|
||||
|
||||
const ordersPath = result.pagePaths.find((pagePath) => pagePath.includes("orders"));
|
||||
expect(ordersPath).toBeTruthy();
|
||||
const ordersRaw = await fs.readFile(path.join(config.vault.path, ordersPath!), "utf8");
|
||||
const orders = parseWikiMarkdown(ordersRaw);
|
||||
expect(orders.frontmatter).toMatchObject({
|
||||
pageType: "concept",
|
||||
title: "Orders",
|
||||
sourceType: "okf",
|
||||
provenanceMode: "okf-import",
|
||||
okfConceptId: "tables/orders",
|
||||
okfType: "BigQuery Table",
|
||||
});
|
||||
expect(orders.frontmatter.sourceIds).toEqual([
|
||||
expect.stringMatching(/^source\.okf\.sales-okf$/),
|
||||
]);
|
||||
expect(orders.body).toMatch(/\]\(okf-sales-okf-tables-customers-/);
|
||||
expect(orders.body).toMatch(/\]\(okf-sales-okf-metrics-weekly-active-users-/);
|
||||
expect(orders.body).toContain('"metric docs"');
|
||||
expect(orders.body).toContain("`[customers](/tables/customers.md)`");
|
||||
expect(orders.body).toContain("```markdown\n[customers](/tables/customers.md)\n```");
|
||||
expect(orders.body).toContain("https://cloud.google.com/bigquery");
|
||||
|
||||
const okf = orders.frontmatter.okf as Record<string, unknown>;
|
||||
expect(okf).toMatchObject({
|
||||
version: "0.1",
|
||||
bundleName: "sales-okf",
|
||||
conceptId: "tables/orders",
|
||||
sourceRelativePath: "tables/orders.md",
|
||||
});
|
||||
expect(orders.frontmatter.relationships).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
targetPath: expect.stringMatching(/^concepts\/okf-sales-okf-tables-customers-/),
|
||||
kind: "okf-link",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
targetPath: expect.stringMatching(
|
||||
/^concepts\/okf-sales-okf-metrics-weekly-active-users-/,
|
||||
),
|
||||
kind: "okf-link",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
const customersPath = result.pagePaths.find((pagePath) => pagePath.includes("customers"));
|
||||
const customersRaw = await fs.readFile(path.join(config.vault.path, customersPath!), "utf8");
|
||||
const customers = parseWikiMarkdown(customersRaw);
|
||||
const customersOkf = customers.frontmatter.okf as Record<string, unknown>;
|
||||
expect(customersOkf.frontmatter).toMatchObject({
|
||||
producer_field: { owner: "data" },
|
||||
});
|
||||
|
||||
const searchResults = await searchMemoryWiki({
|
||||
config,
|
||||
query: "completed order",
|
||||
searchCorpus: "wiki",
|
||||
});
|
||||
expect(searchResults.map((searchResult) => searchResult.path)).toContain(ordersPath);
|
||||
});
|
||||
|
||||
it("caps generated concept filenames for long OKF concept paths", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-long-");
|
||||
const bundlePath = path.join(rootDir, "long-okf");
|
||||
const deepSegments = Array.from({ length: 40 }, (_, index) => `segment-${index}`);
|
||||
const deepDir = path.join(bundlePath, ...deepSegments);
|
||||
await fs.mkdir(deepDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(deepDir, "orders.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Long Orders
|
||||
---
|
||||
|
||||
Long concept body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const result = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(result.importedCount).toBe(1);
|
||||
const [pagePath] = result.pagePaths;
|
||||
expect(pagePath).toBeDefined();
|
||||
if (!pagePath) {
|
||||
throw new Error("Expected OKF import to produce a page path.");
|
||||
}
|
||||
const fileName = path.basename(pagePath);
|
||||
expect(Buffer.byteLength(fileName)).toBeLessThanOrEqual(255);
|
||||
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
|
||||
"Long concept body.",
|
||||
);
|
||||
});
|
||||
|
||||
it("namespaces concept pages by bundle so repeated OKF paths do not overwrite", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-bundles-");
|
||||
const firstBundle = path.join(rootDir, "first-bundle");
|
||||
const secondBundle = path.join(rootDir, "second-bundle");
|
||||
for (const [bundlePath, title] of [
|
||||
[firstBundle, "First Customers"],
|
||||
[secondBundle, "Second Customers"],
|
||||
] as const) {
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "customers.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: ${title}
|
||||
---
|
||||
|
||||
${title} body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const first = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath: firstBundle,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
const second = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath: secondBundle,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
const firstPath = getOnlyPagePath(first.pagePaths);
|
||||
const secondPath = getOnlyPagePath(second.pagePaths);
|
||||
expect(firstPath).not.toBe(secondPath);
|
||||
await expect(fs.readFile(path.join(config.vault.path, firstPath), "utf8")).resolves.toContain(
|
||||
"First Customers body.",
|
||||
);
|
||||
await expect(fs.readFile(path.join(config.vault.path, secondPath), "utf8")).resolves.toContain(
|
||||
"Second Customers body.",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes stale concept pages when an OKF bundle drops a concept", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-remove-");
|
||||
const bundlePath = path.join(rootDir, "removing-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
const customersPath = path.join(bundlePath, "tables", "customers.md");
|
||||
const ordersPath = path.join(bundlePath, "tables", "orders.md");
|
||||
await fs.writeFile(
|
||||
customersPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Customer body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
ordersPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Orders
|
||||
---
|
||||
|
||||
Order body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
const first = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
const stalePagePath = first.pagePaths.find((pagePath) => pagePath.includes("orders"));
|
||||
expect(stalePagePath).toBeDefined();
|
||||
if (!stalePagePath) {
|
||||
throw new Error("Expected initial OKF import to include orders.");
|
||||
}
|
||||
|
||||
await fs.rm(ordersPath);
|
||||
const second = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(second.importedCount).toBe(1);
|
||||
expect(second.removedCount).toBe(1);
|
||||
expect(second.removedPagePaths).toEqual([stalePagePath]);
|
||||
await expect(fs.stat(path.join(config.vault.path, stalePagePath))).rejects.toThrow();
|
||||
const results = await searchMemoryWiki({
|
||||
config,
|
||||
query: "Order body",
|
||||
searchCorpus: "wiki",
|
||||
});
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not prune existing pages when current OKF scan has invalid concepts", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-invalid-");
|
||||
const bundlePath = path.join(rootDir, "invalid-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
const customersPath = path.join(bundlePath, "tables", "customers.md");
|
||||
await fs.writeFile(
|
||||
customersPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Customer body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
const first = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
const pagePath = getOnlyPagePath(first.pagePaths);
|
||||
await fs.writeFile(
|
||||
customersPath,
|
||||
`---
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Temporarily invalid body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const second = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(second.importedCount).toBe(0);
|
||||
expect(second.skippedCount).toBe(1);
|
||||
expect(second.removedCount).toBe(0);
|
||||
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
|
||||
"Customer body.",
|
||||
);
|
||||
});
|
||||
|
||||
it("detects body-only changes on timestamp-shaped markdown lines", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-body-timestamp-");
|
||||
const bundlePath = path.join(rootDir, "body-timestamp-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
const conceptPath = path.join(bundlePath, "tables", "events.md");
|
||||
await fs.writeFile(
|
||||
conceptPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Events
|
||||
---
|
||||
|
||||
updatedAt: 2026-06-12
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
const first = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
const pagePath = getOnlyPagePath(first.pagePaths);
|
||||
await fs.writeFile(
|
||||
conceptPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Events
|
||||
---
|
||||
|
||||
updatedAt: 2026-06-13
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const second = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 13, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(second.updatedCount).toBe(1);
|
||||
await expect(fs.readFile(path.join(config.vault.path, pagePath), "utf8")).resolves.toContain(
|
||||
"updatedAt: 2026-06-13",
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites percent-encoded OKF markdown links and preserves suffixes", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-encoded-link-");
|
||||
const bundlePath = path.join(rootDir, "encoded-okf");
|
||||
await fs.mkdir(bundlePath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "BigQuery Table.md"),
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: BigQuery Table
|
||||
---
|
||||
|
||||
Table body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "links.md"),
|
||||
`---
|
||||
type: Concept
|
||||
title: Links
|
||||
---
|
||||
|
||||
See [table](BigQuery%20Table.md?view=compact#columns).
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const result = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
const linksPath = result.pagePaths.find((pagePath) => pagePath.includes("links"));
|
||||
expect(linksPath).toBeDefined();
|
||||
if (!linksPath) {
|
||||
throw new Error("Expected links page to be imported.");
|
||||
}
|
||||
await expect(fs.readFile(path.join(config.vault.path, linksPath), "utf8")).resolves.toMatch(
|
||||
/\[table\]\(okf-encoded-okf-[0-9a-f]{8}-bigquery-table-[^)]+\.md\?view=compact#columns\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("imports OKF concept frontmatter with CRLF line endings", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-crlf-");
|
||||
const bundlePath = path.join(rootDir, "crlf-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundlePath, "tables", "events.md"),
|
||||
[
|
||||
"---",
|
||||
"type: BigQuery Table",
|
||||
"title: Events",
|
||||
"---",
|
||||
"",
|
||||
"Windows-flavored frontmatter.",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const result = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(result.importedCount).toBe(1);
|
||||
expect(result.skippedCount).toBe(0);
|
||||
await expect(
|
||||
fs.readFile(path.join(config.vault.path, getOnlyPagePath(result.pagePaths)), "utf8"),
|
||||
).resolves.toContain("Windows-flavored frontmatter.");
|
||||
});
|
||||
|
||||
it("refuses to write imported OKF concept pages through symlinks", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-symlink-");
|
||||
const bundlePath = path.join(rootDir, "safe-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
const conceptPath = path.join(bundlePath, "tables", "customers.md");
|
||||
await fs.writeFile(
|
||||
conceptPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Original body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
const first = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
const pagePath = getOnlyPagePath(first.pagePaths);
|
||||
const pageAbsolutePath = path.join(config.vault.path, pagePath);
|
||||
const externalTarget = path.join(rootDir, "outside.md");
|
||||
await fs.writeFile(externalTarget, "external target\n", "utf8");
|
||||
await fs.rm(pageAbsolutePath);
|
||||
await fs.symlink(externalTarget, pageAbsolutePath);
|
||||
await fs.writeFile(
|
||||
conceptPath,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Customers
|
||||
---
|
||||
|
||||
Updated body.
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(
|
||||
importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 11, 0, 0),
|
||||
}),
|
||||
).rejects.toThrow("through symlink");
|
||||
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
|
||||
});
|
||||
|
||||
it("refuses to import OKF concept files through hardlinks", async () => {
|
||||
const rootDir = await createTempDir("memory-wiki-okf-hardlink-");
|
||||
const bundlePath = path.join(rootDir, "hardlink-okf");
|
||||
await fs.mkdir(path.join(bundlePath, "tables"), { recursive: true });
|
||||
const externalSource = path.join(rootDir, "outside.md");
|
||||
await fs.writeFile(
|
||||
externalSource,
|
||||
`---
|
||||
type: BigQuery Table
|
||||
title: Private
|
||||
---
|
||||
|
||||
private body
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.link(externalSource, path.join(bundlePath, "tables", "private.md"));
|
||||
const { config } = await createVault({
|
||||
rootDir: path.join(rootDir, "vault"),
|
||||
});
|
||||
|
||||
const result = await importMemoryWikiOkfBundle({
|
||||
config,
|
||||
bundlePath,
|
||||
nowMs: Date.UTC(2026, 5, 12, 10, 0, 0),
|
||||
});
|
||||
|
||||
expect(result.importedCount).toBe(0);
|
||||
expect(result.skippedCount).toBe(1);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
code: "unreadable-entry",
|
||||
path: "tables/private.md",
|
||||
});
|
||||
});
|
||||
});
|
||||
746
extensions/memory-wiki/src/okf.ts
Normal file
746
extensions/memory-wiki/src/okf.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
// Memory Wiki plugin module implements Open Knowledge Format import behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { FsSafeError, root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeSingleOrTrimmedStringList,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { compileMemoryWikiVault } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import {
|
||||
createWikiPageFilename,
|
||||
parseWikiMarkdown,
|
||||
renderWikiMarkdown,
|
||||
slugifyWikiSegment,
|
||||
WIKI_RELATED_END_MARKER,
|
||||
WIKI_RELATED_START_MARKER,
|
||||
} from "./markdown.js";
|
||||
import { resolveMemoryWikiTimestamp } from "./time.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const OKF_RESERVED_FILENAMES = new Set(["index.md", "log.md"]);
|
||||
const OKF_MARKDOWN_LINK_PATTERN = /(!?)\[([^\]]*)\]\(([^)]+)\)/g;
|
||||
const OKF_FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/;
|
||||
const OKF_RELATED_SECTION_PATTERN = new RegExp(
|
||||
`\\n+## Related\\n${WIKI_RELATED_START_MARKER}[\\s\\S]*?${WIKI_RELATED_END_MARKER}\\n?`,
|
||||
"g",
|
||||
);
|
||||
const OKF_VOLATILE_TIMESTAMP_LINE_PATTERN = /^(?:importedAt|updatedAt): .*\n/gm;
|
||||
const OKF_HASH_CHARS = 8;
|
||||
|
||||
type FileStatLike = {
|
||||
isFile?: unknown;
|
||||
nlink?: unknown;
|
||||
};
|
||||
|
||||
type OkfConceptDocument = {
|
||||
conceptId: string;
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
resource?: string;
|
||||
tags: string[];
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
type OkfImportedPage = {
|
||||
conceptId: string;
|
||||
sourcePath: string;
|
||||
pageId: string;
|
||||
pagePath: string;
|
||||
title: string;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
export type ImportMemoryWikiOkfWarning = {
|
||||
code: "invalid-concept" | "missing-type" | "unreadable-entry";
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ImportMemoryWikiOkfResult = {
|
||||
bundlePath: string;
|
||||
bundleName: string;
|
||||
okfVersion?: string;
|
||||
importedCount: number;
|
||||
updatedCount: number;
|
||||
removedCount: number;
|
||||
skippedCount: number;
|
||||
pagePaths: string[];
|
||||
removedPagePaths: string[];
|
||||
warnings: ImportMemoryWikiOkfWarning[];
|
||||
indexUpdatedFiles: string[];
|
||||
};
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function trimMarkdownExtension(value: string): string {
|
||||
return value.replace(/\.md$/i, "");
|
||||
}
|
||||
|
||||
function isRegularFileStat(value: unknown): value is FileStatLike & { nlink: number } {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const stat = value as FileStatLike;
|
||||
const isFile =
|
||||
typeof stat.isFile === "function"
|
||||
? (stat.isFile as () => boolean).call(stat)
|
||||
: stat.isFile === true;
|
||||
return isFile && typeof stat.nlink === "number";
|
||||
}
|
||||
|
||||
type OkfBundleMetadata = {
|
||||
key: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
function createOkfBundleKey(params: {
|
||||
rootFrontmatter: Record<string, unknown>;
|
||||
bundleName: string;
|
||||
bundlePath: string;
|
||||
}): string {
|
||||
const producerId =
|
||||
normalizeOptionalString(params.rootFrontmatter.id) ??
|
||||
normalizeOptionalString(params.rootFrontmatter.okf_id);
|
||||
if (producerId) {
|
||||
return slugifyWikiSegment(producerId);
|
||||
}
|
||||
const label =
|
||||
normalizeOptionalString(params.rootFrontmatter.name) ??
|
||||
normalizeOptionalString(params.rootFrontmatter.title) ??
|
||||
params.bundleName;
|
||||
const hash = createHash("sha1").update(params.bundlePath).digest("hex").slice(0, OKF_HASH_CHARS);
|
||||
return `${slugifyWikiSegment(label)}-${hash}`;
|
||||
}
|
||||
|
||||
function createOkfPageStem(bundleKey: string, conceptId: string): string {
|
||||
const slug = slugifyWikiSegment(conceptId.replace(/\//g, "-"));
|
||||
const hash = createHash("sha1").update(conceptId).digest("hex").slice(0, OKF_HASH_CHARS);
|
||||
return `okf-${bundleKey}-${slug}-${hash}`;
|
||||
}
|
||||
|
||||
function createOkfPageIdentity(
|
||||
bundleKey: string,
|
||||
conceptId: string,
|
||||
): { pageId: string; pagePath: string } {
|
||||
const fileName = createWikiPageFilename(createOkfPageStem(bundleKey, conceptId));
|
||||
const stem = trimMarkdownExtension(fileName);
|
||||
return {
|
||||
pageId: `concept.${stem}`,
|
||||
pagePath: `concepts/${fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function collectOkfMarkdownFiles(
|
||||
rootDir: string,
|
||||
warnings: ImportMemoryWikiOkfWarning[],
|
||||
): Promise<string[]> {
|
||||
async function walk(relativeDir: string): Promise<string[]> {
|
||||
const absoluteDir = path.join(rootDir, relativeDir);
|
||||
const entries = await fs.readdir(absoluteDir, { withFileTypes: true }).catch((err: unknown) => {
|
||||
warnings.push({
|
||||
code: "unreadable-entry",
|
||||
path: toPosixPath(relativeDir) || ".",
|
||||
message: err instanceof Error ? err.message : "Unable to read OKF directory.",
|
||||
});
|
||||
return [];
|
||||
});
|
||||
const files: string[] = [];
|
||||
for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
||||
if (entry.name === ".git" || entry.name === "node_modules") {
|
||||
continue;
|
||||
}
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walk(relativePath)));
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
files.push(relativePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
return (await walk("")).map(toPosixPath).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function parseOkfMarkdown(
|
||||
content: string,
|
||||
relativePath: string,
|
||||
): {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
warning?: ImportMemoryWikiOkfWarning;
|
||||
} {
|
||||
const normalizedContent = content.replace(/\r\n/g, "\n");
|
||||
try {
|
||||
return parseWikiMarkdown(normalizedContent);
|
||||
} catch (err) {
|
||||
return {
|
||||
frontmatter: {},
|
||||
body: normalizedContent,
|
||||
warning: {
|
||||
code: "invalid-concept",
|
||||
path: relativePath,
|
||||
message: err instanceof Error ? err.message : "Unable to parse OKF frontmatter.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function readOkfTextFile(params: {
|
||||
bundlePath: string;
|
||||
relativePath: string;
|
||||
warnings: ImportMemoryWikiOkfWarning[];
|
||||
}): Promise<string | null> {
|
||||
const root = await fsRoot(params.bundlePath);
|
||||
const stat = await root.stat(params.relativePath).catch((err: unknown) => {
|
||||
params.warnings.push({
|
||||
code: "unreadable-entry",
|
||||
path: params.relativePath,
|
||||
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
if (!isRegularFileStat(stat)) {
|
||||
params.warnings.push({
|
||||
code: "unreadable-entry",
|
||||
path: params.relativePath,
|
||||
message: "Refusing to import OKF concept through non-regular or hardlinked file.",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return await root.readText(params.relativePath).catch((err: unknown) => {
|
||||
params.warnings.push({
|
||||
code: "unreadable-entry",
|
||||
path: params.relativePath,
|
||||
message: err instanceof Error ? err.message : "Unable to read OKF concept.",
|
||||
});
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function deriveOkfTitle(relativePath: string, frontmatter: Record<string, unknown>): string {
|
||||
return (
|
||||
normalizeOptionalString(frontmatter.title) ??
|
||||
path.posix.basename(relativePath, ".md").replace(/[-_]+/g, " ").trim() ??
|
||||
trimMarkdownExtension(relativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOkfConcept(params: {
|
||||
bundlePath: string;
|
||||
relativePath: string;
|
||||
content: string;
|
||||
}): { concept?: OkfConceptDocument; warning?: ImportMemoryWikiOkfWarning } {
|
||||
const parsed = parseOkfMarkdown(params.content, params.relativePath);
|
||||
if (parsed.warning) {
|
||||
return { warning: parsed.warning };
|
||||
}
|
||||
|
||||
const type = normalizeOptionalString(parsed.frontmatter.type);
|
||||
if (!type) {
|
||||
return {
|
||||
warning: {
|
||||
code: "missing-type",
|
||||
path: params.relativePath,
|
||||
message: "OKF concept is missing required non-empty type frontmatter.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const conceptId = trimMarkdownExtension(params.relativePath);
|
||||
const timestamp = normalizeOptionalString(parsed.frontmatter.timestamp);
|
||||
return {
|
||||
concept: {
|
||||
conceptId,
|
||||
relativePath: params.relativePath,
|
||||
absolutePath: path.join(params.bundlePath, params.relativePath),
|
||||
frontmatter: parsed.frontmatter,
|
||||
body: parsed.body,
|
||||
type,
|
||||
title: deriveOkfTitle(params.relativePath, parsed.frontmatter),
|
||||
...(normalizeOptionalString(parsed.frontmatter.description)
|
||||
? { description: normalizeOptionalString(parsed.frontmatter.description) }
|
||||
: {}),
|
||||
...(normalizeOptionalString(parsed.frontmatter.resource)
|
||||
? { resource: normalizeOptionalString(parsed.frontmatter.resource) }
|
||||
: {}),
|
||||
tags: normalizeSingleOrTrimmedStringList(parsed.frontmatter.tags),
|
||||
...(timestamp ? { timestamp } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitMarkdownLinkDestination(target: string): {
|
||||
destination: string;
|
||||
titleSuffix: string;
|
||||
} {
|
||||
const trimmed = target.trim();
|
||||
if (trimmed.startsWith("<")) {
|
||||
const end = trimmed.indexOf(">");
|
||||
if (end > 0) {
|
||||
return {
|
||||
destination: trimmed.slice(1, end),
|
||||
titleSuffix: trimmed.slice(end + 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
const match = trimmed.match(/^(\S+)(\s+[\s\S]+)?$/);
|
||||
return {
|
||||
destination: match?.[1] ?? trimmed,
|
||||
titleSuffix: match?.[2] ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOkfMarkdownTarget(sourceRelativePath: string, target: string): string | null {
|
||||
const { destination } = splitMarkdownLinkDestination(target);
|
||||
const trimmed = destination.trim();
|
||||
if (!trimmed || trimmed.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawTargetWithoutSuffix = trimmed.split("#")[0]?.split("?")[0]?.replace(/\\/g, "/").trim();
|
||||
const targetWithoutSuffix = safeDecodeOkfLinkPath(rawTargetWithoutSuffix);
|
||||
if (!targetWithoutSuffix || !targetWithoutSuffix.endsWith(".md")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = targetWithoutSuffix.startsWith("/")
|
||||
? path.posix.normalize(targetWithoutSuffix.slice(1))
|
||||
: path.posix.normalize(
|
||||
path.posix.join(path.posix.dirname(sourceRelativePath), targetWithoutSuffix),
|
||||
);
|
||||
const conceptId = trimMarkdownExtension(normalized);
|
||||
return conceptId.startsWith("../") ? null : conceptId;
|
||||
}
|
||||
|
||||
function safeDecodeOkfLinkPath(value: string | undefined): string {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function getMarkdownDestinationSuffix(destination: string): string {
|
||||
const queryIndex = destination.indexOf("?");
|
||||
const fragmentIndex = destination.indexOf("#");
|
||||
const suffixIndex = queryIndex === -1
|
||||
? fragmentIndex
|
||||
: fragmentIndex === -1
|
||||
? queryIndex
|
||||
: Math.min(queryIndex, fragmentIndex);
|
||||
return suffixIndex === -1 ? "" : destination.slice(suffixIndex);
|
||||
}
|
||||
|
||||
function rewriteOkfMarkdownLinks(params: {
|
||||
body: string;
|
||||
sourcePagePath: string;
|
||||
sourceRelativePath: string;
|
||||
pageByConceptId: Map<string, { pageId: string; pagePath: string; title: string }>;
|
||||
}): { body: string; linkedConceptIds: string[] } {
|
||||
const linkedConceptIds: string[] = [];
|
||||
const rewriteLinks = (markdown: string) =>
|
||||
markdown.replace(
|
||||
OKF_MARKDOWN_LINK_PATTERN,
|
||||
(match, imagePrefix: string, label: string, rawTarget: string) => {
|
||||
const conceptId = resolveOkfMarkdownTarget(params.sourceRelativePath, rawTarget);
|
||||
if (!conceptId) {
|
||||
return match;
|
||||
}
|
||||
const target = params.pageByConceptId.get(conceptId);
|
||||
if (!target) {
|
||||
return match;
|
||||
}
|
||||
linkedConceptIds.push(conceptId);
|
||||
const { destination, titleSuffix } = splitMarkdownLinkDestination(rawTarget);
|
||||
const relativeTarget = path.posix.relative(
|
||||
path.posix.dirname(params.sourcePagePath),
|
||||
target.pagePath,
|
||||
);
|
||||
const suffix = getMarkdownDestinationSuffix(destination);
|
||||
return `${imagePrefix}[${label}](${relativeTarget}${suffix}${titleSuffix})`;
|
||||
},
|
||||
);
|
||||
const body = rewriteMarkdownOutsideCode(params.body, rewriteLinks);
|
||||
return { body, linkedConceptIds: uniqueStrings(linkedConceptIds) };
|
||||
}
|
||||
|
||||
function rewriteMarkdownLineOutsideInlineCode(
|
||||
line: string,
|
||||
rewriteLinks: (markdown: string) => string,
|
||||
): string {
|
||||
let result = "";
|
||||
let cursor = 0;
|
||||
while (cursor < line.length) {
|
||||
const codeStart = line.indexOf("`", cursor);
|
||||
if (codeStart === -1) {
|
||||
result += rewriteLinks(line.slice(cursor));
|
||||
break;
|
||||
}
|
||||
result += rewriteLinks(line.slice(cursor, codeStart));
|
||||
const delimiter = line.slice(codeStart).match(/^`+/)?.[0] ?? "`";
|
||||
const codeEnd = line.indexOf(delimiter, codeStart + delimiter.length);
|
||||
if (codeEnd === -1) {
|
||||
result += line.slice(codeStart);
|
||||
break;
|
||||
}
|
||||
result += line.slice(codeStart, codeEnd + delimiter.length);
|
||||
cursor = codeEnd + delimiter.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function rewriteMarkdownOutsideCode(
|
||||
markdown: string,
|
||||
rewriteLinks: (markdown: string) => string,
|
||||
): string {
|
||||
const lines = markdown.split(/(\n)/);
|
||||
let inFence = false;
|
||||
let fenceDelimiter = "";
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (line === "\n") {
|
||||
return line;
|
||||
}
|
||||
const fenceMatch = line.match(OKF_FENCE_PATTERN);
|
||||
if (fenceMatch) {
|
||||
const delimiter = fenceMatch[1] ?? "";
|
||||
const closesFence =
|
||||
inFence &&
|
||||
delimiter.startsWith(fenceDelimiter[0] ?? "") &&
|
||||
delimiter.length >= fenceDelimiter.length;
|
||||
const opensFence = !inFence;
|
||||
if (opensFence) {
|
||||
inFence = true;
|
||||
fenceDelimiter = delimiter;
|
||||
} else if (closesFence) {
|
||||
inFence = false;
|
||||
fenceDelimiter = "";
|
||||
}
|
||||
return line;
|
||||
}
|
||||
return inFence ? line : rewriteMarkdownLineOutsideInlineCode(line, rewriteLinks);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function normalizeOkfRenderedPageForComparison(content: string): string {
|
||||
const withoutRelated = content.replace(OKF_RELATED_SECTION_PATTERN, "\n");
|
||||
const frontmatterMatch = withoutRelated.match(/^---\n([\s\S]*?)\n---\n?/);
|
||||
if (!frontmatterMatch) {
|
||||
return withoutRelated.trimEnd();
|
||||
}
|
||||
const normalizedFrontmatter =
|
||||
frontmatterMatch[1]?.replace(OKF_VOLATILE_TIMESTAMP_LINE_PATTERN, "") ?? "";
|
||||
const frontmatterBody = normalizedFrontmatter.endsWith("\n")
|
||||
? normalizedFrontmatter
|
||||
: `${normalizedFrontmatter}\n`;
|
||||
return `---\n${frontmatterBody}---\n${withoutRelated.slice(frontmatterMatch[0].length)}`.trimEnd();
|
||||
}
|
||||
|
||||
async function writeOkfConceptPage(params: {
|
||||
vaultRoot: string;
|
||||
pagePath: string;
|
||||
content: string;
|
||||
}): Promise<{ changed: boolean; created: boolean }> {
|
||||
const vault = await fsRoot(params.vaultRoot);
|
||||
const pageStat = await vault.stat(params.pagePath).catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof FsSafeError &&
|
||||
(error.code === "not-found" || error.code === "path-alias")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const existing = pageStat ? await vault.readText(params.pagePath).catch(() => "") : "";
|
||||
if (
|
||||
existing === params.content ||
|
||||
normalizeOkfRenderedPageForComparison(existing) ===
|
||||
normalizeOkfRenderedPageForComparison(params.content)
|
||||
) {
|
||||
return { changed: false, created: !pageStat };
|
||||
}
|
||||
try {
|
||||
if (isRegularFileStat(pageStat) && pageStat.nlink > 1) {
|
||||
await vault.remove(params.pagePath);
|
||||
}
|
||||
await vault.write(params.pagePath, params.content);
|
||||
} catch (error) {
|
||||
if (error instanceof FsSafeError) {
|
||||
if (error.code !== "symlink" && error.code !== "path-alias") {
|
||||
throw new Error(
|
||||
`Refusing to write OKF concept page (${error.code}): ${params.pagePath}: ${error.message}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw new Error(`Refusing to write OKF concept page through symlink: ${params.pagePath}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { changed: true, created: !pageStat };
|
||||
}
|
||||
|
||||
async function removeStaleOkfConceptPages(params: {
|
||||
vaultRoot: string;
|
||||
bundleKey: string;
|
||||
currentPagePaths: Set<string>;
|
||||
}): Promise<string[]> {
|
||||
const vault = await fsRoot(params.vaultRoot);
|
||||
const conceptsDir = path.join(params.vaultRoot, "concepts");
|
||||
const entries = await fs.readdir(conceptsDir, { withFileTypes: true }).catch(() => []);
|
||||
const removedPagePaths: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") {
|
||||
continue;
|
||||
}
|
||||
const pagePath = `concepts/${entry.name}`;
|
||||
if (params.currentPagePaths.has(pagePath)) {
|
||||
continue;
|
||||
}
|
||||
const raw = await vault.readText(pagePath).catch(() => "");
|
||||
const parsed = parseWikiMarkdown(raw);
|
||||
const okf = parsed.frontmatter.okf;
|
||||
if (
|
||||
okf &&
|
||||
typeof okf === "object" &&
|
||||
!Array.isArray(okf) &&
|
||||
(okf as Record<string, unknown>).bundleKey === params.bundleKey
|
||||
) {
|
||||
await vault.remove(pagePath);
|
||||
removedPagePaths.push(pagePath);
|
||||
}
|
||||
}
|
||||
return removedPagePaths;
|
||||
}
|
||||
|
||||
function readRootOkfMetadata(params: {
|
||||
rootIndex: string | undefined;
|
||||
bundleName: string;
|
||||
bundlePath: string;
|
||||
}): OkfBundleMetadata {
|
||||
if (!params.rootIndex) {
|
||||
return {
|
||||
key: createOkfBundleKey({
|
||||
rootFrontmatter: {},
|
||||
bundleName: params.bundleName,
|
||||
bundlePath: params.bundlePath,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const parsed = parseOkfMarkdown(params.rootIndex, "index.md");
|
||||
return {
|
||||
key: createOkfBundleKey({
|
||||
rootFrontmatter: parsed.frontmatter,
|
||||
bundleName: params.bundleName,
|
||||
bundlePath: params.bundlePath,
|
||||
}),
|
||||
...(normalizeOptionalString(parsed.frontmatter.okf_version)
|
||||
? { version: normalizeOptionalString(parsed.frontmatter.okf_version) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatOkfImportSummary(result: ImportMemoryWikiOkfResult): string {
|
||||
return `Imported ${result.importedCount} OKF concept${result.importedCount === 1 ? "" : "s"} from ${result.bundlePath} into memory wiki. Updated ${result.updatedCount}; removed ${result.removedCount}; skipped ${result.skippedCount}; refreshed ${result.indexUpdatedFiles.length} index file${result.indexUpdatedFiles.length === 1 ? "" : "s"}.`;
|
||||
}
|
||||
|
||||
export { formatOkfImportSummary };
|
||||
|
||||
export async function importMemoryWikiOkfBundle(params: {
|
||||
config: ResolvedMemoryWikiConfig;
|
||||
bundlePath: string;
|
||||
nowMs?: number;
|
||||
}): Promise<ImportMemoryWikiOkfResult> {
|
||||
await initializeMemoryWikiVault(params.config, { nowMs: params.nowMs });
|
||||
const bundlePath = path.resolve(params.bundlePath);
|
||||
const stat = await fs.stat(bundlePath);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error("wiki okf import expects an unpacked OKF bundle directory.");
|
||||
}
|
||||
|
||||
const warnings: ImportMemoryWikiOkfWarning[] = [];
|
||||
const markdownFiles = await collectOkfMarkdownFiles(bundlePath, warnings);
|
||||
const concepts: OkfConceptDocument[] = [];
|
||||
let rootIndexContent: string | undefined;
|
||||
|
||||
for (const relativePath of markdownFiles) {
|
||||
if (relativePath === "index.md") {
|
||||
rootIndexContent =
|
||||
(await readOkfTextFile({ bundlePath, relativePath, warnings })) ?? undefined;
|
||||
}
|
||||
if (OKF_RESERVED_FILENAMES.has(path.posix.basename(relativePath))) {
|
||||
continue;
|
||||
}
|
||||
const content = await readOkfTextFile({ bundlePath, relativePath, warnings });
|
||||
if (content === null) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeOkfConcept({ bundlePath, relativePath, content });
|
||||
if (normalized.warning) {
|
||||
warnings.push(normalized.warning);
|
||||
continue;
|
||||
}
|
||||
if (normalized.concept) {
|
||||
concepts.push(normalized.concept);
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = resolveMemoryWikiTimestamp(params.nowMs);
|
||||
const bundleName = path.basename(bundlePath);
|
||||
const bundleMetadata = readRootOkfMetadata({
|
||||
rootIndex: rootIndexContent,
|
||||
bundleName,
|
||||
bundlePath,
|
||||
});
|
||||
const bundleKey = bundleMetadata.key;
|
||||
const pageByConceptId = new Map<string, { pageId: string; pagePath: string; title: string }>();
|
||||
for (const concept of concepts) {
|
||||
pageByConceptId.set(concept.conceptId, {
|
||||
...createOkfPageIdentity(bundleKey, concept.conceptId),
|
||||
title: concept.title,
|
||||
});
|
||||
}
|
||||
|
||||
const importedPages: OkfImportedPage[] = [];
|
||||
let updatedCount = 0;
|
||||
|
||||
await fs.mkdir(path.join(params.config.vault.path, "concepts"), { recursive: true });
|
||||
for (const concept of concepts.toSorted((left, right) =>
|
||||
left.conceptId.localeCompare(right.conceptId),
|
||||
)) {
|
||||
const page = pageByConceptId.get(concept.conceptId);
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
const rewritten = rewriteOkfMarkdownLinks({
|
||||
body: concept.body,
|
||||
sourcePagePath: page.pagePath,
|
||||
sourceRelativePath: concept.relativePath,
|
||||
pageByConceptId,
|
||||
});
|
||||
const relationships = rewritten.linkedConceptIds.flatMap((conceptId) => {
|
||||
const target = pageByConceptId.get(conceptId);
|
||||
return target
|
||||
? [
|
||||
{
|
||||
targetId: target.pageId,
|
||||
targetPath: target.pagePath,
|
||||
targetTitle: target.title,
|
||||
kind: "okf-link",
|
||||
evidenceKind: "okf-markdown-link",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
const frontmatter = {
|
||||
pageType: "concept",
|
||||
id: page.pageId,
|
||||
title: concept.title,
|
||||
sourceType: "okf",
|
||||
provenanceMode: "okf-import",
|
||||
sourcePath: concept.absolutePath,
|
||||
okfConceptId: concept.conceptId,
|
||||
okfType: concept.type,
|
||||
sourceIds: [`source.okf.${bundleKey}`],
|
||||
importedAt: timestamp,
|
||||
updatedAt: concept.timestamp ?? timestamp,
|
||||
status: "active",
|
||||
...(concept.description ? { description: concept.description } : {}),
|
||||
...(concept.resource ? { resource: concept.resource } : {}),
|
||||
...(concept.tags.length > 0 ? { tags: concept.tags } : {}),
|
||||
...(concept.timestamp ? { okfTimestamp: concept.timestamp } : {}),
|
||||
...(relationships.length > 0 ? { relationships } : {}),
|
||||
okf: {
|
||||
...(bundleMetadata.version ? { version: bundleMetadata.version } : {}),
|
||||
bundleName,
|
||||
bundleKey,
|
||||
conceptId: concept.conceptId,
|
||||
sourceRelativePath: concept.relativePath,
|
||||
frontmatter: concept.frontmatter,
|
||||
},
|
||||
};
|
||||
|
||||
const writeResult = await writeOkfConceptPage({
|
||||
vaultRoot: params.config.vault.path,
|
||||
pagePath: page.pagePath,
|
||||
content: renderWikiMarkdown({
|
||||
frontmatter,
|
||||
body: rewritten.body,
|
||||
}),
|
||||
});
|
||||
if (!writeResult.created && writeResult.changed) {
|
||||
updatedCount++;
|
||||
}
|
||||
importedPages.push({
|
||||
conceptId: concept.conceptId,
|
||||
sourcePath: concept.absolutePath,
|
||||
pageId: page.pageId,
|
||||
pagePath: page.pagePath,
|
||||
title: concept.title,
|
||||
created: writeResult.created,
|
||||
});
|
||||
}
|
||||
const currentPagePaths = new Set(importedPages.map((page) => page.pagePath));
|
||||
const removedPagePaths =
|
||||
warnings.length === 0
|
||||
? await removeStaleOkfConceptPages({
|
||||
vaultRoot: params.config.vault.path,
|
||||
bundleKey,
|
||||
currentPagePaths,
|
||||
})
|
||||
: [];
|
||||
|
||||
await appendMemoryWikiLog(params.config.vault.path, {
|
||||
type: "okf-import",
|
||||
timestamp,
|
||||
details: {
|
||||
bundlePath,
|
||||
bundleName,
|
||||
importedCount: importedPages.length,
|
||||
updatedCount,
|
||||
removedCount: removedPagePaths.length,
|
||||
skippedCount: warnings.length,
|
||||
pagePaths: importedPages.map((page) => page.pagePath),
|
||||
removedPagePaths,
|
||||
},
|
||||
});
|
||||
|
||||
const compile = await compileMemoryWikiVault(params.config);
|
||||
return {
|
||||
bundlePath,
|
||||
bundleName,
|
||||
...(bundleMetadata.version ? { okfVersion: bundleMetadata.version } : {}),
|
||||
importedCount: importedPages.length,
|
||||
updatedCount,
|
||||
removedCount: removedPagePaths.length,
|
||||
skippedCount: warnings.length,
|
||||
pagePaths: importedPages.map((page) => page.pagePath),
|
||||
removedPagePaths,
|
||||
warnings,
|
||||
indexUpdatedFiles: compile.updatedFiles,
|
||||
};
|
||||
}
|
||||
@@ -44,7 +44,7 @@ describe("moonshot provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("owns replay policy for OpenAI-compatible Moonshot transports without mangling native Kimi tool_call IDs", async () => {
|
||||
it("rewrites duplicate tool-call ids with OpenAI-style ids for Moonshot replay", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
const policy = provider.buildReplayPolicy?.({
|
||||
@@ -57,10 +57,28 @@ describe("moonshot provider plugin", () => {
|
||||
applyAssistantFirstOrderingFix: true,
|
||||
validateGeminiTurns: true,
|
||||
validateAnthropicTurns: true,
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
duplicateToolCallIdStyle: "openai",
|
||||
});
|
||||
expect(policy).not.toHaveProperty("dropReasoningFromHistory");
|
||||
expect(policy).not.toHaveProperty("sanitizeToolCallIds");
|
||||
expect(policy).not.toHaveProperty("toolCallIdMode");
|
||||
});
|
||||
|
||||
it("preserves responses-family replay behavior", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
const policy = provider.buildReplayPolicy?.({
|
||||
provider: "moonshot",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "kimi-k2.6",
|
||||
} as never);
|
||||
|
||||
expect(policy).toEqual({
|
||||
applyAssistantFirstOrderingFix: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
allowSyntheticToolResults: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("wires moonshot-thinking stream hooks", async () => {
|
||||
@@ -89,4 +107,60 @@ describe("moonshot provider plugin", () => {
|
||||
thinking: { type: "disabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Kimi K2.7 Code thinking always on without sending a thinking field", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const capturedStream = createCapturedThinkingConfigStream();
|
||||
|
||||
const wrapped = provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
thinkingLevel: "off",
|
||||
streamFn: capturedStream.streamFn,
|
||||
} as never);
|
||||
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "moonshot",
|
||||
id: "kimi-k2.7-code",
|
||||
} as Model<"openai-completions">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(capturedStream.getCapturedPayload()).toEqual({
|
||||
config: { thinkingConfig: { thinkingBudget: -1 } },
|
||||
});
|
||||
expect(
|
||||
provider.wrapSimpleCompletionStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
streamFn: capturedStream.streamFn,
|
||||
} as never),
|
||||
).toBe(capturedStream.streamFn);
|
||||
expect(
|
||||
provider.resolveThinkingProfile?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
reasoning: true,
|
||||
} as never),
|
||||
).toEqual({
|
||||
levels: [{ id: "low", label: "on" }],
|
||||
defaultLevel: "low",
|
||||
preserveWhenCatalogReasoningFalse: true,
|
||||
});
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider.isModernModelRef?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Moonshot plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { MOONSHOT_THINKING_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
|
||||
import { applyMoonshotNativeStreamingUsageCompat } from "./api.js";
|
||||
import { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
MOONSHOT_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
import { buildMoonshotProvider } from "./provider-catalog.js";
|
||||
import { KIMI_K2_7_CODE_MODEL_ID, resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
const PROVIDER_ID = "moonshot";
|
||||
const moonshotThinkingStreamHooks = MOONSHOT_THINKING_STREAM_HOOKS;
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
@@ -59,22 +61,20 @@ export default defineSingleProviderPluginEntry({
|
||||
},
|
||||
applyNativeStreamingUsageCompat: ({ providerConfig }) =>
|
||||
applyMoonshotNativeStreamingUsageCompat(providerConfig),
|
||||
// Kimi K2+ returns native tool_call IDs shaped like `functions.<name>:<index>`.
|
||||
// Sanitizing them to alphanumeric-only breaks Kimi's serving-layer matching in
|
||||
// multi-turn replay. See openclaw/openclaw#62319.
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
sanitizeToolCallIds: false,
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
...MOONSHOT_THINKING_STREAM_HOOKS,
|
||||
resolveThinkingProfile: () => ({
|
||||
levels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "on" },
|
||||
],
|
||||
defaultLevel: "off",
|
||||
}),
|
||||
buildReplayPolicy: ({ modelApi, modelId }) =>
|
||||
buildOpenAICompatibleReplayPolicy(modelApi, {
|
||||
modelId,
|
||||
sanitizeToolCallIds: modelApi === "openai-completions",
|
||||
duplicateToolCallIdStyle: "openai",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
...moonshotThinkingStreamHooks,
|
||||
wrapSimpleCompletionStreamFn: (ctx) =>
|
||||
ctx.modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID
|
||||
? moonshotThinkingStreamHooks.wrapStreamFn?.(ctx)
|
||||
: ctx.streamFn,
|
||||
resolveThinkingProfile,
|
||||
isModernModelRef: ({ modelId }) => modelId.trim().toLowerCase() === KIMI_K2_7_CODE_MODEL_ID,
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(moonshotMediaUnderstandingProvider);
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
// Moonshot tests cover moonshot plugin behavior.
|
||||
import {
|
||||
streamSimple,
|
||||
type AssistantMessage,
|
||||
type Context,
|
||||
type Model,
|
||||
type Tool,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { buildMoonshotProvider, MOONSHOT_CN_BASE_URL } from "./provider-catalog.js";
|
||||
import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
|
||||
const KIMI_SEARCH_KEY =
|
||||
process.env.KIMI_API_KEY?.trim() || process.env.MOONSHOT_API_KEY?.trim() || "";
|
||||
const MOONSHOT_API_KEY = process.env.MOONSHOT_API_KEY?.trim() || "";
|
||||
const describeLive = isLiveTestEnabled() && KIMI_SEARCH_KEY.length > 0 ? describe : describe.skip;
|
||||
const describeModelLive =
|
||||
isLiveTestEnabled() && MOONSHOT_API_KEY.length > 0 ? describe : describe.skip;
|
||||
const KIMI_LIVE_SEARCH_TIMEOUT_SECONDS = 60;
|
||||
|
||||
function isTransientKimiSearchError(error: unknown): boolean {
|
||||
@@ -19,17 +33,31 @@ function isTransientKimiSearchError(error: unknown): boolean {
|
||||
return message.includes("timeout") || message.includes("aborted");
|
||||
}
|
||||
|
||||
function isKimiAuthDrift(error: unknown): boolean {
|
||||
function isMoonshotAuthDrift(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
const message = error.message.toLowerCase();
|
||||
return (
|
||||
message.includes("kimi api error (401)") &&
|
||||
(message.includes("incorrect api key") || message.includes("incorrect_api_key"))
|
||||
message.includes("401") &&
|
||||
(message.includes("incorrect api key") ||
|
||||
message.includes("incorrect_api_key") ||
|
||||
message.includes("invalid authentication") ||
|
||||
message.includes("invalid_authentication_error"))
|
||||
);
|
||||
}
|
||||
|
||||
describe("moonshot live auth drift detection", () => {
|
||||
it.each([
|
||||
["401 Incorrect API key provided", true],
|
||||
["401 invalid_authentication_error: Invalid Authentication", true],
|
||||
["401 Permission denied", false],
|
||||
["400 Incorrect API key provided", false],
|
||||
])("classifies %s", (message, expected) => {
|
||||
expect(isMoonshotAuthDrift(new Error(message))).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describeLive("moonshot plugin live", () => {
|
||||
it("runs Kimi web search through the provider tool", async () => {
|
||||
const provider = createKimiWebSearchProvider();
|
||||
@@ -51,7 +79,7 @@ describeLive("moonshot plugin live", () => {
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isKimiAuthDrift(error)) {
|
||||
if (isMoonshotAuthDrift(error)) {
|
||||
console.warn("[moonshot:live] skip Kimi web search: auth drift");
|
||||
return;
|
||||
}
|
||||
@@ -71,6 +99,256 @@ describeLive("moonshot plugin live", () => {
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
function resolveMoonshotModels(modelId: string): Model<"openai-completions">[] {
|
||||
const provider = buildMoonshotProvider();
|
||||
const model = provider.models.find((entry) => entry.id === modelId);
|
||||
if (!model) {
|
||||
throw new Error(`Moonshot catalog does not include ${modelId}`);
|
||||
}
|
||||
const defaultModel = {
|
||||
provider: "moonshot",
|
||||
baseUrl: provider.baseUrl,
|
||||
...model,
|
||||
api: "openai-completions",
|
||||
} as Model<"openai-completions">;
|
||||
return [defaultModel, { ...defaultModel, baseUrl: MOONSHOT_CN_BASE_URL }];
|
||||
}
|
||||
|
||||
function createNoopTool(): Tool {
|
||||
return {
|
||||
name: "noop",
|
||||
description: "Return ok.",
|
||||
parameters: Type.Object({}, { additionalProperties: false }),
|
||||
};
|
||||
}
|
||||
|
||||
async function collectDoneMessage(
|
||||
stream: AsyncIterable<{ type: string; message?: AssistantMessage; error?: AssistantMessage }>,
|
||||
): Promise<AssistantMessage> {
|
||||
let doneMessage: AssistantMessage | undefined;
|
||||
for await (const event of stream) {
|
||||
if (event.type === "error") {
|
||||
throw new Error(event.error?.errorMessage || "Moonshot live request failed");
|
||||
}
|
||||
if (event.type === "done") {
|
||||
doneMessage = event.message;
|
||||
}
|
||||
}
|
||||
if (!doneMessage) {
|
||||
throw new Error("Moonshot live stream ended without a done message");
|
||||
}
|
||||
return doneMessage;
|
||||
}
|
||||
|
||||
describeModelLive("moonshot K2.6 replay live", () => {
|
||||
it("accepts a cross-model tool-call replay after backfilling reasoning_content", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const wrappedStream = provider.wrapStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.6",
|
||||
thinkingLevel: "low",
|
||||
streamFn: streamSimple,
|
||||
} as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("Moonshot provider did not register a stream wrapper");
|
||||
}
|
||||
|
||||
const tool = createNoopTool();
|
||||
const replayContext: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call the noop tool.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
stopReason: "toolUse",
|
||||
content: [{ type: "toolCall", id: "call_cross_model", name: "noop", arguments: {} }],
|
||||
timestamp: Date.now(),
|
||||
} as AssistantMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_cross_model",
|
||||
toolName: "noop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "The tool returned ok. Reply with exactly: ok",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [tool],
|
||||
};
|
||||
|
||||
const runScenario = async (model: Model<"openai-completions">) => {
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
const response = await collectDoneMessage(
|
||||
wrappedStream(model, replayContext, {
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 256,
|
||||
onPayload: (value) => {
|
||||
payload = value as Record<string, unknown>;
|
||||
},
|
||||
}) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
const messages = payload?.messages as Array<Record<string, unknown>> | undefined;
|
||||
const replayedAssistant = messages?.find(
|
||||
(message) => message.role === "assistant" && Array.isArray(message.tool_calls),
|
||||
);
|
||||
expect(replayedAssistant?.reasoning_content).toBe("");
|
||||
expect(response.stopReason).not.toBe("error");
|
||||
};
|
||||
|
||||
let lastAuthError: unknown;
|
||||
for (const model of resolveMoonshotModels("kimi-k2.6")) {
|
||||
try {
|
||||
await runScenario(model);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMoonshotAuthDrift(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastAuthError = error;
|
||||
}
|
||||
}
|
||||
throw toLintErrorObject(lastAuthError, "Moonshot K2.6 rejected the API key in both regions");
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
describeModelLive("moonshot K2.7 Code live", () => {
|
||||
it("omits thinking controls and completes a replayed tool turn", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const wrappedStream = provider.wrapStreamFn?.({
|
||||
provider: "moonshot",
|
||||
modelId: "kimi-k2.7-code",
|
||||
thinkingLevel: "off",
|
||||
extraParams: { thinking: { type: "disabled", keep: "all" } },
|
||||
streamFn: streamSimple,
|
||||
} as never);
|
||||
if (!wrappedStream) {
|
||||
throw new Error("Moonshot provider did not register a stream wrapper");
|
||||
}
|
||||
|
||||
const tool = createNoopTool();
|
||||
const firstUser = {
|
||||
role: "user" as const,
|
||||
content: "Call the noop tool with {}. Do not answer directly.",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const runScenario = async (model: Model<"openai-completions">) => {
|
||||
let firstPayload: Record<string, unknown> | undefined;
|
||||
const first = await collectDoneMessage(
|
||||
wrappedStream(
|
||||
model,
|
||||
{ messages: [firstUser], tools: [tool] },
|
||||
{
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 16_000,
|
||||
temperature: 0,
|
||||
onPayload: (payload) => {
|
||||
firstPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
},
|
||||
) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
expect(firstPayload).toBeDefined();
|
||||
expect(firstPayload).not.toHaveProperty("thinking");
|
||||
expect(firstPayload).not.toHaveProperty("reasoning_effort");
|
||||
expect(firstPayload).not.toHaveProperty("temperature");
|
||||
const reasoning = first.content.find((block) => block.type === "thinking");
|
||||
if (!reasoning || reasoning.type !== "thinking" || reasoning.thinking.length === 0) {
|
||||
throw new Error("Moonshot K2.7 Code did not return captured reasoning");
|
||||
}
|
||||
const toolCall = first.content.find((block) => block.type === "toolCall");
|
||||
if (!toolCall || toolCall.type !== "toolCall") {
|
||||
throw new Error(`Moonshot K2.7 Code did not call noop: ${first.stopReason}`);
|
||||
}
|
||||
expect(toolCall.name).toBe("noop");
|
||||
|
||||
let secondPayload: Record<string, unknown> | undefined;
|
||||
const replayContext: Context = {
|
||||
messages: [
|
||||
firstUser,
|
||||
first,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with exactly: ok",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
tools: [tool],
|
||||
};
|
||||
const second = await collectDoneMessage(
|
||||
wrappedStream(model, replayContext, {
|
||||
apiKey: MOONSHOT_API_KEY,
|
||||
maxTokens: 16_000,
|
||||
temperature: 0,
|
||||
onPayload: (payload) => {
|
||||
secondPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
}) as AsyncIterable<{
|
||||
type: string;
|
||||
message?: AssistantMessage;
|
||||
error?: AssistantMessage;
|
||||
}>,
|
||||
);
|
||||
|
||||
expect(secondPayload).toBeDefined();
|
||||
expect(secondPayload).not.toHaveProperty("thinking");
|
||||
expect(secondPayload).not.toHaveProperty("reasoning_effort");
|
||||
expect(secondPayload).not.toHaveProperty("temperature");
|
||||
const text = second.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
expect(text).toMatch(/^ok[.!]?$/i);
|
||||
};
|
||||
|
||||
let lastAuthError: unknown;
|
||||
for (const model of resolveMoonshotModels("kimi-k2.7-code")) {
|
||||
try {
|
||||
await runScenario(model);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMoonshotAuthDrift(error)) {
|
||||
throw error;
|
||||
}
|
||||
lastAuthError = error;
|
||||
}
|
||||
}
|
||||
throw toLintErrorObject(
|
||||
lastAuthError,
|
||||
"Moonshot K2.7 Code rejected the API key in both regions",
|
||||
);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
|
||||
@@ -63,6 +63,20 @@
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.7-code",
|
||||
"name": "Kimi K2.7 Code",
|
||||
"reasoning": true,
|
||||
"input": ["text", "image"],
|
||||
"contextWindow": 262144,
|
||||
"maxTokens": 262144,
|
||||
"cost": {
|
||||
"input": 0.95,
|
||||
"output": 4,
|
||||
"cacheRead": 0.19,
|
||||
"cacheWrite": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "kimi-k2.5",
|
||||
"name": "Kimi K2.5",
|
||||
|
||||
@@ -41,6 +41,7 @@ describe("moonshot provider catalog", () => {
|
||||
expect(provider.api).toBe("openai-completions");
|
||||
expect(provider.models.map((model) => model.id)).toEqual([
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-thinking-turbo",
|
||||
@@ -52,6 +53,18 @@ describe("moonshot provider catalog", () => {
|
||||
cacheRead: 0.16,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
expect(requireMoonshotModel(provider, "kimi-k2.7-code")).toMatchObject({
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
cost: {
|
||||
input: 0.95,
|
||||
output: 4,
|
||||
cacheRead: 0.19,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
});
|
||||
expect(requireMoonshotModel(provider, "kimi-k2.5").cost).toEqual({
|
||||
input: 0.6,
|
||||
output: 3,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user