mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 20:32:25 +08:00
Compare commits
1 Commits
codex/prob
...
codex/code
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d1f6e971 |
6
.github/CODEOWNERS
vendored
6
.github/CODEOWNERS
vendored
@@ -11,10 +11,8 @@
|
||||
/.github/workflows/codeql.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-guard.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-workflow.test.ts @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-guard-script.test.ts @openclaw/openclaw-secops
|
||||
/scripts/github/dependency-guard.mjs @openclaw/openclaw-secops
|
||||
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
|
||||
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
|
||||
/package-lock.json @openclaw/openclaw-secops
|
||||
/npm-shrinkwrap.json @openclaw/openclaw-secops
|
||||
/extensions/*/package-lock.json @openclaw/openclaw-secops
|
||||
|
||||
24
.github/actions/detect-docs-changes/action.yml
vendored
24
.github/actions/detect-docs-changes/action.yml
vendored
@@ -35,29 +35,17 @@ runs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
docs_changed=false
|
||||
non_docs=false
|
||||
while IFS= read -r changed_path; do
|
||||
case "$changed_path" in
|
||||
test/fixtures/*)
|
||||
non_docs=true
|
||||
;;
|
||||
docs/* | *.md | *.mdx)
|
||||
docs_changed=true
|
||||
;;
|
||||
*)
|
||||
non_docs=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ "$docs_changed" = "true" ]; then
|
||||
# Check if any changed file is a doc
|
||||
DOCS=$(echo "$CHANGED" | grep -E '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -n "$DOCS" ]; then
|
||||
echo "docs_changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "docs_changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
if [ "$non_docs" = "false" ]; then
|
||||
# Check if all changed files are docs or markdown
|
||||
NON_DOCS=$(echo "$CHANGED" | grep -vE '^docs/|\.md$|\.mdx$' || true)
|
||||
if [ -z "$NON_DOCS" ]; then
|
||||
echo "docs_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Docs-only change detected — skipping heavy jobs"
|
||||
else
|
||||
|
||||
97
.github/workflows/ci.yml
vendored
97
.github/workflows/ci.yml
vendored
@@ -1035,7 +1035,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1141,7 +1141,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight, pnpm-store-warmup]
|
||||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' && needs.pnpm-store-warmup.result == 'success' }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1494,44 +1494,11 @@ jobs:
|
||||
- name: Checkout ClawHub docs source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE/clawhub-source"
|
||||
started_at="$(date +%s)"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
|
||||
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
elapsed="$(( $(date +%s) - started_at ))"
|
||||
echo "ClawHub checkout completed in ${elapsed}s"
|
||||
exit 0
|
||||
fi
|
||||
echo "ClawHub checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "ClawHub checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
git init clawhub-source
|
||||
git -C clawhub-source config gc.auto 0
|
||||
git -C clawhub-source remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
git -C clawhub-source fetch --no-tags --depth=1 origin "+HEAD:refs/remotes/origin/checkout"
|
||||
git -C clawhub-source checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Check docs
|
||||
env:
|
||||
@@ -1960,53 +1927,3 @@ jobs:
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
ci-timings-summary:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
name: ci-timings-summary
|
||||
needs:
|
||||
- preflight
|
||||
- security-fast
|
||||
- pnpm-store-warmup
|
||||
- build-artifacts
|
||||
- checks-fast-core
|
||||
- checks-fast-plugin-contracts-shard
|
||||
- checks-fast-channel-contracts-shard
|
||||
- checks-node-compat
|
||||
- checks-node-core-test-nondist-shard
|
||||
- check-shard
|
||||
- check-additional-shard
|
||||
- check-docs
|
||||
- skills-python
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout timing summary helper
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Write CI timing summary
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
|
||||
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload CI timing summary
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ci-timings-summary
|
||||
path: ci-timings-summary.txt
|
||||
retention-days: 14
|
||||
|
||||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
@@ -85,21 +85,10 @@ jobs:
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ matrix.category != 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout Actions security sources
|
||||
if: ${{ matrix.category == 'actions' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
sparse-checkout: |
|
||||
.github/actions
|
||||
.github/workflows
|
||||
.github/codeql
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
|
||||
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
176
.github/workflows/dependency-change-awareness.yml
vendored
Normal file
@@ -0,0 +1,176 @@
|
||||
name: Dependency Change Awareness
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-change-awareness-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-change-awareness:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Label and comment on dependency changes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const marker = "<!-- openclaw:dependency-change-awareness -->";
|
||||
const labelName = "dependencies-changed";
|
||||
const maxListedFiles = 25;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
|
||||
if (!pullRequest) {
|
||||
core.info("No pull_request payload found; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
const isDependencyFile = (filename) =>
|
||||
filename === "package.json" ||
|
||||
filename === "package-lock.json" ||
|
||||
filename === "npm-shrinkwrap.json" ||
|
||||
filename === "pnpm-lock.yaml" ||
|
||||
filename === "pnpm-workspace.yaml" ||
|
||||
filename === "ui/package.json" ||
|
||||
filename.startsWith("patches/") ||
|
||||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package-lock\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/npm-shrinkwrap\.json$/u.test(filename) ||
|
||||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
|
||||
|
||||
const sanitizeDisplayValue = (value) =>
|
||||
String(value)
|
||||
.replace(/[\u0000-\u001f\u007f]/gu, "?")
|
||||
.slice(0, 240);
|
||||
const markdownCode = (value) =>
|
||||
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
|
||||
const ignoreUnavailableWritePermission = (action) => (error) => {
|
||||
if (error?.status === 403) {
|
||||
core.warning(
|
||||
`Skipping dependency change ${action}; token does not have issue write permission.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error?.status === 404 || error?.status === 422) {
|
||||
core.warning(`Dependency change ${action} is unavailable.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
};
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const dependencyFiles = files
|
||||
.map((file) => file.filename)
|
||||
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existingComment = comments.find(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const hasLabel = labels.some((label) => label.name === labelName);
|
||||
|
||||
if (dependencyFiles.length === 0) {
|
||||
if (hasLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
name: labelName,
|
||||
}).catch(ignoreUnavailableWritePermission("label removal"));
|
||||
}
|
||||
if (existingComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
}).catch(ignoreUnavailableWritePermission("comment deletion"));
|
||||
}
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw("No dependency-related file changes detected.")
|
||||
.write();
|
||||
core.info("No dependency-related file changes detected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
labels: [labelName],
|
||||
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
|
||||
}
|
||||
|
||||
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
|
||||
const omittedCount = dependencyFiles.length - listedFiles.length;
|
||||
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
|
||||
if (omittedCount > 0) {
|
||||
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
"",
|
||||
"### Dependency Changes Detected",
|
||||
"",
|
||||
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
|
||||
"",
|
||||
"Changed files:",
|
||||
...fileLines,
|
||||
"",
|
||||
"Maintainer follow-up:",
|
||||
"- Review whether the dependency changes are intentional.",
|
||||
"- Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.",
|
||||
"- Treat `package-lock.json` and `npm-shrinkwrap.json` diffs as security-review surfaces.",
|
||||
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
|
||||
].join("\n");
|
||||
|
||||
if (existingComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existingComment.id,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment update"));
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body,
|
||||
}).catch(ignoreUnavailableWritePermission("comment creation"));
|
||||
}
|
||||
|
||||
await core.summary
|
||||
.addHeading("Dependency Change Awareness")
|
||||
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
|
||||
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
|
||||
.write();
|
||||
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);
|
||||
33
.github/workflows/dependency-guard.yml
vendored
33
.github/workflows/dependency-guard.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Dependency Guard
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] checks trusted base script only; never checks out PR head
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: dependency-guard-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-guard:
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted base workflow scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Label, comment, and guard dependency changes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
OPENCLAW_SECURITY_APPROVERS: vincentkoc,steipete,joshavant
|
||||
OPENCLAW_SECURITY_TEAM_SLUG: openclaw-secops
|
||||
run: node scripts/github/dependency-guard.mjs
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
@@ -337,7 +337,7 @@ jobs:
|
||||
for (const [dep, rel] of Object.entries(workspace.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(\"missing patch for \" + dep + \": \" + rel);
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
|
||||
1
.github/workflows/mantis-telegram-live.yml
vendored
1
.github/workflows/mantis-telegram-live.yml
vendored
@@ -377,7 +377,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
CRABBOX_COORDINATOR: ${{ secrets.CRABBOX_COORDINATOR }}
|
||||
|
||||
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
1
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -218,7 +218,6 @@ jobs:
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
|
||||
@@ -451,7 +451,7 @@ jobs:
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/baseline
|
||||
run: |
|
||||
mkdir -p "${OUTPUT_DIR}"
|
||||
timeout --preserve-status 300s npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
npm pack --ignore-scripts --json "${BASELINE_SPEC}" --pack-destination "${OUTPUT_DIR}" > "${OUTPUT_DIR}/pack.json"
|
||||
|
||||
- name: Capture candidate metadata
|
||||
id: candidate_metadata
|
||||
|
||||
@@ -1959,7 +1959,7 @@ jobs:
|
||||
profiles: stable full
|
||||
- suite_id: native-live-src-gateway-profiles-openai
|
||||
label: Native live gateway profiles OpenAI
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.5 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
|
||||
timeout_minutes: 60
|
||||
profile_env_only: false
|
||||
profiles: beta minimum stable full
|
||||
|
||||
@@ -1207,7 +1207,6 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
run: |
|
||||
|
||||
6
.github/workflows/opengrep-precise-full.yml
vendored
6
.github/workflows/opengrep-precise-full.yml
vendored
@@ -32,11 +32,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
8
.github/workflows/opengrep-precise.yml
vendored
8
.github/workflows/opengrep-precise.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
fetch-depth: 0
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
- name: Install opengrep
|
||||
env:
|
||||
# Pin both the install script (by commit SHA) and the binary version.
|
||||
# The script SHA must match the v1.22.0 release tag in opengrep/opengrep
|
||||
# The script SHA must match the v1.19.0 release tag in opengrep/opengrep
|
||||
# so a compromised or force-pushed `main` cannot RCE in our CI runner.
|
||||
# Bump both together when upgrading.
|
||||
OPENGREP_VERSION: v1.22.0
|
||||
OPENGREP_INSTALL_SHA: f458d7f0d52cc58eae1ca3cf3d5caf101e637519
|
||||
OPENGREP_VERSION: v1.19.0
|
||||
OPENGREP_INSTALL_SHA: 9a4c0a68220618441608cd2bad4ff2eddccf8113
|
||||
run: |
|
||||
curl -fsSL "https://raw.githubusercontent.com/opengrep/opengrep/${OPENGREP_INSTALL_SHA}/install.sh" \
|
||||
| bash -s -- -v "$OPENGREP_VERSION"
|
||||
|
||||
@@ -530,7 +530,6 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "1800000"
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ github.event_name == 'workflow_dispatch' && inputs.scenario || '' }}
|
||||
|
||||
@@ -24,7 +24,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents: fall back to local config pruning when the optional `agents delete` Gateway probe cannot authenticate, so offline installs can still delete agents without removing shared workspaces.
|
||||
- Tighten phone-control mutation authorization [AI]. (#87150) Thanks @pgondhi987.
|
||||
- Clarify directive persistence authorization policy [AI]. (#86369) Thanks @pgondhi987.
|
||||
- Agents/Codex: keep spawned agent cwd/workspace state separated, keep hook context prompt-local, release session locks on timeout abort, avoid session event queue self-wait, preserve shared app-server state across startup or helper failures, keep native hook relay alive across restarts, route workspace memory through tools, resolve Codex runtime models first, report quarantined dynamic tools, format `skills` command output, and bound compaction/steering retries. (#87218, #86875, #86123, #87399, #87375, #87383, #87400) Thanks @mbelinky, @Alix-007, @luoyanglang, @yetval, and @sjf.
|
||||
|
||||
@@ -48,7 +48,6 @@ RUN --mount=type=bind,source=packages,target=/tmp/packages,readonly \
|
||||
FROM ${OPENCLAW_BUN_IMAGE} AS bun-binary
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
|
||||
# Copy pinned Bun binary from the official image instead of fetching via curl.
|
||||
COPY --from=bun-binary /usr/local/bin/bun /usr/local/bin/bun
|
||||
@@ -78,12 +77,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
|
||||
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
|
||||
# paths. Matrix's native downloader can hit transient release CDN errors while
|
||||
# still exiting successfully, so retry the package downloader before failing.
|
||||
# Skip the entire check when matrix is not a bundled extension (e.g. msteams-only builds).
|
||||
RUN set -eux; \
|
||||
if ! printf '%s\n' "$OPENCLAW_EXTENSIONS" | tr ',' ' ' | tr ' ' '\n' | grep -qx 'matrix'; then \
|
||||
echo "==> matrix not bundled, skipping matrix-sdk-crypto check"; \
|
||||
exit 0; \
|
||||
fi; \
|
||||
echo "==> Verifying critical native addons..."; \
|
||||
for attempt in 1 2 3 4 5; do \
|
||||
if find /app/node_modules -name "matrix-sdk-crypto*.node" 2>/dev/null | grep -q .; then \
|
||||
|
||||
@@ -170,8 +170,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var voiceWakeMeterActive = false
|
||||
|
||||
var talkEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
|
||||
@@ -63,14 +63,6 @@ extension CritterStatusLabel {
|
||||
.frame(width: 6, height: 6)
|
||||
.padding(1)
|
||||
}
|
||||
|
||||
if self.voiceWakeMeterActive {
|
||||
Circle()
|
||||
.fill(.orange)
|
||||
.frame(width: 5, height: 5)
|
||||
.padding(2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
@@ -247,8 +239,7 @@ extension CritterStatusLabel {
|
||||
sendCelebrationTick: 1,
|
||||
gatewayStatus: .running(details: nil),
|
||||
animationsEnabled: true,
|
||||
iconState: .workingMain(.tool(.bash)),
|
||||
voiceWakeMeterActive: true)
|
||||
iconState: .workingMain(.tool(.bash)))
|
||||
|
||||
_ = label.body
|
||||
_ = label.iconImage
|
||||
@@ -284,8 +275,7 @@ extension CritterStatusLabel {
|
||||
sendCelebrationTick: 0,
|
||||
gatewayStatus: .failed("boom"),
|
||||
animationsEnabled: false,
|
||||
iconState: .idle,
|
||||
voiceWakeMeterActive: false)
|
||||
iconState: .idle)
|
||||
_ = failed.gatewayNeedsAttention
|
||||
_ = failed.gatewayBadgeColor
|
||||
|
||||
@@ -298,8 +288,7 @@ extension CritterStatusLabel {
|
||||
sendCelebrationTick: 0,
|
||||
gatewayStatus: .stopped,
|
||||
animationsEnabled: false,
|
||||
iconState: .idle,
|
||||
voiceWakeMeterActive: false)
|
||||
iconState: .idle)
|
||||
_ = stopped.gatewayNeedsAttention
|
||||
_ = stopped.gatewayBadgeColor
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ struct CritterStatusLabel: View {
|
||||
var gatewayStatus: GatewayProcessManager.Status
|
||||
var animationsEnabled: Bool
|
||||
var iconState: IconState
|
||||
var voiceWakeMeterActive: Bool = false
|
||||
|
||||
@State var blinkAmount: CGFloat = 0
|
||||
@State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||
|
||||
@@ -50,8 +50,7 @@ struct OpenClawApp: App {
|
||||
sendCelebrationTick: self.state.sendCelebrationTick,
|
||||
gatewayStatus: self.gatewayManager.status,
|
||||
animationsEnabled: self.state.iconAnimationsEnabled && !self.isGatewaySleeping,
|
||||
iconState: self.effectiveIconState,
|
||||
voiceWakeMeterActive: self.state.voiceWakeMeterActive)
|
||||
iconState: self.effectiveIconState)
|
||||
.background(SettingsWindowOpenRegistrar())
|
||||
}
|
||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||
@@ -76,9 +75,6 @@ struct OpenClawApp: App {
|
||||
.onChange(of: self.gatewayManager.status) { _, _ in
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
}
|
||||
.onChange(of: self.state.voiceWakeMeterActive) { _, _ in
|
||||
self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping)
|
||||
}
|
||||
.onChange(of: self.state.connectionMode) { _, mode in
|
||||
Task { await ConnectionModeCoordinator.shared.apply(mode: mode, paused: self.state.isPaused) }
|
||||
CLIInstallPrompter.shared.checkAndPromptIfNeeded(reason: "connection-mode")
|
||||
@@ -111,9 +107,6 @@ struct OpenClawApp: App {
|
||||
// The SwiftUI label already renders those states; AppKit's disabled appearance can
|
||||
// leak into menu item validation and grey out app-level commands like Settings.
|
||||
self.statusItem?.button?.appearsDisabled = false
|
||||
self.statusItem?.button?.toolTip = self.state.voiceWakeMeterActive
|
||||
? "OpenClaw - Voice Wake live meter active"
|
||||
: "OpenClaw"
|
||||
}
|
||||
|
||||
private static func applyAttachOnlyOverrideIfNeeded() {
|
||||
|
||||
@@ -8,18 +8,12 @@ actor MicLevelMonitor {
|
||||
private var update: (@Sendable (Double) -> Void)?
|
||||
private var running = false
|
||||
private var smoothedLevel: Double = 0
|
||||
private var lastUpdate = ContinuousClock.now
|
||||
private var lastPublishedLevel: Double = 0
|
||||
private let minimumUpdateInterval: Duration = .milliseconds(125)
|
||||
private let minimumLevelDelta = 0.02
|
||||
|
||||
func start(onLevel: @Sendable @escaping (Double) -> Void) async throws {
|
||||
self.update = onLevel
|
||||
if self.running { return }
|
||||
self.logger.info(
|
||||
"mic level monitor start (\(AudioInputDeviceObserver.defaultInputDeviceSummary(), privacy: .public))")
|
||||
self.lastUpdate = .now
|
||||
self.lastPublishedLevel = self.smoothedLevel
|
||||
guard AudioInputDeviceObserver.hasUsableDefaultInputDevice() else {
|
||||
self.engine = nil
|
||||
throw NSError(
|
||||
@@ -62,13 +56,7 @@ actor MicLevelMonitor {
|
||||
private func push(level: Double) {
|
||||
self.smoothedLevel = (self.smoothedLevel * 0.45) + (level * 0.55)
|
||||
guard let update else { return }
|
||||
let now = ContinuousClock.now
|
||||
guard now - self.lastUpdate >= self.minimumUpdateInterval ||
|
||||
abs(self.smoothedLevel - self.lastPublishedLevel) >= self.minimumLevelDelta
|
||||
else { return }
|
||||
self.lastUpdate = now
|
||||
let value = self.smoothedLevel
|
||||
self.lastPublishedLevel = value
|
||||
Task { @MainActor in update(value) }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import Foundation
|
||||
|
||||
enum SoundEffectCatalog {
|
||||
/// All discoverable system sound names, with "Glass" pinned first.
|
||||
static let systemOptions: [String] = {
|
||||
static var systemOptions: [String] {
|
||||
var names = Set(Self.discoveredSoundMap.keys).union(Self.fallbackNames)
|
||||
names.remove("Glass")
|
||||
let sorted = names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
return ["Glass"] + sorted
|
||||
}()
|
||||
}
|
||||
|
||||
static func displayName(for raw: String) -> String {
|
||||
raw
|
||||
|
||||
@@ -20,7 +20,6 @@ struct VoiceWakeSettings: View {
|
||||
private let meter = MicLevelMonitor()
|
||||
@State private var micObserver = AudioInputDeviceObserver()
|
||||
@State private var micRefreshTask: Task<Void, Never>?
|
||||
@State private var meterStartupTask: Task<Void, Never>?
|
||||
@State private var availableLocales: [Locale] = []
|
||||
@State private var triggerEntries: [TriggerEntry] = []
|
||||
private let fieldLabelWidth: CGFloat = 140
|
||||
@@ -189,68 +188,59 @@ struct VoiceWakeSettings: View {
|
||||
}
|
||||
.settingsDetailContent()
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.loadMicsIfNeeded()
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.loadLocalesIfNeeded()
|
||||
}
|
||||
.task {
|
||||
guard !self.isPreview else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
.onAppear {
|
||||
guard !self.isPreview else { return }
|
||||
guard self.isActive else { return }
|
||||
self.activateLivePreview()
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
}
|
||||
.onChange(of: self.state.voiceWakeMicID) { _, _ in
|
||||
guard !self.isPreview else { return }
|
||||
self.updateSelectedMicName()
|
||||
guard self.isActive else { return }
|
||||
self.scheduleMeterRestart()
|
||||
Task { await self.restartMeter() }
|
||||
}
|
||||
.onChange(of: self.isActive) { _, active in
|
||||
guard !self.isPreview else { return }
|
||||
if !active {
|
||||
self.deactivateLivePreview()
|
||||
self.tester.stop()
|
||||
self.isTesting = false
|
||||
self.testState = .idle
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = nil
|
||||
Task { await self.meter.stop() }
|
||||
self.micObserver.stop()
|
||||
self.syncTriggerEntriesToState()
|
||||
} else {
|
||||
self.activateLivePreview()
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
guard !self.isPreview else { return }
|
||||
self.deactivateLivePreview()
|
||||
self.tester.stop()
|
||||
self.isTesting = false
|
||||
self.testState = .idle
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = nil
|
||||
self.micObserver.stop()
|
||||
Task { await self.meter.stop() }
|
||||
self.syncTriggerEntriesToState()
|
||||
}
|
||||
}
|
||||
|
||||
private func activateLivePreview() {
|
||||
self.meterStartupTask?.cancel()
|
||||
self.startMicObserver()
|
||||
self.loadTriggerEntries()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
await self.loadMicsIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.loadLocalesIfNeeded()
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
}
|
||||
|
||||
private func deactivateLivePreview() {
|
||||
self.tester.stop()
|
||||
self.isTesting = false
|
||||
self.testState = .idle
|
||||
self.testTimeoutTask?.cancel()
|
||||
self.micRefreshTask?.cancel()
|
||||
self.micRefreshTask = nil
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = nil
|
||||
self.micObserver.stop()
|
||||
self.state.voiceWakeMeterActive = false
|
||||
Task { await self.meter.stop() }
|
||||
}
|
||||
|
||||
private func scheduleMeterRestart() {
|
||||
self.meterStartupTask?.cancel()
|
||||
self.meterStartupTask = Task { @MainActor in
|
||||
guard !Task.isCancelled, self.isActive else { return }
|
||||
await self.restartMeter()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadTriggerEntries() {
|
||||
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
|
||||
}
|
||||
@@ -662,7 +652,6 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func scheduleMicRefresh() {
|
||||
guard self.isActive else { return }
|
||||
MicRefreshSupport.schedule(refreshTask: &self.micRefreshTask) {
|
||||
await self.loadMicsIfNeeded(force: true)
|
||||
await self.restartMeter()
|
||||
@@ -724,17 +713,8 @@ struct VoiceWakeSettings: View {
|
||||
|
||||
@MainActor
|
||||
private func restartMeter() async {
|
||||
guard self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
self.meterError = nil
|
||||
await self.meter.stop()
|
||||
guard !Task.isCancelled, self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
return
|
||||
}
|
||||
do {
|
||||
try await self.meter.start { [weak state] level in
|
||||
Task { @MainActor in
|
||||
@@ -742,14 +722,7 @@ struct VoiceWakeSettings: View {
|
||||
self.meterLevel = level
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled, self.isActive else {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
await self.meter.stop()
|
||||
return
|
||||
}
|
||||
self.state.voiceWakeMeterActive = true
|
||||
} catch {
|
||||
self.state.voiceWakeMeterActive = false
|
||||
self.meterError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2379,7 +2379,6 @@ public struct SessionsCompactParams: Codable, Sendable {
|
||||
public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let agentid: String?
|
||||
public let agentscope: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let mode: AnyCodable?
|
||||
@@ -2393,7 +2392,6 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
public init(
|
||||
key: String?,
|
||||
agentid: String? = nil,
|
||||
agentscope: String? = nil,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
mode: AnyCodable?,
|
||||
@@ -2406,7 +2404,6 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
{
|
||||
self.key = key
|
||||
self.agentid = agentid
|
||||
self.agentscope = agentscope
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.mode = mode
|
||||
@@ -2421,7 +2418,6 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case agentid = "agentId"
|
||||
case agentscope = "agentScope"
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case mode
|
||||
@@ -5462,8 +5458,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
public let offset: Int?
|
||||
public let query: String?
|
||||
public let enabled: AnyCodable?
|
||||
public let schedulekind: AnyCodable?
|
||||
public let lastrunstatus: AnyCodable?
|
||||
public let sortby: AnyCodable?
|
||||
public let sortdir: AnyCodable?
|
||||
public let agentid: String?
|
||||
@@ -5474,8 +5468,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
offset: Int?,
|
||||
query: String?,
|
||||
enabled: AnyCodable?,
|
||||
schedulekind: AnyCodable?,
|
||||
lastrunstatus: AnyCodable?,
|
||||
sortby: AnyCodable?,
|
||||
sortdir: AnyCodable?,
|
||||
agentid: String?)
|
||||
@@ -5485,8 +5477,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
self.offset = offset
|
||||
self.query = query
|
||||
self.enabled = enabled
|
||||
self.schedulekind = schedulekind
|
||||
self.lastrunstatus = lastrunstatus
|
||||
self.sortby = sortby
|
||||
self.sortdir = sortdir
|
||||
self.agentid = agentid
|
||||
@@ -5498,8 +5488,6 @@ public struct CronListParams: Codable, Sendable {
|
||||
case offset
|
||||
case query
|
||||
case enabled
|
||||
case schedulekind = "scheduleKind"
|
||||
case lastrunstatus = "lastRunStatus"
|
||||
case sortby = "sortBy"
|
||||
case sortdir = "sortDir"
|
||||
case agentid = "agentId"
|
||||
|
||||
@@ -63,7 +63,6 @@ services:
|
||||
ports:
|
||||
- "${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
- "${OPENCLAW_BRIDGE_PORT:-18790}:18790"
|
||||
- "${OPENCLAW_MSTEAMS_PORT:-3978}:3978"
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
command:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
c61b32fda64ee6cd4d4aa5ed6950c4c681a585d49bf5c127b92e562608a0a303 config-baseline.json
|
||||
a69acd971a7d54d3086f26c52fde4084eaeef350f71b918fb8e7338f329bff95 config-baseline.json
|
||||
ee4c0f0fb15cda02268f2e83d0c5e1c8d0ec0a2c1b2fdb89cdfce308dadb2b8b config-baseline.core.json
|
||||
ccb0c68e959854b9d54d66b8c78bfba5fe6f8a37e669e2e7e511b02c4c977122 config-baseline.channel.json
|
||||
b901fb766edfd9df630690281476fc4032c64772f69d1d8f7b2e0e913a90f229 config-baseline.channel.json
|
||||
1b763a5524aca2d7ecf1eea38f845ad1ffed5c1b37e85e62f6a7902a3ee0f920 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
91cb45dc1e8aaa3dac9a2c1d3c98c8ff22112e41c305de17f30d0d4420635ee4 plugin-sdk-api-baseline.json
|
||||
3aa4802ffcb68c4f15e367030994eae10e73b55b5f14c8e23d4e9467fae325fe plugin-sdk-api-baseline.jsonl
|
||||
7039b60f2cea732a90db633328952faaddd919f0d098b303b29d554e64184073 plugin-sdk-api-baseline.json
|
||||
1a78f4df81562af070c5379c6369a8bea9c704f985b5382a463364757b26db0d plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -666,58 +666,6 @@ Teams delivers messages via HTTP webhook. If processing takes too long (e.g., sl
|
||||
|
||||
OpenClaw handles this by returning quickly and sending replies proactively, but very slow responses may still cause issues.
|
||||
|
||||
### Teams cloud and service URL support
|
||||
|
||||
This SDK-backed Teams path is live-validated for Microsoft Teams public cloud.
|
||||
|
||||
Inbound replies use the incoming Teams SDK turn context. Out-of-context proactive operations - sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies - use the stored conversation reference `serviceUrl`. Public cloud defaults to the Teams SDK public cloud environment and allows stored references on the public Teams Connector host: `https://smba.trafficmanager.net/`.
|
||||
|
||||
Public cloud is the default. You do not need to set `channels.msteams.cloud` or `channels.msteams.serviceUrl` for normal public-cloud bots.
|
||||
|
||||
For non-public Teams clouds, set `cloud` and the matching proactive boundary when Microsoft publishes one:
|
||||
|
||||
- `channels.msteams.cloud` selects the Teams SDK cloud preset for authentication, JWT validation, token services, and Graph scope.
|
||||
- `channels.msteams.serviceUrl` selects the Bot Connector endpoint boundary used to validate stored conversation references before proactive sends, edits, deletes, cards, polls, file-consent messages, and queued long-running replies. It is required for USGov and DoD SDK clouds. For China/21Vianet, OpenClaw uses the SDK `China` preset and accepts stored/configured service URLs only on Azure China Bot Framework channel hosts.
|
||||
|
||||
Microsoft publishes the global proactive Bot Connector endpoints in the [Create the conversation](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/send-proactive-messages?tabs=dotnet#create-the-conversation) section of the Teams proactive messaging docs. Use the incoming activity's `serviceUrl` when available; if you need a global proactive endpoint, use Microsoft's table.
|
||||
|
||||
| Teams environment | OpenClaw config | Proactive `serviceUrl` |
|
||||
| ----------------- | ----------------------------------------------------------- | -------------------------------------------------- |
|
||||
| Public | no cloud/serviceUrl config needed | `https://smba.trafficmanager.net/teams` |
|
||||
| GCC | set `serviceUrl`; no separate Teams SDK cloud preset exists | `https://smba.infra.gcc.teams.microsoft.com/teams` |
|
||||
| GCC High | `cloud: "USGov"` + `serviceUrl` | `https://smba.infra.gov.teams.microsoft.us/teams` |
|
||||
| DoD | `cloud: "USGovDoD"` + `serviceUrl` | `https://smba.infra.dod.teams.microsoft.us/teams` |
|
||||
| China/21Vianet | `cloud: "China"` | use the incoming activity's `serviceUrl` |
|
||||
|
||||
Example for GCC, where Microsoft documents a separate proactive service URL but the Teams SDK does not expose a separate GCC cloud preset:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"msteams": {
|
||||
"serviceUrl": "https://smba.infra.gcc.teams.microsoft.com/teams"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example for GCC High:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"msteams": {
|
||||
"cloud": "USGov",
|
||||
"serviceUrl": "https://smba.infra.gov.teams.microsoft.us/teams"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`channels.msteams.serviceUrl` is restricted to supported Microsoft Teams Bot Connector hosts. When a service URL is configured, OpenClaw checks that the stored conversation `serviceUrl` uses the same host before proactive sends, edits, deletes, cards, polls, or queued long-running replies run. With the default public-cloud config, OpenClaw fails closed if a stored conversation points outside the public Teams Connector host. Receive a fresh message from the conversation after changing cloud/service URL settings so the stored conversation reference is current.
|
||||
|
||||
China/21Vianet does not have a separate global proactive `smba` URL in Microsoft's Teams proactive endpoint table. Configure `cloud: "China"` so the Teams SDK uses Azure China auth, token, and JWT endpoints. Proactive sends then require a stored conversation reference from an incoming China Teams activity, or an explicitly configured service URL, on the Azure China Bot Framework channel boundary (`*.botframework.azure.cn`). Graph-backed Teams helpers are currently disabled for `cloud: "China"` until OpenClaw routes Graph requests through the Azure China Graph endpoint.
|
||||
|
||||
### Formatting
|
||||
|
||||
Teams markdown is more limited than Slack or Discord:
|
||||
@@ -732,8 +680,6 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
|
||||
- `channels.msteams.enabled`: enable/disable the channel.
|
||||
- `channels.msteams.appId`, `channels.msteams.appPassword`, `channels.msteams.tenantId`: bot credentials.
|
||||
- `channels.msteams.cloud`: Teams SDK cloud environment (`Public`, `USGov`, `USGovDoD`, or `China`; default `Public`). Set this with `serviceUrl` for USGov/DoD SDK clouds; China uses the SDK preset and stored Azure China Bot Framework conversation references, with Graph-backed helpers disabled until Azure China Graph routing is implemented.
|
||||
- `channels.msteams.serviceUrl`: Bot Connector service URL boundary for SDK proactive operations. Public cloud uses the SDK default; set this for GCC (`https://smba.infra.gcc.teams.microsoft.com/teams`), GCC High, or DoD. China accepts Azure China Bot Framework channel hosts when the stored conversation reference comes from Teams operated by 21Vianet.
|
||||
- `channels.msteams.webhook.port` (default `3978`)
|
||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
|
||||
24
docs/ci.md
24
docs/ci.md
@@ -43,9 +43,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
|
||||
|
||||
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
|
||||
|
||||
Use `pnpm ci:timings`, `pnpm ci:timings:recent`, or `node scripts/ci-run-timings.mjs <run-id>` to summarize wall time, queue time, slowest jobs, failures, and the `pnpm-store-warmup` fanout barrier from GitHub Actions. CI also uploads the same run summary as a `ci-timings-summary` artifact. For build timing, check the `build-artifacts` job's `Build dist` step: `pnpm build:ci-artifacts` prints `[build-all] phase timings:` and includes `ui:build`; the job also uploads the `startup-memory` artifact.
|
||||
|
||||
For pull request runs, the terminal timing-summary job runs the helper from the trusted base revision before passing `GH_TOKEN` to `gh run view`. That keeps the tokened query out of branch-controlled code while still summarizing the pull request's current CI run.
|
||||
The `ci-timings-summary` job uploads a compact `ci-timings-summary` artifact for each non-draft CI run. It records wall time, queue time, slowest jobs, and failed jobs for the current run, so CI health checks do not need to scrape the full Actions payload repeatedly. The `build-artifacts` job also runs the blocking startup-memory smoke and uploads a `startup-memory` artifact with per-command RSS values for `--help`, `status --json`, and `gateway status`.
|
||||
|
||||
## Real behavior proof
|
||||
|
||||
@@ -122,17 +120,17 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
|
||||
## Runners
|
||||
|
||||
| Runner | Jobs |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ubuntu-24.04` | Manual CI dispatch and non-canonical repository fallbacks, workflow-sanity, labeler, auto-response, docs workflows outside CI, and install-smoke preflight so the Blacksmith matrix can queue earlier |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, `preflight`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, `checks-node-compat-node22`, `check-guards`, `check-prod-types`, and `check-test-types` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Linux Node test shards, bundled plugin test shards, `check-additional-*` shards, `check-dependencies`, and `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
| Runner | Jobs |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ubuntu-24.04` | `preflight`, docs checks, Python skills, workflow-sanity, labeler, auto-response; install-smoke preflight also uses GitHub-hosted Ubuntu so the Blacksmith matrix can queue earlier |
|
||||
| `blacksmith-4vcpu-ubuntu-2404` | `CodeQL Critical Quality`, `security-fast`, lower-weight extension shards, `checks-fast-core`, plugin/channel contract shards, `checks-node-compat-node22`, `check-guards`, `check-prod-types`, and `check-test-types` |
|
||||
| `blacksmith-8vcpu-ubuntu-2404` | Linux Node test shards, bundled plugin test shards, `check-additional-*` shards, `android` |
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-16vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path. During `preflight`, `scripts/ci-runner-labels.mjs` checks recent queued and in-progress Actions runs for queued Blacksmith jobs. If a specific Blacksmith label already has queued jobs, downstream jobs that would use that exact label fall back to the matching GitHub-hosted runner (`ubuntu-24.04`, `windows-2025`, `macos-15`, or `macos-26`) for that run only. Other Blacksmith sizes in the same OS family stay on their primary labels. If the API probe fails, no fallback is applied.
|
||||
|
||||
## Local equivalents
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ openclaw doctor
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --allow-exec
|
||||
openclaw doctor --deep
|
||||
openclaw doctor --fix
|
||||
openclaw doctor --fix --non-interactive
|
||||
@@ -65,7 +64,6 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
- `--force`: apply aggressive repairs, including overwriting custom service config when needed
|
||||
- `--non-interactive`: run without prompts; safe migrations and non-service repairs only
|
||||
- `--generate-gateway-token`: generate and configure a gateway token
|
||||
- `--allow-exec`: allow doctor to execute configured exec SecretRefs while verifying secrets
|
||||
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
|
||||
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output
|
||||
@@ -86,7 +84,6 @@ are only accepted with `--lint`.
|
||||
openclaw doctor --lint
|
||||
openclaw doctor --lint --severity-min warning
|
||||
openclaw doctor --lint --json
|
||||
openclaw doctor --lint --allow-exec
|
||||
openclaw doctor --lint --only core/doctor/gateway-config --json
|
||||
```
|
||||
|
||||
@@ -194,7 +191,6 @@ Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
- Performance: non-interactive `doctor` runs skip eager plugin loading so headless health checks stay fast. Interactive doctor sessions still load the plugin surfaces needed by the legacy health and repair flow.
|
||||
- `--lint` is stricter than `--non-interactive`: it is always read-only, never prompts, and never applies safe migrations. Run `doctor --fix` or `doctor --repair` when you want doctor to make changes.
|
||||
- By default, doctor does not execute `exec` SecretRefs while checking secrets. Use `openclaw doctor --allow-exec` or `openclaw doctor --lint --allow-exec` only when you intentionally want doctor to run those configured secret resolvers.
|
||||
- `--fix` (alias for `--repair`) writes a backup to `~/.openclaw/openclaw.json.bak` and drops unknown config keys, listing each removal.
|
||||
- Modernized health checks can expose a `repair()` path for `doctor --fix`; checks that do not expose one continue through the existing doctor repair flow.
|
||||
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` when you intentionally want to replace the launcher.
|
||||
@@ -218,7 +214,7 @@ Notes:
|
||||
- Doctor warns when skills allowed for the default agent are unavailable in the current runtime environment because bins, env vars, config, or OS requirements are missing. `doctor --fix` can disable those unavailable skills with `skills.entries.<skill>.enabled=false`; install/configure the missing requirement instead when you want to keep the skill active.
|
||||
- If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`).
|
||||
- If legacy sandbox registry files (`~/.openclaw/sandbox/containers.json` or `~/.openclaw/sandbox/browsers.json`) are present, doctor reports them; `openclaw doctor --fix` migrates valid entries into sharded registry directories and quarantines invalid legacy files.
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. For exec-backed SecretRefs, doctor skips execution unless `--allow-exec` is present.
|
||||
- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials.
|
||||
- If channel SecretRef inspection fails in a fix path, doctor continues and reports a warning instead of exiting early.
|
||||
- After state-directory migrations, doctor warns when enabled default Telegram or Discord accounts depend on env fallback and `TELEGRAM_BOT_TOKEN` or `DISCORD_BOT_TOKEN` is unavailable to the doctor process.
|
||||
- Telegram `allowFrom` username auto-resolution (`doctor --fix`) requires a resolvable Telegram token in the current command path. If token inspection is unavailable, doctor reports a warning and skips auto-resolution for that pass.
|
||||
|
||||
@@ -170,7 +170,7 @@ Notes:
|
||||
- `memory status` includes any extra paths configured via `memorySearch.extraPaths`.
|
||||
- If effectively active memory remote API key fields are configured as SecretRefs, the command resolves those values from the active gateway snapshot. If gateway is unavailable, the command fails fast.
|
||||
- Gateway version skew note: this command path requires a gateway that supports `secrets.resolve`; older gateways return an unknown-method error.
|
||||
- Tune scheduled sweep cadence with `dreaming.frequency`. Deep promotion policy is otherwise internal except for `dreaming.phases.deep.maxPromotedSnippetTokens`, which bounds promoted snippet length while keeping provenance visible. Use CLI flags on `memory promote` when you need one-off manual threshold overrides.
|
||||
- Tune scheduled sweep cadence with `dreaming.frequency`. Deep promotion policy is otherwise internal; use CLI flags on `memory promote` when you need one-off manual overrides.
|
||||
- `memory rem-harness --path <file-or-dir> --grounded` previews grounded `What Happened`, `Reflections`, and `Possible Lasting Updates` from historical daily notes without writing anything.
|
||||
- `memory rem-backfill --path <file-or-dir>` writes reversible grounded diary entries into `DREAMS.md` for UI review.
|
||||
- `memory rem-backfill --path <file-or-dir> --stage-short-term` also seeds grounded durable candidates into the live short-term promotion store so the normal deep phase can rank them.
|
||||
|
||||
@@ -177,8 +177,6 @@ is available, then fall back to `latest`.
|
||||
|
||||
Bare specs and `@latest` stay on the stable track. OpenClaw date-stamped correction versions such as `2026.5.3-1` are stable releases for this check. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a prerelease tag such as `@beta`/`@rc` or an exact prerelease version such as `@1.2.3-beta.4`.
|
||||
|
||||
For npm installs without an exact version (`npm:<package>` or `npm:<package>@latest`), OpenClaw checks the resolved package metadata before install. If the latest stable package requires a newer OpenClaw plugin API or minimum host version, OpenClaw inspects older stable versions and installs the newest compatible release instead. Exact versions and explicit dist-tags such as `@beta` remain strict: if the selected package is incompatible, the command fails and asks you to upgrade OpenClaw or choose a compatible version.
|
||||
|
||||
If a bare install spec matches an official plugin id (for example `diffs`), OpenClaw installs the catalog entry directly. To install an npm package with the same name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -229,16 +229,13 @@ All settings live under `plugins.entries.memory-core.config.dreaming`.
|
||||
<ParamField path="model" type="string">
|
||||
Optional Dream Diary subagent model override. Use a canonical `provider/model` value when also setting a subagent `allowedModels` allowlist.
|
||||
</ParamField>
|
||||
<ParamField path="phases.deep.maxPromotedSnippetTokens" type="number" default="160">
|
||||
Maximum estimated token count kept from each short-term recall snippet promoted into `MEMORY.md`. Ranking provenance remains visible.
|
||||
</ParamField>
|
||||
|
||||
<Warning>
|
||||
`dreaming.model` requires `plugins.entries.memory-core.subagent.allowModelOverride: true`. To restrict it, also set `plugins.entries.memory-core.subagent.allowedModels`. Trust or allowlist failures stay visible instead of falling back silently; the retry only covers model-unavailable errors.
|
||||
</Warning>
|
||||
|
||||
<Note>
|
||||
Most phase policy, thresholds, and storage behavior are internal implementation details. See [Memory configuration reference](/reference/memory-config#dreaming) for the full key list.
|
||||
Phase policy, thresholds, and storage behavior are internal implementation details (not user-facing config). See [Memory configuration reference](/reference/memory-config#dreaming) for the full key list.
|
||||
</Note>
|
||||
|
||||
## Dreams UI
|
||||
|
||||
@@ -290,32 +290,32 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
|
||||
### Other bundled provider plugins
|
||||
|
||||
| Provider | Id | Auth env | Example model |
|
||||
| ----------------------- | -------------------------------- | ------------------------------------------------------------ | -------------------------------------------------- |
|
||||
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
|
||||
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
|
||||
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
|
||||
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
|
||||
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |
|
||||
| GitHub Copilot | `github-copilot` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | - |
|
||||
| Groq | `groq` | `GROQ_API_KEY` | - |
|
||||
| Hugging Face Inference | `huggingface` | `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` | `huggingface/deepseek-ai/DeepSeek-R1` |
|
||||
| Kilo Gateway | `kilocode` | `KILOCODE_API_KEY` | `kilocode/kilo/auto` |
|
||||
| Kimi Coding | `kimi` | `KIMI_API_KEY` or `KIMICODE_API_KEY` | `kimi/kimi-for-coding` |
|
||||
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M2.7` |
|
||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | `mistral/mistral-large-latest` |
|
||||
| Moonshot | `moonshot` | `MOONSHOT_API_KEY` | `moonshot/kimi-k2.6` |
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-super-120b-a12b` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` |
|
||||
| Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` |
|
||||
| StepFun | `stepfun` / `stepfun-plan` | `STEPFUN_API_KEY` | `stepfun/step-3.5-flash` |
|
||||
| Together | `together` | `TOGETHER_API_KEY` | `together/meta-llama/Llama-3.3-70B-Instruct-Turbo` |
|
||||
| Venice | `venice` | `VENICE_API_KEY` | - |
|
||||
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway/anthropic/claude-opus-4.6` |
|
||||
| Volcano Engine (Doubao) | `volcengine` / `volcengine-plan` | `VOLCANO_ENGINE_API_KEY` | `volcengine-plan/ark-code-latest` |
|
||||
| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | `xai/grok-4.3` |
|
||||
| Xiaomi | `xiaomi` | `XIAOMI_API_KEY` | `xiaomi/mimo-v2-flash` |
|
||||
| Provider | Id | Auth env | Example model |
|
||||
| ----------------------- | -------------------------------- | ------------------------------------------------------------ | --------------------------------------------- |
|
||||
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
|
||||
| Cerebras | `cerebras` | `CEREBRAS_API_KEY` | `cerebras/zai-glm-4.7` |
|
||||
| Cloudflare AI Gateway | `cloudflare-ai-gateway` | `CLOUDFLARE_AI_GATEWAY_API_KEY` | - |
|
||||
| DeepInfra | `deepinfra` | `DEEPINFRA_API_KEY` | `deepinfra/deepseek-ai/DeepSeek-V4-Flash` |
|
||||
| DeepSeek | `deepseek` | `DEEPSEEK_API_KEY` | `deepseek/deepseek-v4-flash` |
|
||||
| GitHub Copilot | `github-copilot` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | - |
|
||||
| Groq | `groq` | `GROQ_API_KEY` | - |
|
||||
| Hugging Face Inference | `huggingface` | `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` | `huggingface/deepseek-ai/DeepSeek-R1` |
|
||||
| Kilo Gateway | `kilocode` | `KILOCODE_API_KEY` | `kilocode/kilo/auto` |
|
||||
| Kimi Coding | `kimi` | `KIMI_API_KEY` or `KIMICODE_API_KEY` | `kimi/kimi-for-coding` |
|
||||
| MiniMax | `minimax` / `minimax-portal` | `MINIMAX_API_KEY` / `MINIMAX_OAUTH_TOKEN` | `minimax/MiniMax-M2.7` |
|
||||
| Mistral | `mistral` | `MISTRAL_API_KEY` | `mistral/mistral-large-latest` |
|
||||
| Moonshot | `moonshot` | `MOONSHOT_API_KEY` | `moonshot/kimi-k2.6` |
|
||||
| NVIDIA | `nvidia` | `NVIDIA_API_KEY` | `nvidia/nvidia/nemotron-3-super-120b-a12b` |
|
||||
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` | `openrouter/auto` |
|
||||
| Qianfan | `qianfan` | `QIANFAN_API_KEY` | `qianfan/deepseek-v3.2` |
|
||||
| Qwen Cloud | `qwen` | `QWEN_API_KEY` / `MODELSTUDIO_API_KEY` / `DASHSCOPE_API_KEY` | `qwen/qwen3.5-plus` |
|
||||
| StepFun | `stepfun` / `stepfun-plan` | `STEPFUN_API_KEY` | `stepfun/step-3.5-flash` |
|
||||
| Together | `together` | `TOGETHER_API_KEY` | `together/moonshotai/Kimi-K2.5` |
|
||||
| Venice | `venice` | `VENICE_API_KEY` | - |
|
||||
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway/anthropic/claude-opus-4.6` |
|
||||
| Volcano Engine (Doubao) | `volcengine` / `volcengine-plan` | `VOLCANO_ENGINE_API_KEY` | `volcengine-plan/ark-code-latest` |
|
||||
| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | `xai/grok-4.3` |
|
||||
| Xiaomi | `xiaomi` | `XIAOMI_API_KEY` | `xiaomi/mimo-v2-flash` |
|
||||
|
||||
#### Quirks worth knowing
|
||||
|
||||
|
||||
@@ -665,48 +665,6 @@ pnpm openclaw qa slack \
|
||||
|
||||
A green run completes in well under 30 seconds and `slack-qa-report.md` shows both `slack-canary` and `slack-mention-gating` at status `pass`. If the lane hangs for ~90 seconds and exits with `Convex credential pool exhausted for kind "slack"`, either the pool is empty or every row is leased - `qa credentials list --kind slack --status all --json` will tell you which.
|
||||
|
||||
### WhatsApp QA
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa whatsapp
|
||||
```
|
||||
|
||||
Targets two dedicated WhatsApp Web accounts: a driver account controlled by
|
||||
the harness and a SUT account started by the child OpenClaw gateway through the
|
||||
bundled WhatsApp plugin.
|
||||
|
||||
Required env when `--credential-source env`:
|
||||
|
||||
- `OPENCLAW_QA_WHATSAPP_DRIVER_PHONE_E164`
|
||||
- `OPENCLAW_QA_WHATSAPP_SUT_PHONE_E164`
|
||||
- `OPENCLAW_QA_WHATSAPP_DRIVER_AUTH_ARCHIVE_BASE64`
|
||||
- `OPENCLAW_QA_WHATSAPP_SUT_AUTH_ARCHIVE_BASE64`
|
||||
|
||||
Optional:
|
||||
|
||||
- `OPENCLAW_QA_WHATSAPP_GROUP_JID` enables `whatsapp-mention-gating`.
|
||||
- `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1` keeps message bodies in
|
||||
observed-message artifacts.
|
||||
|
||||
Scenarios (`extensions/qa-lab/src/live-transports/whatsapp/whatsapp-live.runtime.ts`):
|
||||
|
||||
- `whatsapp-canary`
|
||||
- `whatsapp-pairing-block`
|
||||
- `whatsapp-mention-gating`
|
||||
- `whatsapp-approval-exec-native` - opt-in native WhatsApp exec approval
|
||||
scenario. Requests an exec approval through the gateway, verifies the
|
||||
WhatsApp message has native reaction approval affordances, resolves it, and
|
||||
verifies the resolved WhatsApp follow-up.
|
||||
- `whatsapp-approval-plugin-native` - opt-in native WhatsApp plugin approval
|
||||
scenario. Enables exec and plugin approval forwarding together, then verifies
|
||||
the same pending/resolved native WhatsApp path.
|
||||
|
||||
Output artifacts:
|
||||
|
||||
- `whatsapp-qa-report.md`
|
||||
- `whatsapp-qa-summary.json`
|
||||
- `whatsapp-qa-observed-messages.json` - bodies redacted unless `OPENCLAW_QA_WHATSAPP_CAPTURE_CONTENT=1`.
|
||||
|
||||
### Convex credential pool
|
||||
|
||||
Telegram, Discord, Slack, and WhatsApp lanes can lease credentials from a shared Convex pool instead of reading the env vars above. Pass `--credential-source convex` (or set `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`); QA Lab acquires an exclusive lease, heartbeats it for the duration of the run, and releases it on shutdown. Pool kinds are `"telegram"`, `"discord"`, `"slack"`, and `"whatsapp"`.
|
||||
|
||||
@@ -141,8 +141,8 @@ layer stack for Telegram direct, Discord group, and heartbeat turns. That stack
|
||||
includes a pinned Codex `gpt-5.5` model prompt fixture generated from Codex's
|
||||
model catalog/cache shape, the Codex happy-path permission developer text,
|
||||
OpenClaw developer instructions, turn-scoped collaboration-mode instructions
|
||||
when OpenClaw provides them, user turn input, and references to the dynamic tool
|
||||
specs.
|
||||
for cron and heartbeat turns when OpenClaw provides them, user turn input, and
|
||||
references to the dynamic tool specs.
|
||||
|
||||
Refresh the pinned Codex model prompt fixture with
|
||||
`pnpm prompt:snapshots:sync-codex-model`. By default, the script looks for
|
||||
@@ -178,18 +178,19 @@ prompt surface that matches their lifetime:
|
||||
- `BOOTSTRAP.md` (only on brand-new workspaces)
|
||||
- `MEMORY.md` when present
|
||||
|
||||
On the native Codex harness, OpenClaw avoids repeating stable workspace files
|
||||
On the native Codex harness, OpenClaw avoids repeating standing workspace files
|
||||
in every user turn. Codex loads `AGENTS.md` through its own project-doc
|
||||
discovery. `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md` are forwarded as
|
||||
Codex developer instructions. `HEARTBEAT.md` content is not injected; heartbeat
|
||||
turns get a collaboration-mode note pointing to the file when it exists and is
|
||||
non-empty. `MEMORY.md` content from the configured agent workspace is not pasted
|
||||
into every native Codex turn; when memory tools are available for that workspace,
|
||||
Codex turns get a small workspace-memory note and should use `memory_search` or
|
||||
`memory_get` when durable memory is relevant. If tools are disabled, memory
|
||||
search is unavailable, or the active workspace differs from the agent memory
|
||||
workspace, `MEMORY.md` falls back to the normal bounded turn-context path. Active
|
||||
`BOOTSTRAP.md` content keeps the normal turn-context role for now.
|
||||
Codex thread developer instructions. `HEARTBEAT.md` content is not injected;
|
||||
heartbeat turns get a collaboration-mode note pointing to the file when it
|
||||
exists and is non-empty. `MEMORY.md` stays out of Codex thread developer
|
||||
instructions because it changes often: when memory tools are available for that
|
||||
workspace, Codex turns get a small workspace-memory note and should use
|
||||
`memory_search` or `memory_get` when durable memory is relevant. If tools are
|
||||
disabled, memory search is unavailable, or the active workspace differs from the
|
||||
agent memory workspace, `MEMORY.md` falls back to the normal bounded
|
||||
turn-context path. Active `BOOTSTRAP.md` content keeps the normal turn-context
|
||||
role for now.
|
||||
|
||||
On non-Codex harnesses, bootstrap files continue to be composed into the
|
||||
OpenClaw prompt according to their existing gates. `HEARTBEAT.md` is omitted on
|
||||
|
||||
@@ -1554,7 +1554,6 @@
|
||||
"gateway/security/index",
|
||||
"gateway/security/exposure-runbook",
|
||||
"gateway/security/secure-file-operations",
|
||||
"gateway/security/shrinkwrap",
|
||||
"gateway/security/audit-checks",
|
||||
"gateway/operator-scopes",
|
||||
"gateway/sandboxing",
|
||||
@@ -1816,7 +1815,6 @@
|
||||
"pages": [
|
||||
"reference/RELEASING",
|
||||
"reference/full-release-validation",
|
||||
"reference/release-performance-sweep",
|
||||
"reference/test",
|
||||
"ci",
|
||||
"help/scripts"
|
||||
|
||||
@@ -562,6 +562,20 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b
|
||||
first compaction summary exists. Auth profile or credential-epoch changes
|
||||
still never raw-reseed.
|
||||
|
||||
### `agents.defaults.systemPromptOverride`
|
||||
|
||||
Replace the entire OpenClaw-assembled system prompt with a fixed string. Set at the default level (`agents.defaults.systemPromptOverride`) or per agent (`agents.list[].systemPromptOverride`). Per-agent values take precedence; an empty or whitespace-only value is ignored. Useful for controlled prompt experiments.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
systemPromptOverride: "You are a helpful assistant.",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.promptOverlays`
|
||||
|
||||
Provider-independent prompt overlays applied by model family on OpenClaw-assembled prompt surfaces. GPT-5-family model ids receive the shared behavior contract across OpenClaw/provider routes; `personality` controls only the friendly interaction-style layer. Native Codex app-server routes keep Codex-owned base/model instructions instead of this OpenClaw GPT-5 overlay, and OpenClaw disables Codex's built-in personality for native threads.
|
||||
|
||||
@@ -347,11 +347,9 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
- `models.list` returns the runtime-allowed model catalog. Pass `{ "view": "configured" }` for picker-sized configured models (`agents.defaults.models` first, then `models.providers.*.models`), or `{ "view": "all" }` for the full catalog.
|
||||
- `usage.status` returns provider usage windows/remaining quota summaries.
|
||||
- `usage.cost` returns aggregated cost usage summaries for a date range.
|
||||
Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
|
||||
- `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only when the caller explicitly wants a live embedding provider ping.
|
||||
- `doctor.memory.remHarness` returns a bounded, read-only REM harness preview for remote control-plane clients. It can include workspace paths, memory snippets, rendered grounded markdown, and deep promotion candidates, so callers need `operator.read`.
|
||||
- `sessions.usage` returns per-session usage summaries. Pass `agentId` for one
|
||||
agent, or `agentScope: "all"` to list configured agents together.
|
||||
- `sessions.usage` returns per-session usage summaries.
|
||||
- `sessions.usage.timeseries` returns timeseries usage for one session.
|
||||
- `sessions.usage.logs` returns usage log entries for one session.
|
||||
|
||||
|
||||
@@ -63,11 +63,67 @@ OpenClaw source checkouts use `pnpm-lock.yaml`. The published `openclaw` npm
|
||||
package and OpenClaw-owned npm plugin packages include `npm-shrinkwrap.json`,
|
||||
npm's publishable dependency lockfile, so package installs use the reviewed
|
||||
transitive dependency graph from the release instead of resolving a fresh graph
|
||||
at install time.
|
||||
at install time. Suitable OpenClaw-owned npm plugin packages can also publish
|
||||
with explicit `bundledDependencies`, so their runtime dependency files are
|
||||
carried in the plugin tarball instead of depending only on install-time
|
||||
resolution.
|
||||
|
||||
Shrinkwrap is a supply-chain hardening and release reproducibility boundary,
|
||||
not a sandbox. For the plain-English model, maintainer commands, and package
|
||||
inspection checks, see [npm shrinkwrap](/gateway/security/shrinkwrap).
|
||||
This is a supply-chain hardening measure:
|
||||
|
||||
- release installs are more reproducible;
|
||||
- transitive dependency updates become visible review surfaces;
|
||||
- the package tarball contains the dependency graph that release validators
|
||||
checked;
|
||||
- suitable OpenClaw-owned plugin tarballs contain the dependency files from
|
||||
that graph;
|
||||
- `package-lock.json` stays out of the published package, because npm does not
|
||||
treat it as the publishable lock contract.
|
||||
|
||||
Shrinkwrap is not a sandbox and does not make every dependency trustworthy. It
|
||||
does not replace `openclaw security audit`, host isolation, npm provenance,
|
||||
signature/audit checks, or `--ignore-scripts` install smoke tests when those are
|
||||
appropriate. Treat it as a release reproducibility and review-control boundary.
|
||||
|
||||
Maintainers should update and verify shrinkwrap whenever the root package or an
|
||||
OpenClaw-owned published plugin package changes its published dependency graph:
|
||||
|
||||
```bash
|
||||
pnpm deps:shrinkwrap:generate
|
||||
pnpm deps:shrinkwrap:check
|
||||
```
|
||||
|
||||
The generator resolves npm's publishable lock format but rejects generated
|
||||
package versions that are not already present in `pnpm-lock.yaml`, preserving
|
||||
the pnpm dependency age, override, and patch review boundary.
|
||||
|
||||
Use `pnpm deps:shrinkwrap:root:generate` and
|
||||
`pnpm deps:shrinkwrap:root:check` only when you intentionally want to refresh
|
||||
the root `openclaw` package without touching plugin packages.
|
||||
|
||||
Review `pnpm-lock.yaml`, `npm-shrinkwrap.json`, bundled plugin dependency
|
||||
payloads, and any `package-lock.json` diff as security-sensitive. The package
|
||||
validators require shrinkwrap in new root package tarballs and the plugin npm
|
||||
publish path checks plugin-local shrinkwrap, installs package-local bundled
|
||||
dependencies, and then packs or publishes. Package validators reject
|
||||
`package-lock.json`.
|
||||
|
||||
To inspect a published package:
|
||||
|
||||
```bash
|
||||
npm pack openclaw@<version> --json --pack-destination /tmp/openclaw-pack
|
||||
tar -tf /tmp/openclaw-pack/openclaw-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
|
||||
```
|
||||
|
||||
To inspect an OpenClaw-owned plugin package, replace the package spec and check
|
||||
the same tar entry:
|
||||
|
||||
```bash
|
||||
npm pack @openclaw/discord@<version> --json --pack-destination /tmp/openclaw-plugin-pack
|
||||
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
|
||||
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/node_modules/'
|
||||
```
|
||||
|
||||
Background: [npm-shrinkwrap.json](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json).
|
||||
|
||||
### Deployment and host trust
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
summary: "Plain-English and technical explanation of npm shrinkwrap in OpenClaw releases"
|
||||
read_when:
|
||||
- You want to know what npm shrinkwrap means in an OpenClaw release
|
||||
- You are reviewing package lockfiles, dependency changes, or supply-chain risk
|
||||
- You are validating root or plugin npm packages before publishing
|
||||
title: "npm shrinkwrap"
|
||||
---
|
||||
|
||||
OpenClaw source checkouts use `pnpm-lock.yaml`. Published OpenClaw npm
|
||||
packages use `npm-shrinkwrap.json`, npm's publishable dependency lockfile, so
|
||||
package installs use the dependency graph reviewed during release.
|
||||
|
||||
## The easy version
|
||||
|
||||
Shrinkwrap is a receipt for the dependency tree that ships with an npm package.
|
||||
It tells npm which exact transitive package versions to install.
|
||||
|
||||
For OpenClaw releases, that means:
|
||||
|
||||
- the published package does not ask npm to invent a fresh dependency graph at
|
||||
install time;
|
||||
- dependency changes become easier to review because they appear in a lockfile;
|
||||
- release validation can test the same graph users will install;
|
||||
- package-size or native-dependency surprises are easier to spot before
|
||||
publishing.
|
||||
|
||||
Shrinkwrap is not a sandbox. It does not make a dependency safe by itself, and
|
||||
it does not replace host isolation, `openclaw security audit`, package
|
||||
provenance, or install smoke tests.
|
||||
|
||||
The short mental model:
|
||||
|
||||
| File | Where it matters | What it means |
|
||||
| --------------------- | ------------------------ | --------------------------------- |
|
||||
| `pnpm-lock.yaml` | OpenClaw source checkout | Maintainer dependency graph |
|
||||
| `npm-shrinkwrap.json` | Published npm package | npm install graph for users |
|
||||
| `package-lock.json` | Local npm apps | Not the OpenClaw publish contract |
|
||||
|
||||
## Why OpenClaw uses it
|
||||
|
||||
OpenClaw is a gateway, plugin host, model router, and agent runtime. A default
|
||||
install can affect startup time, disk use, native package downloads, and
|
||||
supply-chain exposure.
|
||||
|
||||
Shrinkwrap gives release review a stable boundary:
|
||||
|
||||
- reviewers can see transitive dependency movement;
|
||||
- package validators can reject unexpected lockfile drift;
|
||||
- package acceptance can test installs with the graph that will ship;
|
||||
- plugin packages can carry their own locked dependency graph instead of
|
||||
relying on the root package to own plugin-only dependencies.
|
||||
|
||||
The goal is not "more lockfiles." The goal is reproducible release installs
|
||||
with clear ownership.
|
||||
|
||||
## Technical details
|
||||
|
||||
The root `openclaw` npm package and OpenClaw-owned npm plugin packages include
|
||||
`npm-shrinkwrap.json` when they publish. Suitable OpenClaw-owned plugin
|
||||
packages can also publish with explicit `bundledDependencies`, so their runtime
|
||||
dependency files are carried in the plugin tarball instead of depending only on
|
||||
install-time resolution.
|
||||
|
||||
Maintain the boundary like this:
|
||||
|
||||
```bash
|
||||
pnpm deps:shrinkwrap:generate
|
||||
pnpm deps:shrinkwrap:check
|
||||
```
|
||||
|
||||
The generator resolves npm's publishable lock format but rejects generated
|
||||
package versions that are not already present in `pnpm-lock.yaml`. That keeps
|
||||
the pnpm dependency age, override, and patch-review boundary intact.
|
||||
|
||||
Use root-only commands only when intentionally refreshing the root package
|
||||
without touching plugin packages:
|
||||
|
||||
```bash
|
||||
pnpm deps:shrinkwrap:root:generate
|
||||
pnpm deps:shrinkwrap:root:check
|
||||
```
|
||||
|
||||
Review these files as security-sensitive:
|
||||
|
||||
- `pnpm-lock.yaml`
|
||||
- `npm-shrinkwrap.json`
|
||||
- bundled plugin dependency payloads
|
||||
- any `package-lock.json` diff
|
||||
|
||||
OpenClaw package validators require shrinkwrap in new root package tarballs.
|
||||
The plugin npm publish path checks plugin-local shrinkwrap, installs
|
||||
package-local bundled dependencies, and then packs or publishes. Package
|
||||
validators reject `package-lock.json` for published OpenClaw packages.
|
||||
|
||||
To inspect a published root package:
|
||||
|
||||
```bash
|
||||
npm pack openclaw@<version> --json --pack-destination /tmp/openclaw-pack
|
||||
tar -tf /tmp/openclaw-pack/openclaw-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
|
||||
```
|
||||
|
||||
To inspect an OpenClaw-owned plugin package:
|
||||
|
||||
```bash
|
||||
npm pack @openclaw/discord@<version> --json --pack-destination /tmp/openclaw-plugin-pack
|
||||
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/npm-shrinkwrap.json$'
|
||||
tar -tf /tmp/openclaw-plugin-pack/openclaw-discord-<version>.tgz | grep '^package/node_modules/'
|
||||
```
|
||||
|
||||
Background: [npm-shrinkwrap.json](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json).
|
||||
@@ -99,16 +99,6 @@ OpenClaw can mirror selected events, but it cannot rewrite the native Codex
|
||||
thread unless Codex exposes that operation through app-server or native hook
|
||||
callbacks.
|
||||
|
||||
Codex app-server report-mode `PreToolUse` events defer plugin approval requests
|
||||
to the matching app-server approval. If an OpenClaw `before_tool_call` hook
|
||||
returns `requireApproval` while the native payload sets report approval mode
|
||||
(`openclaw_approval_mode` is `"report"`), the native hook relay records the
|
||||
plugin approval requirement and returns no native decision. When Codex sends the
|
||||
app-server approval request for the same tool use, OpenClaw opens the plugin
|
||||
approval prompt and maps the decision back to Codex. Codex `PermissionRequest`
|
||||
events are a separate approval path and can still route through OpenClaw
|
||||
approvals when the runtime is configured for that bridge.
|
||||
|
||||
Codex app-server item notifications also provide async `after_tool_call`
|
||||
observations for native tool completions that are not already covered by the
|
||||
native `PostToolUse` relay. These observations are for telemetry and plugin
|
||||
|
||||
@@ -211,8 +211,6 @@ Hook guard behavior for typed lifecycle hooks:
|
||||
- `params` rewrites the tool parameters for execution.
|
||||
- `requireApproval` pauses the agent run and asks the user through plugin
|
||||
approvals. The `/approve` command can approve both exec and plugin approvals.
|
||||
In Codex app-server report-mode native `PreToolUse` relays, this is deferred
|
||||
to the matching app-server approval request; see [Codex harness runtime](/plugins/codex-harness-runtime#hook-boundaries).
|
||||
- A lower-priority `block: true` can still block after a higher-priority hook
|
||||
requested approval.
|
||||
- `onResolution` receives the resolved approval decision - `allow-once`,
|
||||
|
||||
@@ -230,7 +230,7 @@ Current runtime behaviour:
|
||||
- Provider-owned raw config lives under `realtime.providers.<providerId>`.
|
||||
- Voice Call exposes the shared `openclaw_agent_consult` realtime tool by default. The realtime model can call it when the caller asks for deeper reasoning, current information, or normal OpenClaw tools.
|
||||
- `realtime.consultPolicy` optionally adds guidance for when the realtime model should call `openclaw_agent_consult`.
|
||||
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity and selected workspace-file capsule into the realtime provider instructions at session setup.
|
||||
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity, system prompt override, and selected workspace-file capsule into the realtime provider instructions at session setup.
|
||||
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns those snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true.
|
||||
- If `realtime.provider` points at an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the whole plugin.
|
||||
- Consult session keys reuse the stored call session when available, then fall back to the configured `sessionScope` (`per-phone` by default, or `per-call` for isolated calls).
|
||||
@@ -278,6 +278,7 @@ for tool work, current information, memory lookups, or workspace state.
|
||||
enabled: true,
|
||||
maxChars: 6000,
|
||||
includeIdentity: true,
|
||||
includeSystemPrompt: true,
|
||||
includeWorkspaceFiles: true,
|
||||
files: ["SOUL.md", "IDENTITY.md", "USER.md"],
|
||||
},
|
||||
|
||||
@@ -33,9 +33,7 @@ models including Llama, DeepSeek, Kimi, and more through a unified API.
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "together/meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
},
|
||||
model: { primary: "together/moonshotai/Kimi-K2.5" },
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -53,32 +51,35 @@ openclaw onboard --non-interactive \
|
||||
```
|
||||
|
||||
<Note>
|
||||
The onboarding preset sets
|
||||
`together/meta-llama/Llama-3.3-70B-Instruct-Turbo` as the default model.
|
||||
The onboarding preset sets `together/moonshotai/Kimi-K2.5` as the default
|
||||
model.
|
||||
</Note>
|
||||
|
||||
## Built-in catalog
|
||||
|
||||
OpenClaw ships this bundled Together catalog:
|
||||
|
||||
| Model ref | Name | Input | Context | Notes |
|
||||
| -------------------------------------------------- | ---------------------------- | ----------- | ------- | -------------------- |
|
||||
| `together/meta-llama/Llama-3.3-70B-Instruct-Turbo` | Llama 3.3 70B Instruct Turbo | text | 131,072 | Default model |
|
||||
| `together/moonshotai/Kimi-K2.6` | Kimi K2.6 FP4 | text, image | 262,144 | Kimi reasoning model |
|
||||
| `together/deepseek-ai/DeepSeek-V4-Pro` | DeepSeek V4 Pro | text | 512,000 | Reasoning text model |
|
||||
| `together/Qwen/Qwen2.5-7B-Instruct-Turbo` | Qwen2.5 7B Instruct Turbo | text | 32,768 | Fast text model |
|
||||
| `together/zai-org/GLM-5.1` | GLM 5.1 FP4 | text | 202,752 | Reasoning text model |
|
||||
| Model ref | Name | Input | Context | Notes |
|
||||
| ------------------------------------------------------------ | -------------------------------------- | ----------- | ---------- | -------------------------------- |
|
||||
| `together/moonshotai/Kimi-K2.5` | Kimi K2.5 | text, image | 262,144 | Default model; reasoning enabled |
|
||||
| `together/zai-org/GLM-4.7` | GLM 4.7 Fp8 | text | 202,752 | General-purpose text model |
|
||||
| `together/meta-llama/Llama-3.3-70B-Instruct-Turbo` | Llama 3.3 70B Instruct Turbo | text | 131,072 | Fast instruction model |
|
||||
| `together/meta-llama/Llama-4-Scout-17B-16E-Instruct` | Llama 4 Scout 17B 16E Instruct | text, image | 10,000,000 | Multimodal |
|
||||
| `together/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8` | Llama 4 Maverick 17B 128E Instruct FP8 | text, image | 20,000,000 | Multimodal |
|
||||
| `together/deepseek-ai/DeepSeek-V3.1` | DeepSeek V3.1 | text | 131,072 | General text model |
|
||||
| `together/deepseek-ai/DeepSeek-R1` | DeepSeek R1 | text | 131,072 | Reasoning model |
|
||||
| `together/moonshotai/Kimi-K2-Instruct-0905` | Kimi K2-Instruct 0905 | text | 262,144 | Secondary Kimi text model |
|
||||
|
||||
## Video generation
|
||||
|
||||
The bundled `together` plugin also registers video generation through the
|
||||
shared `video_generate` tool.
|
||||
|
||||
| Property | Value |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| Default video model | `together/Wan-AI/Wan2.2-T2V-A14B` |
|
||||
| Modes | text-to-video; single-image reference only with `Wan-AI/Wan2.2-I2V-A14B` |
|
||||
| Supported parameters | `aspectRatio`, `resolution` |
|
||||
| Property | Value |
|
||||
| -------------------- | ------------------------------------- |
|
||||
| Default video model | `together/Wan-AI/Wan2.2-T2V-A14B` |
|
||||
| Modes | text-to-video, single-image reference |
|
||||
| Supported parameters | `aspectRatio`, `resolution` |
|
||||
|
||||
To use Together as the default video provider:
|
||||
|
||||
|
||||
@@ -557,12 +557,11 @@ For conceptual behavior and slash commands, see [Dreaming](/concepts/dreaming).
|
||||
|
||||
### User settings
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------------------------- | --------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `boolean` | `false` | Enable or disable dreaming entirely |
|
||||
| `frequency` | `string` | `0 3 * * *` | Optional cron cadence for the full dreaming sweep |
|
||||
| `model` | `string` | default model | Optional Dream Diary subagent model override |
|
||||
| `phases.deep.maxPromotedSnippetTokens` | `number` | `160` | Maximum estimated tokens kept from each short-term recall snippet promoted into `MEMORY.md`; provenance metadata remains visible |
|
||||
| Key | Type | Default | Description |
|
||||
| ----------- | --------- | ------------- | ------------------------------------------------- |
|
||||
| `enabled` | `boolean` | `false` | Enable or disable dreaming entirely |
|
||||
| `frequency` | `string` | `0 3 * * *` | Optional cron cadence for the full dreaming sweep |
|
||||
| `model` | `string` | default model | Optional Dream Diary subagent model override |
|
||||
|
||||
### Example
|
||||
|
||||
|
||||
@@ -1,360 +0,0 @@
|
||||
---
|
||||
summary: "Visual summary and technical evidence for the May 2026 performance, package-size, dependency, and shrinkwrap cleanup"
|
||||
read_when:
|
||||
- You are validating the May 2026 performance and package-size cleanup
|
||||
- You need the numbers behind the OpenClaw performance and dependency blog post
|
||||
- You are changing release gates, package shrinkwrap, or plugin dependency boundaries
|
||||
title: "Release performance sweep"
|
||||
---
|
||||
|
||||
This page captures the evidence behind the May 2026 OpenClaw performance,
|
||||
package-size, dependency, and shrinkwrap cleanup. It is the technical companion
|
||||
to the public blog post.
|
||||
|
||||
Two audits are combined here:
|
||||
|
||||
- **Release performance sweep:** GitHub Releases from `v2026.5.27` back through
|
||||
stable `v2026.4.23`, using the `OpenClaw Performance` workflow,
|
||||
`profile=smoke`, `repeat=1`, mock-provider lane.
|
||||
- **Earlier April context:** published `clawgrit-reports` mock-provider
|
||||
baselines from `v2026.4.1` through `v2026.5.2`, used only to avoid treating
|
||||
the broken late-April releases as the public performance baseline.
|
||||
- **Install footprint sweep:** fresh `npm install --ignore-scripts` installs
|
||||
into temporary packages, with `du -sk node_modules` for size and a
|
||||
`node_modules` walk for package-instance counts.
|
||||
- **npm package size sweep:** `npm pack openclaw@<version> --dry-run --json`
|
||||
for published releases, recording compressed tarball size, unpacked size, and
|
||||
file count.
|
||||
|
||||
<Warning>
|
||||
The main performance sweep uses one smoke sample per tag. Earlier April context
|
||||
uses published repeat-3 medians from `clawgrit-reports`. Treat the numbers as
|
||||
trend evidence and regression-hunting signal, not as release-gate statistics.
|
||||
</Warning>
|
||||
|
||||
## Snapshot
|
||||
|
||||
Performance coverage: **76 requested releases**, **73 artifact-backed points**,
|
||||
and **3 unavailable CI runs**. Latest stable measured point: `v2026.5.27`.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Stable agent turn" icon="gauge">
|
||||
**2.9x faster cold turn**
|
||||
|
||||
- `v2026.4.14`: 9.8s
|
||||
- `v2026.5.27`: 3.4s
|
||||
|
||||
</Card>
|
||||
<Card title="Published package" icon="package">
|
||||
**17.8MB tarball**
|
||||
|
||||
Latest stable package, down from the 43.3MB March package-size peak.
|
||||
|
||||
</Card>
|
||||
<Card title="Latest stable install" icon="hard-drive">
|
||||
**786.9MB fresh install**
|
||||
|
||||
`v2026.5.27` still contains the nested OpenClaw dependency tree. The
|
||||
next-release state on `main` is 407.4MB.
|
||||
|
||||
</Card>
|
||||
<Card title="Dependency graph" icon="boxes">
|
||||
**371 installed packages**
|
||||
|
||||
Latest stable release. Current `main` is down to 314 after the follow-up
|
||||
dependency cleanup.
|
||||
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Install Footprint Timeline
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Monthly high" icon="triangle-alert">
|
||||
**645 dependencies**
|
||||
|
||||
`2026.2.26` was the monthly dependency-count high in this sample.
|
||||
|
||||
</Card>
|
||||
<Card title="Shrinkwrap introduced" icon="lock">
|
||||
**1,020.6MB install**
|
||||
|
||||
`2026.5.22` added root shrinkwrap and exposed a package-shape problem:
|
||||
911.8MB landed under nested `openclaw/node_modules`.
|
||||
|
||||
</Card>
|
||||
<Card title="Latest stable" icon="tag">
|
||||
**786.9MB install**
|
||||
|
||||
`2026.5.27` reduced the peak but still installed a 675.9MB nested
|
||||
OpenClaw tree.
|
||||
|
||||
</Card>
|
||||
<Card title="Next-release state" icon="scissors">
|
||||
**407.4MB install**
|
||||
|
||||
Current `main` keeps shrinkwrap, removes the nested tree, and installs
|
||||
314 packages.
|
||||
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
<Tip>
|
||||
Shrinkwrap was not the problem by itself. The bad package shape was. Current
|
||||
`main` still ships shrinkwrap, but npm no longer materializes a second
|
||||
OpenClaw dependency tree during install.
|
||||
</Tip>
|
||||
|
||||
## What Changed After 5.27
|
||||
|
||||
The cleanup between `v2026.5.27` and current `main` removed the duplicate
|
||||
default-install graph instead of removing the capabilities themselves.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Root default graph" icon="git-branch">
|
||||
Root shrinkwrap package paths fell from **372** to **331**. Unique package
|
||||
names fell from **357** to **318**.
|
||||
</Card>
|
||||
<Card title="Direct root dependencies" icon="unplug">
|
||||
`@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`,
|
||||
`@earendil-works/pi-coding-agent`, and `pdfjs-dist` left the default root
|
||||
dependency path.
|
||||
</Card>
|
||||
<Card title="Native optional cones" icon="cpu">
|
||||
The all-platform `@napi-rs/canvas` and `@mariozechner/clipboard` native
|
||||
package cones stopped landing in the default install.
|
||||
</Card>
|
||||
<Card title="Supply-chain surface" icon="shield">
|
||||
Fewer default packages means fewer tarballs, maintainers, native binaries,
|
||||
install-time behaviors, and transitive update paths to trust by default.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
## Headline Numbers
|
||||
|
||||
Do not use the late-April broken rows as public performance baselines.
|
||||
`v2026.4.23` and `v2026.4.29` are useful regression evidence, but the large
|
||||
`14x`-style deltas mostly describe the recovery from a bad release line.
|
||||
|
||||
For the blog narrative, use the earlier April published baseline as scale:
|
||||
|
||||
| Metric | Earlier April baseline | `v2026.5.27` | Delta |
|
||||
| --------------- | ---------------------: | -----------: | -----------------------: |
|
||||
| Cold agent turn | 9,819ms | 3,378ms | 65.6% lower, 2.9x faster |
|
||||
| Warm agent turn | 7,458ms | 2,973ms | 60.1% lower, 2.5x faster |
|
||||
| Agent peak RSS | 686.2MB | 635.5MB | 7.4% lower |
|
||||
|
||||
The earlier April baseline is `v2026.4.14` from the published
|
||||
`clawgrit-reports` mock-provider run. That run used repeat 3 and failed only
|
||||
because the diagnostic timeline was not emitted; the cold, warm, and RSS
|
||||
medians are still useful as rough scale. Treat this as narrative context, not a
|
||||
release-gate statistic.
|
||||
|
||||
Within the single-sample stable May sweep, the line moved more modestly:
|
||||
|
||||
| Metric | `v2026.5.2` | `v2026.5.27` | Delta |
|
||||
| --------------- | ----------: | -----------: | ----------: |
|
||||
| Cold agent turn | 3,897ms | 3,378ms | 13.3% lower |
|
||||
| Warm agent turn | 3,610ms | 2,973ms | 17.6% lower |
|
||||
| Agent peak RSS | 613.7MB | 635.5MB | 3.6% higher |
|
||||
|
||||
Best prerelease point in the single-sample sweep:
|
||||
|
||||
| Metric | `v2026.5.27` | `v2026.5.27-beta.1` | Delta |
|
||||
| --------------- | -----------: | ------------------: | ----------: |
|
||||
| Cold agent turn | 3,378ms | 2,575ms | 23.8% lower |
|
||||
| Warm agent turn | 2,973ms | 2,217ms | 25.4% lower |
|
||||
| Agent peak RSS | 635.5MB | 635.3MB | flat |
|
||||
|
||||
### Install footprint
|
||||
|
||||
| Metric | Baseline | Current main | Delta |
|
||||
| ----------------------------------------------- | --------: | -----------: | ----------: |
|
||||
| Install size from `2026.5.22` peak | 1,020.6MB | 407.4MB | 60.1% lower |
|
||||
| Install size from latest release `2026.5.27` | 786.9MB | 407.4MB | 48.2% lower |
|
||||
| Dependencies from monthly high `2026.2.26` | 645 | 314 | 51.3% lower |
|
||||
| Dependencies from latest release `2026.5.27` | 371 | 314 | 15.4% lower |
|
||||
| Nested `openclaw/node_modules` from `2026.5.22` | 911.8MB | 0MB | removed |
|
||||
| Nested `openclaw/node_modules` from `2026.5.27` | 675.9MB | 0MB | removed |
|
||||
|
||||
### npm package size
|
||||
|
||||
| Version | Compressed tarball | Unpacked package | Files | Notes |
|
||||
| ----------- | -----------------: | ---------------: | -----: | --------------------------------- |
|
||||
| `2026.1.30` | 12.8MB | 33.5MB | 4,607 | early rebranded package |
|
||||
| `2026.2.26` | 23.6MB | 82.9MB | 10,125 | feature growth |
|
||||
| `2026.3.31` | 43.3MB | 182.6MB | 21,037 | package-size high point |
|
||||
| `2026.4.29` | 22.9MB | 74.6MB | 9,309 | package pruning visible |
|
||||
| `2026.5.12` | 23.4MB | 80.1MB | 12,035 | major external-plugin split |
|
||||
| `2026.5.22` | 17.2MB | 76.9MB | 12,386 | docs/assets excluded from package |
|
||||
| `2026.5.27` | 17.8MB | 79.0MB | 12,509 | latest stable package |
|
||||
|
||||
`2026.5.12` is the visible plugin-extraction milestone in the changelog:
|
||||
Amazon Bedrock, Bedrock Mantle, Slack, OpenShell sandbox, Anthropic Vertex,
|
||||
Matrix, and WhatsApp moved out of the core dependency path so their dependency
|
||||
cones install with those plugins instead of every core install.
|
||||
|
||||
## Kova agent turn summary
|
||||
|
||||
The April stable line contains two different stories. Earlier April was slow
|
||||
but recognizable. Late April became a regression cliff. `v2026.5.2` is where
|
||||
the mock-provider lane first drops into the 3-5s range and starts passing
|
||||
consistently in the supplied sweep.
|
||||
|
||||
Earlier published context:
|
||||
|
||||
| Release | Kova | Cold turn | Warm turn | Agent peak RSS |
|
||||
| ------------ | ---- | --------: | --------: | -------------: |
|
||||
| `v2026.4.10` | FAIL | 11,031ms | 7,962ms | 679.0MB |
|
||||
| `v2026.4.12` | FAIL | 11,965ms | 8,289ms | 713.5MB |
|
||||
| `v2026.4.14` | FAIL | 9,819ms | 7,458ms | 686.2MB |
|
||||
| `v2026.4.20` | FAIL | 22,314ms | 18,811ms | 810.8MB |
|
||||
| `v2026.4.22` | FAIL | 9,630ms | 7,459ms | 743.0MB |
|
||||
|
||||
Supplied single-sample sweep:
|
||||
|
||||
| Release | Kova | Cold turn | Warm turn | Agent peak RSS |
|
||||
| ------------------- | ---- | --------: | --------: | -------------: |
|
||||
| `v2026.4.23` | FAIL | 47,847ms | 8,010ms | 1,082.7MB |
|
||||
| `v2026.4.24` | FAIL | 48,264ms | 25,483ms | 996.0MB |
|
||||
| `v2026.4.25` | FAIL | 81,080ms | 59,172ms | 1,113.9MB |
|
||||
| `v2026.4.26` | FAIL | 76,771ms | 54,941ms | 1,140.8MB |
|
||||
| `v2026.4.27` | FAIL | 60,902ms | 33,699ms | 1,156.0MB |
|
||||
| `v2026.4.29` | FAIL | 94,031ms | 57,334ms | 3,613.7MB |
|
||||
| `v2026.5.2` | PASS | 3,897ms | 3,610ms | 613.7MB |
|
||||
| `v2026.5.7` | PASS | 3,923ms | 3,693ms | 654.1MB |
|
||||
| `v2026.5.12` | PASS | 7,248ms | 6,629ms | 834.8MB |
|
||||
| `v2026.5.18` | PASS | 3,301ms | 2,913ms | 630.3MB |
|
||||
| `v2026.5.20` | PASS | 3,413ms | 2,952ms | 643.2MB |
|
||||
| `v2026.5.22` | PASS | 4,494ms | 4,093ms | 654.3MB |
|
||||
| `v2026.5.26` | PASS | 2,626ms | 2,282ms | 660.4MB |
|
||||
| `v2026.5.27-beta.1` | PASS | 2,575ms | 2,217ms | 635.3MB |
|
||||
| `v2026.5.27` | PASS | 3,378ms | 2,973ms | 635.5MB |
|
||||
|
||||
## Source probes
|
||||
|
||||
Source probes were skipped for 17 successful older refs because those source
|
||||
trees did not yet have the required probe entry points. Agent-turn metrics still
|
||||
exist for those refs.
|
||||
|
||||
Representative source-probe points:
|
||||
|
||||
| Release | Default `readyz` p50 | 50 plugins `readyz` p50 | CLI health p50 | Plugin max RSS |
|
||||
| ------------------- | -------------------: | ----------------------: | -------------: | -------------: |
|
||||
| `v2026.4.29` | 2,819ms | 2,618ms | 1,679ms | 389.0MB |
|
||||
| `v2026.5.2` | 2,324ms | 2,013ms | 1,384ms | 377.2MB |
|
||||
| `v2026.5.7` | 1,649ms | 1,540ms | 1,175ms | 387.6MB |
|
||||
| `v2026.5.18` | 1,942ms | 1,927ms | 607ms | 426.5MB |
|
||||
| `v2026.5.20` | 1,966ms | 1,987ms | 621ms | 455.0MB |
|
||||
| `v2026.5.22` | 2,081ms | 1,884ms | 5,095ms | 444.2MB |
|
||||
| `v2026.5.26` | 1,546ms | 1,634ms | 656ms | 400.4MB |
|
||||
| `v2026.5.27-beta.1` | 1,462ms | 1,548ms | 548ms | 394.0MB |
|
||||
| `v2026.5.27` | 1,874ms | 1,925ms | 660ms | 398.0MB |
|
||||
|
||||
The `v2026.5.22` CLI health spike is visible in this table even though the
|
||||
agent-turn lane still passed. Keep the source probes when investigating
|
||||
targeted CLI or gateway regressions.
|
||||
|
||||
## Install footprint audit
|
||||
|
||||
Dependency samples use one stable release per month, plus the
|
||||
`2026.5.22` shrinkwrap-introduction event, latest `2026.5.27`, and current
|
||||
`main`.
|
||||
|
||||
| Point | Installed deps | Fresh install | OpenClaw package | Nested `openclaw/node_modules` | Root shrinkwrap | Canvas install behavior |
|
||||
| ------------------ | -------------: | ------------: | ---------------: | -----------------------------: | --------------- | ----------------------------------------- |
|
||||
| Jan `2026.1.30` | 605 | 438.4MB | 45.8MB | 2.4MB | no | top-level wrapper + `darwin-arm64` |
|
||||
| Feb `2026.2.26` | 645 | 575.7MB | 110.1MB | 3.5MB | no | top-level wrapper + `darwin-arm64` |
|
||||
| Mar `2026.3.31` | 438 | 584.1MB | 234.8MB | 0MB | no | top-level wrapper + `darwin-arm64` |
|
||||
| Apr `2026.4.29` | 392 | 335.0MB | 97.4MB | 0MB | no | none installed |
|
||||
| `2026.5.22` | 401 | 1,020.6MB | 1,020.4MB | 911.8MB | yes | nested: all 12 `@napi-rs/canvas` packages |
|
||||
| May `2026.5.26` | 371 | 767.5MB | 767.4MB | 656.4MB | yes | nested: all 12 `@napi-rs/canvas` packages |
|
||||
| Latest `2026.5.27` | 371 | 786.9MB | 786.7MB | 675.9MB | yes | nested: all 12 `@napi-rs/canvas` packages |
|
||||
| Current `main` | 314 | 407.4MB | 101.0MB | 0MB | yes | top-level wrapper + `darwin-arm64` |
|
||||
|
||||
### Shrinkwrap boundary
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Before shrinkwrap" icon="unlock">
|
||||
`2026.5.20` has no root shrinkwrap and no large nested OpenClaw dependency
|
||||
tree.
|
||||
</Card>
|
||||
<Card title="Introduced" icon="lock">
|
||||
`2026.5.22` adds root shrinkwrap and installs 911.8MB under nested
|
||||
`openclaw/node_modules`.
|
||||
</Card>
|
||||
<Card title="Latest stable" icon="tag">
|
||||
`2026.5.27` keeps shrinkwrap and still installs 675.9MB under nested
|
||||
`openclaw/node_modules`.
|
||||
</Card>
|
||||
<Card title="Current main" icon="check">
|
||||
`main` keeps shrinkwrap and removes the nested OpenClaw dependency tree.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
Published tarball inspection verifies the boundary:
|
||||
|
||||
| Version | Published stable? | Root `npm-shrinkwrap.json` | Notes |
|
||||
| ----------- | ----------------- | -------------------------- | ------------------------------------- |
|
||||
| `2026.5.20` | yes | no | last stable release before shrinkwrap |
|
||||
| `2026.5.21` | no | n/a | no stable npm release |
|
||||
| `2026.5.22` | yes | yes | shrinkwrap introduced |
|
||||
| `2026.5.23` | no | n/a | no stable npm release |
|
||||
| `2026.5.24` | no | n/a | no stable npm release |
|
||||
| `2026.5.25` | no | n/a | no stable npm release |
|
||||
| `2026.5.26` | yes | yes | nested dependency tree still present |
|
||||
| `2026.5.27` | yes | yes | nested dependency tree still present |
|
||||
| `main` | n/a | yes | nested dependency tree removed |
|
||||
|
||||
The important distinction: **shrinkwrap itself is not the problem**. Current
|
||||
`main` still ships root shrinkwrap. The problem was the package shape that made
|
||||
npm materialize a large nested OpenClaw dependency tree and all 12
|
||||
`@napi-rs/canvas` platform packages.
|
||||
|
||||
For a plain-English explanation of shrinkwrap and the maintainer-level package
|
||||
checks, see [npm shrinkwrap](/gateway/security/shrinkwrap).
|
||||
|
||||
## Supply-chain interpretation
|
||||
|
||||
Dependency count is an operational security metric, not only an install-size
|
||||
metric. Every package expands the set of maintainers, tarballs, transitive
|
||||
updates, optional native binaries, and install-time behaviors that operators
|
||||
must trust.
|
||||
|
||||
The cleanup direction is:
|
||||
|
||||
- keep heavy and optional capabilities outside the default core install
|
||||
- make plugin packages own their runtime dependency graph
|
||||
- avoid runtime package-manager repair during Gateway startup
|
||||
- preserve deterministic installs without causing all-platform native package
|
||||
materialization
|
||||
- keep install scripts disabled in package acceptance and measurement paths
|
||||
- catch nested dependency trees and native optional dependency explosions before
|
||||
publishing
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Plugin dependency resolution](/plugins/dependency-resolution)
|
||||
- [Plugin inventory](/plugins/plugin-inventory)
|
||||
- [Full release validation](/reference/full-release-validation)
|
||||
|
||||
## Unavailable performance runs
|
||||
|
||||
| Release | Run | Result | Reason |
|
||||
| ------------------- | ---------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `v2026.5.3-1` | [26561664645](https://github.com/openclaw/openclaw/actions/runs/26561664645) | failure | mock-provider job failed: CLI startup timed out waiting for qa-channel ready; no qa-channel accounts reported |
|
||||
| `v2026.5.3` | [26561666722](https://github.com/openclaw/openclaw/actions/runs/26561666722) | failure | mock-provider job failed: CLI startup timed out waiting for qa-channel ready; no qa-channel accounts reported |
|
||||
| `v2026.4.29-beta.2` | [26561683635](https://github.com/openclaw/openclaw/actions/runs/26561683635) | cancelled | optional baseline fetch hung before artifact upload |
|
||||
|
||||
## Follow-up gates
|
||||
|
||||
Recommended release checks from this sweep:
|
||||
|
||||
1. Run the mock-provider performance smoke for release candidates and retain
|
||||
artifacts.
|
||||
2. Track cold turn, warm turn, agent RSS, Gateway `readyz`, and CLI health.
|
||||
3. Fresh-install the packed tarball with scripts disabled.
|
||||
4. Record installed dependency count, install size, package size, nested
|
||||
`openclaw/node_modules` size, and native optional package shape.
|
||||
5. Fail or hold release review when nested dependency trees or all-platform
|
||||
native packages appear unexpectedly.
|
||||
@@ -136,13 +136,6 @@ bundled copy. Use `clawhub:`, `npm:`, `git:`, or `npm-pack:` when you need
|
||||
deterministic source selection. See [`openclaw plugins`](/cli/plugins#install)
|
||||
for the full command contract.
|
||||
|
||||
For npm installs, unpinned package specs and `@latest` choose the newest stable
|
||||
package that advertises compatibility with this OpenClaw build. If npm's
|
||||
current latest release declares a newer `openclaw.compat.pluginApi` or
|
||||
`openclaw.install.minHostVersion`, OpenClaw scans older stable package versions
|
||||
and installs the newest one that fits. Exact versions and explicit channel tags
|
||||
such as `@beta` stay pinned to the selected package and fail when incompatible.
|
||||
|
||||
### Configure plugin policy
|
||||
|
||||
The common plugin config shape is:
|
||||
|
||||
@@ -639,7 +639,7 @@ still need normal device approval for scope upgrades.
|
||||
- Sub-agent announce is **best-effort**. If the gateway restarts, pending "announce back" work is lost.
|
||||
- Sub-agents still share the same gateway process resources; treat `maxConcurrent` as a safety valve.
|
||||
- `sessions_spawn` is always non-blocking: it returns `{ status: "accepted", runId, childSessionKey }` immediately.
|
||||
- Sub-agent context only injects `AGENTS.md` and `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). Codex-native subagents follow the same boundary: `TOOLS.md` stays in inherited Codex thread instructions, while parent-only persona, identity, and user files are injected as turn-scoped collaboration instructions so children do not clone them.
|
||||
- OpenClaw-managed sub-agent context only injects `AGENTS.md` and `TOOLS.md` (no `SOUL.md`, `IDENTITY.md`, `USER.md`, `MEMORY.md`, `HEARTBEAT.md`, or `BOOTSTRAP.md`). Codex-native subagents inherit the native Codex thread's developer instructions, including `SOUL.md`, `IDENTITY.md`, `TOOLS.md`, and `USER.md`, so the main agent identity stays session-scoped instead of being replayed on every turn. `MEMORY.md` stays in the memory-tool or bounded turn-context path because it changes often.
|
||||
- Maximum nesting depth is 5 (`maxSpawnDepth` range: 1–5). Depth 2 is recommended for most use cases.
|
||||
- `maxChildrenPerAgent` caps active children per session (default `5`, range `1–20`).
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ generation.
|
||||
| OpenRouter | `google/veo-3.1-fast` | ✓ | Up to 4 images (first/last frame or references) | - | `OPENROUTER_API_KEY` |
|
||||
| Qwen | `wan2.6-t2v` | ✓ | Yes (remote URL) | Yes (remote URL) | `QWEN_API_KEY` |
|
||||
| Runway | `gen4.5` | ✓ | 1 image | 1 video | `RUNWAYML_API_SECRET` |
|
||||
| Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | `Wan-AI/Wan2.2-I2V-A14B` only | - | `TOGETHER_API_KEY` |
|
||||
| Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | 1 image | - | `TOGETHER_API_KEY` |
|
||||
| Vydra | `veo3` | ✓ | 1 image (`kling`) | - | `VYDRA_API_KEY` |
|
||||
| xAI | `grok-imagine-video` | ✓ | 1 first-frame image or up to 7 `reference_image`s | 1 video | `XAI_API_KEY` |
|
||||
|
||||
|
||||
@@ -271,8 +271,8 @@ describe("normalizeClaudeBackendConfig", () => {
|
||||
});
|
||||
|
||||
it("passes system prompt on every turn (issue #80374 — systemPromptWhen must be 'always')", () => {
|
||||
// Before fix this was hardcoded to "first", which silently dropped updated
|
||||
// OpenClaw system prompt context on resumed / compacted claude-cli sessions.
|
||||
// Before fix this was hardcoded to "first", which silently dropped
|
||||
// systemPromptOverride on every resumed / compacted claude-cli session.
|
||||
const backend = buildAnthropicCliBackend();
|
||||
expect(backend.config.systemPromptWhen).toBe("always");
|
||||
});
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
formatCliCommand,
|
||||
MAX_SEARCH_COUNT,
|
||||
normalizeFreshness,
|
||||
parseIsoDateRange,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readProviderEnvValue,
|
||||
readStringParam,
|
||||
resolveSearchCacheTtlMs,
|
||||
@@ -352,12 +351,7 @@ export async function executeBraveSearch(
|
||||
const braveEndpointMode = await validateBraveBaseUrl(braveBaseUrl);
|
||||
const query = readStringParam(args, "query", { required: true });
|
||||
const count =
|
||||
readPositiveIntegerParam(args, "count", {
|
||||
max: MAX_SEARCH_COUNT,
|
||||
message: `count must be an integer from 1 to ${MAX_SEARCH_COUNT}.`,
|
||||
}) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined;
|
||||
const country = normalizeBraveCountry(readStringParam(args, "country"));
|
||||
const language = readStringParam(args, "language");
|
||||
const search_lang = readStringParam(args, "search_lang");
|
||||
|
||||
@@ -28,7 +28,7 @@ const BraveSearchSchema = {
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
readNonNegativeIntegerParam,
|
||||
readPositiveIntegerParam,
|
||||
} from "openclaw/plugin-sdk/param-readers";
|
||||
import {
|
||||
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||
browserAct,
|
||||
@@ -40,15 +36,9 @@ type BrowserActRequest = Parameters<typeof browserAct>[1];
|
||||
type BrowserActRequestWithTimeout = BrowserActRequest & { timeoutMs?: number };
|
||||
|
||||
function normalizePositiveTimeoutMs(value: unknown): number | undefined {
|
||||
return readPositiveIntegerParam({ value }, "value", {
|
||||
message: "timeoutMs must be a positive integer.",
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNonNegativeDurationMs(value: unknown): number | undefined {
|
||||
return readNonNegativeIntegerParam({ value }, "value", {
|
||||
message: "timeMs must be a non-negative integer.",
|
||||
});
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0
|
||||
? Math.floor(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function supportsBrowserActTimeout(request: BrowserActRequest): boolean {
|
||||
@@ -120,7 +110,7 @@ function resolveActProxyTimeoutMs(request: BrowserActRequest): number | undefine
|
||||
candidateTimeouts.push(explicitTimeout + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
|
||||
}
|
||||
if (request.kind === "wait") {
|
||||
const waitDuration = normalizeNonNegativeDurationMs(request.timeMs);
|
||||
const waitDuration = normalizePositiveTimeoutMs(request.timeMs);
|
||||
if (waitDuration !== undefined) {
|
||||
candidateTimeouts.push(waitDuration + BROWSER_ACT_REQUEST_TIMEOUT_SLACK_MS);
|
||||
}
|
||||
@@ -356,18 +346,16 @@ export async function executeSnapshotAction(params: {
|
||||
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
|
||||
const hasMaxChars = Object.hasOwn(input, "maxChars");
|
||||
const targetId = normalizeOptionalString(input.targetId);
|
||||
const limit = readPositiveIntegerParam(input, "limit", {
|
||||
message: "limit must be a positive integer.",
|
||||
});
|
||||
const maxCharsRaw = readNonNegativeIntegerParam(input, "maxChars", {
|
||||
message: "maxChars must be a non-negative integer.",
|
||||
});
|
||||
const maxChars = maxCharsRaw !== undefined && maxCharsRaw > 0 ? maxCharsRaw : undefined;
|
||||
const limit =
|
||||
typeof input.limit === "number" && Number.isFinite(input.limit) ? input.limit : undefined;
|
||||
const maxChars =
|
||||
typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0
|
||||
? Math.floor(input.maxChars)
|
||||
: undefined;
|
||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
||||
const depth = readNonNegativeIntegerParam(input, "depth", {
|
||||
message: "depth must be a non-negative integer.",
|
||||
});
|
||||
const depth =
|
||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
||||
const selector = normalizeOptionalString(input.selector);
|
||||
const frame = normalizeOptionalString(input.frame);
|
||||
const resolvedMaxChars =
|
||||
@@ -381,9 +369,7 @@ export async function executeSnapshotAction(params: {
|
||||
? maxChars
|
||||
: undefined;
|
||||
const snapshotTimeoutMs =
|
||||
readPositiveIntegerParam(input, "timeoutMs", {
|
||||
message: "timeoutMs must be a positive integer.",
|
||||
}) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS;
|
||||
normalizePositiveTimeoutMs(input.timeoutMs) ?? DEFAULT_BROWSER_SNAPSHOT_TIMEOUT_MS;
|
||||
const snapshotQuery = {
|
||||
...(format ? { format } : {}),
|
||||
targetId,
|
||||
|
||||
@@ -13,7 +13,6 @@ export {
|
||||
imageResultFromFile,
|
||||
jsonResult,
|
||||
listNodes,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
resolveNodeIdFromList,
|
||||
selectDefaultNodeFromList,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
optionalFiniteNumberSchema,
|
||||
optionalNonNegativeIntegerSchema,
|
||||
optionalPositiveIntegerSchema,
|
||||
optionalStringEnum,
|
||||
stringEnum,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import { optionalStringEnum, stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import { Type } from "typebox";
|
||||
|
||||
const BROWSER_ACT_KINDS = [
|
||||
@@ -62,15 +56,15 @@ const BrowserActSchema = Type.Object({
|
||||
doubleClick: Type.Optional(Type.Boolean()),
|
||||
button: Type.Optional(Type.String()),
|
||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
||||
x: optionalFiniteNumberSchema(),
|
||||
y: optionalFiniteNumberSchema(),
|
||||
x: Type.Optional(Type.Number()),
|
||||
y: Type.Optional(Type.Number()),
|
||||
// type
|
||||
text: Type.Optional(Type.String()),
|
||||
submit: Type.Optional(Type.Boolean()),
|
||||
slowly: Type.Optional(Type.Boolean()),
|
||||
// press
|
||||
key: Type.Optional(Type.String()),
|
||||
delayMs: optionalNonNegativeIntegerSchema(),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
// drag
|
||||
startRef: Type.Optional(Type.String()),
|
||||
endRef: Type.Optional(Type.String()),
|
||||
@@ -79,15 +73,15 @@ const BrowserActSchema = Type.Object({
|
||||
// fill - use permissive array of objects
|
||||
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
|
||||
// resize
|
||||
width: optionalPositiveIntegerSchema(),
|
||||
height: optionalPositiveIntegerSchema(),
|
||||
width: Type.Optional(Type.Number()),
|
||||
height: Type.Optional(Type.Number()),
|
||||
// wait
|
||||
timeMs: optionalNonNegativeIntegerSchema(),
|
||||
timeMs: Type.Optional(Type.Number()),
|
||||
selector: Type.Optional(Type.String()),
|
||||
url: Type.Optional(Type.String()),
|
||||
loadState: Type.Optional(Type.String()),
|
||||
textGone: Type.Optional(Type.String()),
|
||||
timeoutMs: optionalPositiveIntegerSchema(),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
// evaluate
|
||||
fn: Type.Optional(Type.String()),
|
||||
});
|
||||
@@ -104,14 +98,14 @@ export const BrowserToolSchema = Type.Object({
|
||||
url: Type.Optional(Type.String()),
|
||||
targetId: Type.Optional(Type.String()),
|
||||
label: Type.Optional(Type.String()),
|
||||
limit: optionalPositiveIntegerSchema(),
|
||||
maxChars: optionalNonNegativeIntegerSchema(),
|
||||
limit: Type.Optional(Type.Number()),
|
||||
maxChars: Type.Optional(Type.Number()),
|
||||
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
|
||||
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
|
||||
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
|
||||
interactive: Type.Optional(Type.Boolean()),
|
||||
compact: Type.Optional(Type.Boolean()),
|
||||
depth: optionalNonNegativeIntegerSchema(),
|
||||
depth: Type.Optional(Type.Number()),
|
||||
selector: Type.Optional(Type.String()),
|
||||
frame: Type.Optional(Type.String()),
|
||||
labels: Type.Optional(Type.Boolean()),
|
||||
@@ -123,7 +117,7 @@ export const BrowserToolSchema = Type.Object({
|
||||
level: Type.Optional(Type.String()),
|
||||
paths: Type.Optional(Type.Array(Type.String())),
|
||||
inputRef: Type.Optional(Type.String()),
|
||||
timeoutMs: optionalPositiveIntegerSchema(),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
dialogId: Type.Optional(Type.String()),
|
||||
accept: Type.Optional(Type.Boolean()),
|
||||
promptText: Type.Optional(Type.String()),
|
||||
@@ -132,20 +126,20 @@ export const BrowserToolSchema = Type.Object({
|
||||
doubleClick: Type.Optional(Type.Boolean()),
|
||||
button: Type.Optional(Type.String()),
|
||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
||||
x: optionalFiniteNumberSchema(),
|
||||
y: optionalFiniteNumberSchema(),
|
||||
x: Type.Optional(Type.Number()),
|
||||
y: Type.Optional(Type.Number()),
|
||||
text: Type.Optional(Type.String()),
|
||||
submit: Type.Optional(Type.Boolean()),
|
||||
slowly: Type.Optional(Type.Boolean()),
|
||||
key: Type.Optional(Type.String()),
|
||||
delayMs: optionalNonNegativeIntegerSchema(),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
startRef: Type.Optional(Type.String()),
|
||||
endRef: Type.Optional(Type.String()),
|
||||
values: Type.Optional(Type.Array(Type.String())),
|
||||
fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))),
|
||||
width: optionalPositiveIntegerSchema(),
|
||||
height: optionalPositiveIntegerSchema(),
|
||||
timeMs: optionalNonNegativeIntegerSchema(),
|
||||
width: Type.Optional(Type.Number()),
|
||||
height: Type.Optional(Type.Number()),
|
||||
timeMs: Type.Optional(Type.Number()),
|
||||
textGone: Type.Optional(Type.String()),
|
||||
loadState: Type.Optional(Type.String()),
|
||||
fn: Type.Optional(Type.String()),
|
||||
|
||||
@@ -204,26 +204,6 @@ vi.mock("./browser-tool.runtime.js", () => {
|
||||
listNodes: nodesUtilsMocks.listNodes,
|
||||
normalizeOptionalString: (value: unknown) => readStringValue(value)?.trim() || undefined,
|
||||
persistBrowserProxyFiles: vi.fn(async () => new Map<string, string>()),
|
||||
readPositiveIntegerParam: (
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
options?: { message?: string },
|
||||
) => {
|
||||
const raw = params[key];
|
||||
if (raw == null) {
|
||||
return undefined;
|
||||
}
|
||||
const value =
|
||||
typeof raw === "number"
|
||||
? raw
|
||||
: typeof raw === "string" && /^\d+$/.test(raw.trim())
|
||||
? Number(raw.trim())
|
||||
: undefined;
|
||||
if (value === undefined || !Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(options?.message ?? `${key} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
readStringParam,
|
||||
readStringValue,
|
||||
resolveExistingPathsWithinRoot: vi.fn(async ({ requestedPaths }) => ({
|
||||
@@ -478,44 +458,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(opts.maxChars).toBe(override);
|
||||
});
|
||||
|
||||
it("parses string snapshot numeric options", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
target: "host",
|
||||
snapshotFormat: "ai",
|
||||
depth: "2",
|
||||
limit: "4",
|
||||
maxChars: "2000",
|
||||
timeoutMs: "9000",
|
||||
});
|
||||
|
||||
const opts = lastMockCallArg<{
|
||||
depth?: number;
|
||||
limit?: number;
|
||||
maxChars?: number;
|
||||
timeoutMs?: number;
|
||||
}>(browserClientMocks.browserSnapshot, 1);
|
||||
expect(opts.depth).toBe(2);
|
||||
expect(opts.limit).toBe(4);
|
||||
expect(opts.maxChars).toBe(2000);
|
||||
expect(opts.timeoutMs).toBe(9000);
|
||||
});
|
||||
|
||||
it("rejects fractional snapshot numeric options", async () => {
|
||||
const tool = createBrowserTool();
|
||||
|
||||
await expect(
|
||||
tool.execute?.("call-1", {
|
||||
action: "snapshot",
|
||||
target: "host",
|
||||
snapshotFormat: "ai",
|
||||
maxChars: 12.5,
|
||||
}),
|
||||
).rejects.toThrow("maxChars must be a non-negative integer.");
|
||||
expect(browserClientMocks.browserSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the default when maxChars is explicitly zero", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
@@ -574,37 +516,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(opts.timeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("parses string top-level timeoutMs values", async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "open",
|
||||
profile: "user",
|
||||
url: "https://example.com",
|
||||
timeoutMs: "60000",
|
||||
});
|
||||
|
||||
const opts = lastMockCallArg<{ profile?: string; timeoutMs?: number }>(
|
||||
browserClientMocks.browserOpenTab,
|
||||
2,
|
||||
);
|
||||
expect(opts.timeoutMs).toBe(60_000);
|
||||
});
|
||||
|
||||
it("rejects fractional top-level timeoutMs values", async () => {
|
||||
const tool = createBrowserTool();
|
||||
|
||||
await expect(
|
||||
tool.execute?.("call-1", {
|
||||
action: "profiles",
|
||||
timeoutMs: 12.5,
|
||||
}),
|
||||
).rejects.toThrow("timeoutMs must be a positive integer.");
|
||||
expect(browserClientMocks.browserProfiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes top-level timeoutMs through to close without targetId", async () => {
|
||||
setResolvedBrowserProfiles({
|
||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
@@ -891,22 +802,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(opts.timeoutMs).toBe(12_345);
|
||||
});
|
||||
|
||||
it("parses string screenshot timeoutMs values", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "screenshot",
|
||||
target: "host",
|
||||
targetId: "tab-1",
|
||||
timeoutMs: "12345",
|
||||
});
|
||||
|
||||
const opts = lastMockCallArg<{ targetId?: string; timeoutMs?: number }>(
|
||||
browserActionsMocks.browserScreenshotAction,
|
||||
1,
|
||||
);
|
||||
expect(opts.timeoutMs).toBe(12_345);
|
||||
});
|
||||
|
||||
it("passes configured image sanitization to screenshot image results", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
@@ -1372,40 +1267,6 @@ describe("browser tool act compatibility", () => {
|
||||
expect(request.params?.body).toEqual({ kind: "wait", timeMs: 20_000, timeoutMs: 45_000 });
|
||||
expect(request.params?.timeoutMs).toBe(45_000 + 5_000);
|
||||
});
|
||||
|
||||
it("honors string act request timeouts when sizing node proxy calls", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
target: "node",
|
||||
request: { kind: "wait", timeMs: "20000", timeoutMs: "45000" },
|
||||
});
|
||||
|
||||
const { options, request } = lastNodeInvokeCall();
|
||||
expect(options.timeoutMs).toBe(55_000);
|
||||
expect(request.params?.path).toBe("/act");
|
||||
expect(request.params?.body).toEqual({
|
||||
kind: "wait",
|
||||
timeMs: "20000",
|
||||
timeoutMs: "45000",
|
||||
});
|
||||
expect(request.params?.timeoutMs).toBe(50_000);
|
||||
});
|
||||
|
||||
it("rejects fractional act request timeouts before node proxy calls", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool();
|
||||
|
||||
await expect(
|
||||
tool.execute?.("call-1", {
|
||||
action: "act",
|
||||
target: "node",
|
||||
request: { kind: "wait", timeMs: "20000", timeoutMs: "45000.5" },
|
||||
}),
|
||||
).rejects.toThrow("timeoutMs must be a positive integer.");
|
||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser tool snapshot labels", () => {
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
listNodes,
|
||||
normalizeOptionalString,
|
||||
persistBrowserProxyFiles,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
readStringValue,
|
||||
resolveBrowserConfig,
|
||||
@@ -129,9 +128,10 @@ export const testing = {
|
||||
|
||||
function readOptionalTargetAndTimeout(params: Record<string, unknown>) {
|
||||
const targetId = normalizeOptionalString(params.targetId);
|
||||
const timeoutMs = readPositiveIntegerParam(params, "timeoutMs", {
|
||||
message: "timeoutMs must be a positive integer.",
|
||||
});
|
||||
const timeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? params.timeoutMs
|
||||
: undefined;
|
||||
return { targetId, timeoutMs };
|
||||
}
|
||||
|
||||
@@ -422,9 +422,9 @@ function usesExistingSessionManageFlow(params: { action: string; profileName?: s
|
||||
}
|
||||
|
||||
function readToolTimeoutMs(params: Record<string, unknown>) {
|
||||
return readPositiveIntegerParam(params, "timeoutMs", {
|
||||
message: "timeoutMs must be a positive integer.",
|
||||
});
|
||||
return typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function createBrowserTool(opts?: {
|
||||
@@ -735,7 +735,11 @@ export function createBrowserTool(opts?: {
|
||||
const element = readStringParam(params, "element");
|
||||
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
||||
const type = params.type === "jpeg" ? "jpeg" : "png";
|
||||
const effectiveTimeoutMs = requestedTimeoutMs ?? DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS;
|
||||
const timeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: undefined;
|
||||
const effectiveTimeoutMs = timeoutMs ?? DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS;
|
||||
const result = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "POST",
|
||||
|
||||
@@ -8,7 +8,6 @@ export type { AnyAgentTool, NodeListNode } from "openclaw/plugin-sdk/agent-harne
|
||||
export {
|
||||
imageResultFromFile,
|
||||
jsonResult,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
export { optionalStringEnum, stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
|
||||
@@ -145,74 +145,6 @@ describe("byteplus video generation provider", () => {
|
||||
expect(body.camera_fixed).toBe(false);
|
||||
});
|
||||
|
||||
it("drops malformed seed values before creating videos", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A cinematic lobster montage",
|
||||
providerOptions: {
|
||||
seed: 1.5,
|
||||
},
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(requireBytePlusPostBody()).not.toHaveProperty("seed");
|
||||
});
|
||||
|
||||
it("drops out-of-range duration values before creating videos", async () => {
|
||||
mockSuccessfulBytePlusTask({ model: "seedance-1-0-pro-250528" });
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-pro-250528",
|
||||
prompt: "A cinematic lobster montage",
|
||||
durationSeconds: 99,
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(requireBytePlusPostBody()).not.toHaveProperty("duration");
|
||||
});
|
||||
|
||||
it("drops malformed response duration metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
fetchWithTimeoutMock
|
||||
.mockResolvedValueOnce({
|
||||
json: async () => ({
|
||||
id: "task_123",
|
||||
status: "succeeded",
|
||||
content: {
|
||||
video_url: "https://example.com/byteplus.mp4",
|
||||
},
|
||||
duration: 1.5,
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
headers: new Headers({ "content-type": "video/mp4" }),
|
||||
arrayBuffer: async () => Buffer.from("mp4-bytes"),
|
||||
});
|
||||
|
||||
const provider = buildBytePlusVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "byteplus",
|
||||
model: "seedance-1-0-lite-t2v-250428",
|
||||
prompt: "A lantern floats upward into the night sky",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.metadata).toMatchObject({ duration: undefined });
|
||||
});
|
||||
|
||||
it("reports malformed create JSON with a provider-owned error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
waitProviderOperationPollInterval,
|
||||
type ProviderOperationTimeoutMs,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
asSafeIntegerInRange,
|
||||
isRecord,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
GeneratedVideoAsset,
|
||||
VideoGenerationProvider,
|
||||
@@ -29,9 +25,6 @@ const DEFAULT_BYTEPLUS_VIDEO_MODEL = "seedance-1-0-lite-t2v-250428";
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const MAX_POLL_ATTEMPTS = 120;
|
||||
const BYTEPLUS_SEED_MAX = 2_147_483_647;
|
||||
const BYTEPLUS_MIN_DURATION_SECONDS = 2;
|
||||
const BYTEPLUS_MAX_DURATION_SECONDS = 12;
|
||||
|
||||
type BytePlusTaskCreateResponse = {
|
||||
id?: unknown;
|
||||
@@ -123,27 +116,6 @@ function resolveBytePlusImageUrl(req: VideoGenerationRequest): string | undefine
|
||||
return toDataUrl(input.buffer, normalizeOptionalString(input.mimeType) ?? "image/png");
|
||||
}
|
||||
|
||||
function resolveBytePlusSeed(value: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(value, { min: -1, max: BYTEPLUS_SEED_MAX });
|
||||
}
|
||||
|
||||
function resolveBytePlusDurationSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return asSafeIntegerInRange(Math.round(value), {
|
||||
min: BYTEPLUS_MIN_DURATION_SECONDS,
|
||||
max: BYTEPLUS_MAX_DURATION_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
function readBytePlusDurationSeconds(value: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(value, {
|
||||
min: BYTEPLUS_MIN_DURATION_SECONDS,
|
||||
max: BYTEPLUS_MAX_DURATION_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
async function pollBytePlusTask(params: {
|
||||
taskId: string;
|
||||
headers: Headers;
|
||||
@@ -325,9 +297,8 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
if (resolution) {
|
||||
body.resolution = resolution;
|
||||
}
|
||||
const duration = resolveBytePlusDurationSeconds(req.durationSeconds);
|
||||
if (duration !== undefined) {
|
||||
body.duration = duration;
|
||||
if (typeof req.durationSeconds === "number" && Number.isFinite(req.durationSeconds)) {
|
||||
body.duration = Math.max(1, Math.round(req.durationSeconds));
|
||||
}
|
||||
if (typeof req.audio === "boolean") {
|
||||
body.generate_audio = req.audio;
|
||||
@@ -339,7 +310,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
// Forward declared providerOptions: seed, draft, camerafixed.
|
||||
// draft=true forces 480p resolution for faster generation.
|
||||
const opts = req.providerOptions ?? {};
|
||||
const seed = resolveBytePlusSeed(opts.seed);
|
||||
const seed = typeof opts.seed === "number" ? opts.seed : undefined;
|
||||
const draft = opts.draft === true;
|
||||
// Official JSON body field is camera_fixed (with underscore).
|
||||
const cameraFixed = typeof opts.camera_fixed === "boolean" ? opts.camera_fixed : undefined;
|
||||
@@ -403,7 +374,7 @@ export function buildBytePlusVideoGenerationProvider(): VideoGenerationProvider
|
||||
videoUrl,
|
||||
ratio: normalizeOptionalString(completed.ratio),
|
||||
resolution: normalizeOptionalString(completed.resolution),
|
||||
duration: readBytePlusDurationSeconds(completed.duration),
|
||||
duration: typeof completed.duration === "number" ? completed.duration : undefined,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import {
|
||||
optionalFiniteNumberSchema,
|
||||
optionalNonNegativeIntegerSchema,
|
||||
optionalPositiveIntegerSchema,
|
||||
stringEnum,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import { Type } from "typebox";
|
||||
|
||||
export const CANVAS_ACTIONS = [
|
||||
@@ -18,23 +12,30 @@ export const CANVAS_ACTIONS = [
|
||||
|
||||
export const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
|
||||
|
||||
function stringEnum<T extends readonly string[]>(values: T) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
});
|
||||
}
|
||||
|
||||
export const CanvasToolSchema = Type.Object({
|
||||
action: stringEnum(CANVAS_ACTIONS),
|
||||
gatewayUrl: Type.Optional(Type.String()),
|
||||
gatewayToken: Type.Optional(Type.String()),
|
||||
timeoutMs: optionalPositiveIntegerSchema(),
|
||||
timeoutMs: Type.Optional(Type.Number()),
|
||||
node: Type.Optional(Type.String()),
|
||||
target: Type.Optional(Type.String()),
|
||||
x: optionalFiniteNumberSchema(),
|
||||
y: optionalFiniteNumberSchema(),
|
||||
width: optionalFiniteNumberSchema(),
|
||||
height: optionalFiniteNumberSchema(),
|
||||
x: Type.Optional(Type.Number()),
|
||||
y: Type.Optional(Type.Number()),
|
||||
width: Type.Optional(Type.Number()),
|
||||
height: Type.Optional(Type.Number()),
|
||||
url: Type.Optional(Type.String()),
|
||||
javaScript: Type.Optional(Type.String()),
|
||||
outputFormat: Type.Optional(stringEnum(CANVAS_SNAPSHOT_FORMATS)),
|
||||
maxWidth: optionalPositiveIntegerSchema(),
|
||||
quality: optionalFiniteNumberSchema({ minimum: 0, maximum: 1 }),
|
||||
delayMs: optionalNonNegativeIntegerSchema(),
|
||||
maxWidth: Type.Optional(Type.Number()),
|
||||
quality: Type.Optional(Type.Number()),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
jsonl: Type.Optional(Type.String()),
|
||||
jsonlPath: Type.Optional(Type.String()),
|
||||
});
|
||||
|
||||
@@ -97,72 +97,6 @@ describe("Canvas tool", () => {
|
||||
expect(imageResultParams?.imageSanitization).toEqual({ maxDimensionPx: 1600 });
|
||||
});
|
||||
|
||||
it("normalizes numeric string params before invoking node canvas commands", async () => {
|
||||
mocks.callGatewayTool.mockResolvedValue({
|
||||
payload: {
|
||||
format: "png",
|
||||
base64: Buffer.from("not-a-real-png").toString("base64"),
|
||||
},
|
||||
});
|
||||
const tool = createCanvasTool();
|
||||
|
||||
await tool.execute("tool-call-1", {
|
||||
action: "present",
|
||||
timeoutMs: "1500",
|
||||
x: "10.5",
|
||||
y: "-2",
|
||||
width: "640",
|
||||
height: "480",
|
||||
});
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenLastCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 1500 },
|
||||
expect.objectContaining({
|
||||
command: "canvas.present",
|
||||
params: {
|
||||
placement: {
|
||||
x: 10.5,
|
||||
y: -2,
|
||||
width: 640,
|
||||
height: 480,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await tool.execute("tool-call-2", {
|
||||
action: "snapshot",
|
||||
maxWidth: "800",
|
||||
quality: "0.75",
|
||||
});
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenLastCalledWith(
|
||||
"node.invoke",
|
||||
{},
|
||||
expect.objectContaining({
|
||||
command: "canvas.snapshot",
|
||||
params: {
|
||||
format: "png",
|
||||
maxWidth: 800,
|
||||
quality: 0.75,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed numeric canvas params before invoking node commands", async () => {
|
||||
const tool = createCanvasTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("tool-call-1", {
|
||||
action: "snapshot",
|
||||
maxWidth: "800px",
|
||||
}),
|
||||
).rejects.toThrow("maxWidth must be a positive integer");
|
||||
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects node-controlled snapshot formats before creating image results", async () => {
|
||||
mocks.callGatewayTool.mockResolvedValue({
|
||||
payload: {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
jsonResult,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import { readFiniteNumberParam, readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import type { AnyAgentTool, OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { normalizeCanvasSnapshotFileExtension, parseCanvasSnapshotPayload } from "./cli-helpers.js";
|
||||
@@ -30,7 +29,7 @@ function readGatewayCallOptions(params: Record<string, unknown>) {
|
||||
return {
|
||||
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
|
||||
timeoutMs: readPositiveIntegerParam(params, "timeoutMs"),
|
||||
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,10 +114,10 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool {
|
||||
switch (action) {
|
||||
case "present": {
|
||||
const placement = {
|
||||
x: readFiniteNumberParam(params, "x"),
|
||||
y: readFiniteNumberParam(params, "y"),
|
||||
width: readFiniteNumberParam(params, "width"),
|
||||
height: readFiniteNumberParam(params, "height"),
|
||||
x: typeof params.x === "number" ? params.x : undefined,
|
||||
y: typeof params.y === "number" ? params.y : undefined,
|
||||
width: typeof params.width === "number" ? params.width : undefined,
|
||||
height: typeof params.height === "number" ? params.height : undefined,
|
||||
};
|
||||
const invokeParams: Record<string, unknown> = {};
|
||||
const presentTarget =
|
||||
@@ -170,11 +169,14 @@ export function createCanvasTool(options?: CanvasToolOptions): AnyAgentTool {
|
||||
? params.outputFormat.trim().toLowerCase()
|
||||
: "png";
|
||||
const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
|
||||
const maxWidth = readPositiveIntegerParam(params, "maxWidth");
|
||||
const quality = readFiniteNumberParam(params, "quality", {
|
||||
min: 0,
|
||||
max: 1,
|
||||
});
|
||||
const maxWidth =
|
||||
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
|
||||
? params.maxWidth
|
||||
: undefined;
|
||||
const quality =
|
||||
typeof params.quality === "number" && Number.isFinite(params.quality)
|
||||
? params.quality
|
||||
: undefined;
|
||||
const raw = (await invoke("canvas.snapshot", {
|
||||
format,
|
||||
maxWidth,
|
||||
|
||||
@@ -145,41 +145,6 @@ describe("chutes-models", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back from malformed live token metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [
|
||||
{
|
||||
id: "provider/bad-window",
|
||||
context_length: -1,
|
||||
max_output_length: 16384.5,
|
||||
},
|
||||
{
|
||||
id: "provider/bad-max-output",
|
||||
context_length: Number.POSITIVE_INFINITY,
|
||||
max_output_length: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("malformed-token-metadata");
|
||||
|
||||
expect(requireChutesModel(models, 0)).toMatchObject({
|
||||
id: "provider/bad-window",
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
expect(requireChutesModel(models, 1)).toMatchObject({
|
||||
id: "provider/bad-max-output",
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("discoverChutesModels retries without auth on 401", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((url, init) => {
|
||||
if (init?.headers?.Authorization === "Bearer test-token-error") {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
asPositiveSafeInteger,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -617,9 +616,8 @@ export async function discoverChutesModels(accessToken?: string): Promise<ModelD
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow:
|
||||
asPositiveSafeInteger(entry.context_length) ?? CHUTES_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: asPositiveSafeInteger(entry.max_output_length) ?? CHUTES_DEFAULT_MAX_TOKENS,
|
||||
contextWindow: entry.context_length || CHUTES_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: entry.max_output_length || CHUTES_DEFAULT_MAX_TOKENS,
|
||||
compat: {
|
||||
supportsUsageInStreaming: false,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
callGatewayTool,
|
||||
hasNativeHookRelayInvocation,
|
||||
invokeNativeHookRelay,
|
||||
resolveNativeHookRelayDeferredToolApproval,
|
||||
runBeforeToolCallHook,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
@@ -14,7 +13,6 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => (
|
||||
callGatewayTool: vi.fn(),
|
||||
hasNativeHookRelayInvocation: vi.fn(() => false),
|
||||
invokeNativeHookRelay: vi.fn(),
|
||||
resolveNativeHookRelayDeferredToolApproval: vi.fn(),
|
||||
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
|
||||
blocked: false,
|
||||
params,
|
||||
@@ -24,9 +22,6 @@ vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => (
|
||||
const mockCallGatewayTool = vi.mocked(callGatewayTool);
|
||||
const mockHasNativeHookRelayInvocation = vi.mocked(hasNativeHookRelayInvocation);
|
||||
const mockInvokeNativeHookRelay = vi.mocked(invokeNativeHookRelay);
|
||||
const mockResolveNativeHookRelayDeferredToolApproval = vi.mocked(
|
||||
resolveNativeHookRelayDeferredToolApproval,
|
||||
);
|
||||
const mockRunBeforeToolCallHook = vi.mocked(runBeforeToolCallHook);
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
@@ -108,8 +103,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
mockHasNativeHookRelayInvocation.mockReset();
|
||||
mockHasNativeHookRelayInvocation.mockReturnValue(false);
|
||||
mockInvokeNativeHookRelay.mockReset();
|
||||
mockResolveNativeHookRelayDeferredToolApproval.mockReset();
|
||||
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValue(undefined);
|
||||
mockRunBeforeToolCallHook.mockReset();
|
||||
mockRunBeforeToolCallHook.mockImplementation(async ({ params }) => ({
|
||||
blocked: false,
|
||||
@@ -139,7 +132,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(mockRunBeforeToolCallHook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolName: "exec",
|
||||
approvalMode: "request",
|
||||
approvalMode: "report",
|
||||
}),
|
||||
);
|
||||
findApprovalEvent(params, {
|
||||
@@ -219,7 +212,7 @@ describe("Codex app-server approval bridge", () => {
|
||||
},
|
||||
},
|
||||
toolCallId: "cmd-1",
|
||||
approvalMode: "request",
|
||||
approvalMode: "report",
|
||||
signal: undefined,
|
||||
ctx: {
|
||||
agentId: "main",
|
||||
@@ -392,11 +385,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
||||
expect(mockInvokeNativeHookRelay).toHaveBeenCalledTimes(1);
|
||||
expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({
|
||||
relayId: "relay-1",
|
||||
toolUseId: "cmd-native-relay-noop",
|
||||
signal: undefined,
|
||||
});
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
@@ -444,53 +432,12 @@ describe("Codex app-server approval bridge", () => {
|
||||
event: "pre_tool_use",
|
||||
toolUseId: "cmd-native-relay-observed",
|
||||
});
|
||||
expect(mockResolveNativeHookRelayDeferredToolApproval).toHaveBeenCalledWith({
|
||||
relayId: "relay-1",
|
||||
toolUseId: "cmd-native-relay-observed",
|
||||
signal: undefined,
|
||||
});
|
||||
expect(mockCallGatewayTool.mock.calls.map(([method]) => method)).toEqual([
|
||||
"plugin.approval.request",
|
||||
"plugin.approval.waitDecision",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts command approvals from deferred native PreToolUse plugin approvals", async () => {
|
||||
const params = createParams();
|
||||
mockHasNativeHookRelayInvocation.mockReturnValueOnce(true);
|
||||
mockResolveNativeHookRelayDeferredToolApproval.mockResolvedValueOnce({
|
||||
handled: true,
|
||||
outcome: "approved-once",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-native-relay-deferred",
|
||||
command: "pnpm test extensions/codex/src/app-server",
|
||||
cwd: "/workspace",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
nativeHookRelay: {
|
||||
relayId: "relay-1",
|
||||
allowedEvents: ["pre_tool_use"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockRunBeforeToolCallHook).not.toHaveBeenCalled();
|
||||
expect(mockInvokeNativeHookRelay).not.toHaveBeenCalled();
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
findApprovalEvent(params, {
|
||||
status: "approved",
|
||||
message: "Codex app-server approval granted for this turn.",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when the native hook relay returns unreadable approval output", async () => {
|
||||
const params = createParams();
|
||||
mockInvokeNativeHookRelay.mockResolvedValueOnce({
|
||||
@@ -729,43 +676,6 @@ describe("Codex app-server approval bridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps OpenClaw plugin allow-always approvals scoped to one Codex request", async () => {
|
||||
const params = createParams();
|
||||
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
||||
blocked: false,
|
||||
params: {
|
||||
command: "pnpm test",
|
||||
approval: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-needs-approval",
|
||||
command: "pnpm test",
|
||||
},
|
||||
},
|
||||
approvalResolution: "allow-always",
|
||||
});
|
||||
|
||||
const result = await handleCodexAppServerApprovalRequest({
|
||||
method: "item/commandExecution/requestApproval",
|
||||
requestParams: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-needs-approval",
|
||||
command: "pnpm test",
|
||||
},
|
||||
paramsForRun: params,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ decision: "accept" });
|
||||
expect(mockCallGatewayTool).not.toHaveBeenCalled();
|
||||
findApprovalEvent(params, {
|
||||
status: "approved",
|
||||
message: "Codex app-server approval granted for this turn.",
|
||||
});
|
||||
});
|
||||
|
||||
it("denies command approvals when OpenClaw tool policy requires approval", async () => {
|
||||
const params = createParams();
|
||||
mockRunBeforeToolCallHook.mockResolvedValueOnce({
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
formatApprovalDisplayPath,
|
||||
hasNativeHookRelayInvocation,
|
||||
invokeNativeHookRelay,
|
||||
resolveNativeHookRelayDeferredToolApproval,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type NativeHookRelayProcessResponse,
|
||||
type NativeHookRelayRegistrationHandle,
|
||||
@@ -102,21 +101,6 @@ export async function handleCodexAppServerApprovalRequest(params: {
|
||||
});
|
||||
return buildApprovalResponse(params.method, context.requestParams, "denied");
|
||||
}
|
||||
if (
|
||||
policyOutcome?.outcome === "approved-once" ||
|
||||
policyOutcome?.outcome === "approved-session"
|
||||
) {
|
||||
emitApprovalEvent(params.paramsForRun, {
|
||||
phase: "resolved",
|
||||
kind: context.kind,
|
||||
status: "approved",
|
||||
title: context.title,
|
||||
...context.eventDetails,
|
||||
...approvalEventScope(params.method, policyOutcome.outcome),
|
||||
message: approvalResolutionMessage(policyOutcome.outcome),
|
||||
});
|
||||
return buildApprovalResponse(params.method, context.requestParams, policyOutcome.outcome);
|
||||
}
|
||||
if (params.autoApprove === true) {
|
||||
emitApprovalEvent(params.paramsForRun, {
|
||||
phase: "resolved",
|
||||
@@ -328,10 +312,7 @@ function buildApprovalContext(params: {
|
||||
}
|
||||
|
||||
type ApprovalContext = ReturnType<typeof buildApprovalContext>;
|
||||
type ApprovalPolicyOutcome =
|
||||
| { outcome: "denied"; reason: string }
|
||||
| { outcome: "approved-once" | "approved-session" }
|
||||
| { outcome: "no-decision" };
|
||||
type ApprovalPolicyOutcome = { outcome: "denied"; reason: string } | { outcome: "no-decision" };
|
||||
|
||||
async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
method: string;
|
||||
@@ -356,17 +337,10 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
policyRequest,
|
||||
nativeHookRelay: params.nativeHookRelay,
|
||||
cwd,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (nativeRelayOutcome?.blocked) {
|
||||
return { outcome: "denied", reason: nativeRelayOutcome.reason };
|
||||
}
|
||||
if (
|
||||
nativeRelayOutcome?.outcome === "approved-once" ||
|
||||
nativeRelayOutcome?.outcome === "approved-session"
|
||||
) {
|
||||
return { outcome: nativeRelayOutcome.outcome };
|
||||
}
|
||||
if (nativeRelayOutcome?.handled) {
|
||||
return { outcome: "no-decision" };
|
||||
}
|
||||
@@ -381,7 +355,7 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
toolName: policyRequest.toolName,
|
||||
params: policyRequest.params,
|
||||
...(params.context.itemId ? { toolCallId: params.context.itemId } : {}),
|
||||
approvalMode: "request",
|
||||
approvalMode: "report",
|
||||
signal: params.signal,
|
||||
ctx: {
|
||||
...(params.paramsForRun.agentId ? { agentId: params.paramsForRun.agentId } : {}),
|
||||
@@ -403,13 +377,6 @@ async function runOpenClawToolPolicyForApprovalRequest(params: {
|
||||
"OpenClaw tool policy rewrote Codex app-server approval params; refusing original request.",
|
||||
};
|
||||
}
|
||||
if (outcome.approvalResolution) {
|
||||
return {
|
||||
// Generic plugin approval `allow-always` is plugin-owned durability, not
|
||||
// Codex session trust. Keep the app-server request scoped to this item.
|
||||
outcome: "approved-once",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -423,7 +390,6 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
"allowedEvents" | "generation" | "relayId"
|
||||
>;
|
||||
cwd?: string;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<
|
||||
| {
|
||||
handled: true;
|
||||
@@ -433,7 +399,6 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
| {
|
||||
handled: true;
|
||||
blocked?: false;
|
||||
outcome?: "approved-once" | "approved-session";
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
@@ -461,17 +426,6 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
toolUseId: params.context.itemId,
|
||||
})
|
||||
) {
|
||||
const approvalOutcome = await resolveNativeHookRelayDeferredToolApproval({
|
||||
relayId: params.nativeHookRelay.relayId,
|
||||
toolUseId: params.context.itemId,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (approvalOutcome?.outcome === "denied") {
|
||||
return { handled: true, blocked: true, reason: approvalOutcome.reason };
|
||||
}
|
||||
if (approvalOutcome?.outcome === "approved-once") {
|
||||
return { handled: true, outcome: approvalOutcome.outcome };
|
||||
}
|
||||
return { handled: true };
|
||||
}
|
||||
try {
|
||||
@@ -487,17 +441,6 @@ async function runNativeRelayToolPolicyForApprovalRequest(params: {
|
||||
if (decision.blocked) {
|
||||
return { handled: true, blocked: true, reason: decision.reason };
|
||||
}
|
||||
const approvalOutcome = await resolveNativeHookRelayDeferredToolApproval({
|
||||
relayId: params.nativeHookRelay.relayId,
|
||||
toolUseId: params.context.itemId,
|
||||
signal: params.signal,
|
||||
});
|
||||
if (approvalOutcome?.outcome === "denied") {
|
||||
return { handled: true, blocked: true, reason: approvalOutcome.reason };
|
||||
}
|
||||
if (approvalOutcome?.outcome === "approved-once") {
|
||||
return { handled: true, outcome: approvalOutcome.outcome };
|
||||
}
|
||||
return { handled: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
} from "./thread-lifecycle.js";
|
||||
|
||||
const CODEX_NATIVE_PROJECT_DOC_BASENAMES = new Set(["agents.md"]);
|
||||
const CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set(["tools.md"]);
|
||||
const CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
const CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
"identity.md",
|
||||
"soul.md",
|
||||
"tools.md",
|
||||
"user.md",
|
||||
]);
|
||||
const CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set<string>();
|
||||
const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
|
||||
...CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
...CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
|
||||
@@ -634,7 +635,7 @@ function renderCodexWorkspaceBootstrapPromptContext(
|
||||
return undefined;
|
||||
}
|
||||
const lines = [
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. SOUL.md, IDENTITY.md, TOOLS.md, and USER.md are provided as Codex thread developer instructions so standing workspace guidance is not repeated in every turn. MEMORY.md stays in turn-scoped memory context, and HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
|
||||
"",
|
||||
"# Project Context",
|
||||
"",
|
||||
|
||||
@@ -1150,10 +1150,7 @@ describe("CodexAppServerEventProjector", () => {
|
||||
|
||||
const result = projector.buildResult(buildEmptyToolTelemetry());
|
||||
|
||||
expect(onReasoningStream).toHaveBeenCalledWith({
|
||||
text: "thinking",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
expect(onReasoningStream).toHaveBeenCalledWith({ text: "thinking" });
|
||||
expect(onReasoningEnd).toHaveBeenCalledTimes(1);
|
||||
expect(findPlanEventWithSteps(onAgentEvent, ["patch (in_progress)"]).steps).toEqual([
|
||||
"patch (in_progress)",
|
||||
@@ -1178,94 +1175,6 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(requireRecord(result.itemLifecycle, "item lifecycle").compactionCount).toBe(1);
|
||||
});
|
||||
|
||||
it("streams accumulated reasoning snapshots grouped by Codex reasoning indexes", async () => {
|
||||
const onReasoningStream = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onReasoningStream,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/textDelta", {
|
||||
itemId: "reason-1",
|
||||
contentIndex: 1,
|
||||
delta: "Checking ",
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/textDelta", {
|
||||
itemId: "reason-1",
|
||||
contentIndex: 0,
|
||||
delta: "Reading ",
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/textDelta", {
|
||||
itemId: "reason-1",
|
||||
contentIndex: 0,
|
||||
delta: "files",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onReasoningStream).toHaveBeenCalledTimes(3);
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(1, {
|
||||
text: "Checking ",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(2, {
|
||||
text: "Reading \n\nChecking ",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(3, {
|
||||
text: "Reading files\n\nChecking ",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("streams accumulated reasoning summaries grouped by summary section", async () => {
|
||||
const onReasoningStream = vi.fn();
|
||||
const projector = await createProjector({
|
||||
...(await createParams()),
|
||||
onReasoningStream,
|
||||
});
|
||||
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/summaryTextDelta", {
|
||||
itemId: "reason-1",
|
||||
summaryIndex: 1,
|
||||
delta: "Second",
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/summaryTextDelta", {
|
||||
itemId: "reason-1",
|
||||
summaryIndex: 0,
|
||||
delta: "First ",
|
||||
}),
|
||||
);
|
||||
await projector.handleNotification(
|
||||
forCurrentTurn("item/reasoning/summaryTextDelta", {
|
||||
itemId: "reason-1",
|
||||
summaryIndex: 0,
|
||||
delta: "section",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onReasoningStream).toHaveBeenCalledTimes(3);
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(1, {
|
||||
text: "Second",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(2, {
|
||||
text: "First \n\nSecond",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
expect(onReasoningStream).toHaveBeenNthCalledWith(3, {
|
||||
text: "First section\n\nSecond",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("synthesizes normalized tool progress for Codex-native tool items", async () => {
|
||||
const onAgentEvent = vi.fn();
|
||||
const projector = await createProjector({ ...(await createParams()), onAgentEvent });
|
||||
|
||||
@@ -64,15 +64,6 @@ export type CodexAppServerEventProjectorOptions = {
|
||||
trajectoryRecorder?: CodexTrajectoryRecorder | null;
|
||||
};
|
||||
|
||||
type ReasoningDeltaMethod = "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta";
|
||||
|
||||
type ReasoningTextGroup = {
|
||||
itemId: string;
|
||||
method: ReasoningDeltaMethod;
|
||||
index: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const ZERO_USAGE: Usage = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
@@ -139,8 +130,7 @@ export class CodexAppServerEventProjector {
|
||||
private readonly assistantItemOrder: string[] = [];
|
||||
private readonly assistantPhaseByItem = new Map<string, string>();
|
||||
private readonly lastCommentaryProgressTextByItem = new Map<string, string>();
|
||||
private readonly reasoningTextByGroup = new Map<string, ReasoningTextGroup>();
|
||||
private readonly reasoningItemOrder = new Map<string, number>();
|
||||
private readonly reasoningTextByItem = new Map<string, string>();
|
||||
private readonly planTextByItem = new Map<string, string>();
|
||||
private readonly activeItemIds = new Set<string>();
|
||||
private readonly completedItemIds = new Set<string>();
|
||||
@@ -217,7 +207,7 @@ export class CodexAppServerEventProjector {
|
||||
break;
|
||||
case "item/reasoning/summaryTextDelta":
|
||||
case "item/reasoning/textDelta":
|
||||
await this.handleReasoningDelta(notification.method, params);
|
||||
await this.handleReasoningDelta(params);
|
||||
break;
|
||||
case "item/plan/delta":
|
||||
this.handlePlanDelta(params);
|
||||
@@ -271,10 +261,7 @@ export class CodexAppServerEventProjector {
|
||||
options?: { yieldDetected?: boolean },
|
||||
): EmbeddedRunAttemptResult {
|
||||
const assistantTexts = this.collectAssistantTexts();
|
||||
const reasoningText = collectReasoningTextValues(
|
||||
this.reasoningTextByGroup,
|
||||
this.reasoningItemOrder,
|
||||
).join("\n\n");
|
||||
const reasoningText = collectTextValues(this.reasoningTextByItem).join("\n\n");
|
||||
const planText = collectTextValues(this.planTextByItem).join("\n\n");
|
||||
const lastAssistant =
|
||||
assistantTexts.length > 0
|
||||
@@ -452,39 +439,15 @@ export class CodexAppServerEventProjector {
|
||||
// turn completion chooses the last assistant item as the user-visible reply.
|
||||
}
|
||||
|
||||
private async handleReasoningDelta(
|
||||
method: ReasoningDeltaMethod,
|
||||
params: JsonObject,
|
||||
): Promise<void> {
|
||||
private async handleReasoningDelta(params: JsonObject): Promise<void> {
|
||||
const itemId = readString(params, "itemId") ?? readString(params, "id") ?? "reasoning";
|
||||
const delta = readString(params, "delta") ?? "";
|
||||
if (!delta) {
|
||||
return;
|
||||
}
|
||||
this.reasoningStarted = true;
|
||||
if (!this.reasoningItemOrder.has(itemId)) {
|
||||
this.reasoningItemOrder.set(itemId, this.reasoningItemOrder.size);
|
||||
}
|
||||
// Codex indexes reasoning sections independently within an item. Keep those
|
||||
// sections separate so the live snapshot matches the completed item shape.
|
||||
const groupIndex =
|
||||
method === "item/reasoning/textDelta"
|
||||
? (readNonNegativeInteger(params, "contentIndex") ?? 0)
|
||||
: (readNonNegativeInteger(params, "summaryIndex") ?? 0);
|
||||
const groupKey = `${method}\0${itemId}\0${groupIndex}`;
|
||||
const current = this.reasoningTextByGroup.get(groupKey);
|
||||
this.reasoningTextByGroup.set(groupKey, {
|
||||
itemId,
|
||||
method,
|
||||
index: groupIndex,
|
||||
text: `${current?.text ?? ""}${delta}`,
|
||||
});
|
||||
await this.params.onReasoningStream?.({
|
||||
text: collectReasoningTextValues(this.reasoningTextByGroup, this.reasoningItemOrder).join(
|
||||
"\n\n",
|
||||
),
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
this.reasoningTextByItem.set(itemId, `${this.reasoningTextByItem.get(itemId) ?? ""}${delta}`);
|
||||
await this.params.onReasoningStream?.({ text: delta });
|
||||
}
|
||||
|
||||
private handlePlanDelta(params: JsonObject): void {
|
||||
@@ -1622,11 +1585,6 @@ function readNumber(record: JsonObject, key: string): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readNonNegativeInteger(record: JsonObject, key: string): number | undefined {
|
||||
const value = readNumber(record, key);
|
||||
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(record: JsonObject, key: string): boolean | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
@@ -1729,29 +1687,6 @@ function collectTextValues(map: Map<string, string>): string[] {
|
||||
return [...map.values()].filter((text) => text.trim().length > 0);
|
||||
}
|
||||
|
||||
function collectReasoningTextValues(
|
||||
groups: Map<string, ReasoningTextGroup>,
|
||||
itemOrder: Map<string, number>,
|
||||
): string[] {
|
||||
return [...groups.values()]
|
||||
.toSorted((left, right) => {
|
||||
const itemDelta =
|
||||
(itemOrder.get(left.itemId) ?? Number.MAX_SAFE_INTEGER) -
|
||||
(itemOrder.get(right.itemId) ?? Number.MAX_SAFE_INTEGER);
|
||||
if (itemDelta !== 0) {
|
||||
return itemDelta;
|
||||
}
|
||||
const methodDelta = reasoningMethodOrder(left.method) - reasoningMethodOrder(right.method);
|
||||
return methodDelta !== 0 ? methodDelta : left.index - right.index;
|
||||
})
|
||||
.map((group) => group.text)
|
||||
.filter((text) => text.trim().length > 0);
|
||||
}
|
||||
|
||||
function reasoningMethodOrder(method: ReasoningDeltaMethod): number {
|
||||
return method === "item/reasoning/summaryTextDelta" ? 0 : 1;
|
||||
}
|
||||
|
||||
function extractRawAssistantText(item: JsonObject): string | undefined {
|
||||
const content = Array.isArray(item.content) ? item.content : [];
|
||||
const text = content
|
||||
|
||||
@@ -272,22 +272,20 @@ async function buildCodexTurnContextForTest(
|
||||
cwd: workspaceDir,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({}),
|
||||
promptText: codexTurnPromptText,
|
||||
turnScopedDeveloperInstructions:
|
||||
workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
});
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
const inputText =
|
||||
turnStartParams.input?.find((item) => item.type === "text")?.text ?? "";
|
||||
const inputText = turnStartParams.input?.find((item) => item.type === "text")?.text ?? "";
|
||||
const systemPromptReport = buildCodexSystemPromptReport({
|
||||
attempt: params,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
workspaceDir,
|
||||
developerInstructions: [threadDeveloperInstructions, collaborationInstructions].join(
|
||||
"\n\n",
|
||||
),
|
||||
developerInstructions: [threadDeveloperInstructions, collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n"),
|
||||
workspaceBootstrapContext,
|
||||
skillsPrompt: "",
|
||||
tools: dynamicTools,
|
||||
@@ -1715,6 +1713,63 @@ describe("runCodexAppServerAttempt", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves workspace instruction files when before_prompt_build replaces Codex developer instructions", async () => {
|
||||
const beforePromptBuild = vi.fn(async () => ({
|
||||
systemPrompt: "hook replacement codex system",
|
||||
}));
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "before_prompt_build", handler: beforePromptBuild }]),
|
||||
);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const soulGuidance = "Soul guidance that must stay session-scoped.";
|
||||
const identityGuidance = "Identity guidance that must stay session-scoped.";
|
||||
const toolGuidance = "Tool guidance that must stay session-scoped.";
|
||||
const userProfile = "User profile that must stay session-scoped.";
|
||||
const memorySummary = "Memory summary that must stay turn-scoped.";
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), soulGuidance);
|
||||
await fs.writeFile(path.join(workspaceDir, "IDENTITY.md"), identityGuidance);
|
||||
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);
|
||||
const harness = createStartedThreadHarness();
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
expect(beforePromptBuild).toHaveBeenCalledOnce();
|
||||
const threadStart = harness.requests.find((request) => request.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as { developerInstructions?: string };
|
||||
expect(threadStartParams.developerInstructions).toContain("hook replacement codex system");
|
||||
expect(threadStartParams.developerInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadStartParams.developerInstructions).toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(toolGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(userProfile);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(memorySummary);
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
input?: Array<{ text?: string }>;
|
||||
collaborationMode?: { settings?: { developer_instructions?: string | null } };
|
||||
};
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
expect(turnStartParams.input?.[0]?.text ?? "").toContain(memorySummary);
|
||||
expect(result.systemPromptReport?.systemPrompt.chars).toBe(
|
||||
(threadStartParams.developerInstructions ?? "").length,
|
||||
);
|
||||
});
|
||||
|
||||
it("projects mirrored history when starting Codex without a native thread binding", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
@@ -1873,21 +1928,19 @@ describe("runCodexAppServerAttempt", () => {
|
||||
} = await buildCodexTurnContextForTest(params, workspaceDir);
|
||||
|
||||
expect(threadDeveloperInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadDeveloperInstructions).not.toContain(soulGuidance);
|
||||
expect(threadDeveloperInstructions).not.toContain(identityGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(soulGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(identityGuidance);
|
||||
expect(threadDeveloperInstructions).toContain(toolGuidance);
|
||||
expect(threadDeveloperInstructions).not.toContain(userProfile);
|
||||
expect(threadDeveloperInstructions).toContain(userProfile);
|
||||
expect(threadDeveloperInstructions).not.toContain(memorySummary);
|
||||
expect(threadDeveloperInstructions).not.toContain("Codex loads AGENTS.md natively");
|
||||
expect(threadDeveloperInstructions).not.toContain(agentsGuidance);
|
||||
|
||||
expect(collaborationInstructions).toContain("# Collaboration Mode: Default");
|
||||
expect(collaborationInstructions).toContain("request_user_input availability");
|
||||
expect(collaborationInstructions).toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).toContain(soulGuidance);
|
||||
expect(collaborationInstructions).toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
expect(collaborationInstructions).toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(memorySummary);
|
||||
expect(inputText).toContain("OpenClaw runtime context for this turn:");
|
||||
expect(inputText).not.toContain("does not override Codex system/developer instructions");
|
||||
@@ -1905,7 +1958,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
expect(inputText).not.toContain(agentsGuidance);
|
||||
expect(inputText).toContain("Current user request:\nhello");
|
||||
expect(systemPromptReport.systemPrompt.chars).toBe(
|
||||
[threadDeveloperInstructions, collaborationInstructions].join("\n\n").length,
|
||||
[threadDeveloperInstructions, collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n").length,
|
||||
);
|
||||
|
||||
const fileStats = new Map(
|
||||
@@ -1973,14 +2028,12 @@ describe("runCodexAppServerAttempt", () => {
|
||||
developerInstructions?: string;
|
||||
};
|
||||
expect(threadStartParams.config?.instructions).toBeUndefined();
|
||||
expect(threadStartParams.developerInstructions).toContain(
|
||||
"OpenClaw Workspace Instructions",
|
||||
);
|
||||
expect(threadStartParams.developerInstructions).toContain("OpenClaw Workspace Instructions");
|
||||
expect(threadStartParams.developerInstructions).toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(toolGuidance);
|
||||
expect(threadStartParams.developerInstructions).toContain(userProfile);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(agentsGuidance);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(soulGuidance);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(identityGuidance);
|
||||
expect(threadStartParams.developerInstructions).not.toContain(userProfile);
|
||||
|
||||
const turnStart = harness.requests.find((request) => request.method === "turn/start");
|
||||
const turnStartParams = turnStart?.params as {
|
||||
@@ -1993,20 +2046,19 @@ describe("runCodexAppServerAttempt", () => {
|
||||
};
|
||||
const collaborationInstructions =
|
||||
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
|
||||
expect(collaborationInstructions).toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).toContain(soulGuidance);
|
||||
expect(collaborationInstructions).toContain(identityGuidance);
|
||||
expect(collaborationInstructions).toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain("OpenClaw Agent Soul");
|
||||
expect(collaborationInstructions).not.toContain(soulGuidance);
|
||||
expect(collaborationInstructions).not.toContain(identityGuidance);
|
||||
expect(collaborationInstructions).not.toContain(userProfile);
|
||||
expect(collaborationInstructions).not.toContain(toolGuidance);
|
||||
|
||||
const inputText = turnStartParams.input?.[0]?.text ?? "";
|
||||
expect(inputText).toBe("hello");
|
||||
expect(inputText).not.toContain(agentsGuidance);
|
||||
expect(result.systemPromptReport?.systemPrompt.chars).toBe(
|
||||
[
|
||||
threadStartParams.developerInstructions ?? "",
|
||||
collaborationInstructions,
|
||||
].join("\n\n").length,
|
||||
[threadStartParams.developerInstructions ?? "", collaborationInstructions]
|
||||
.filter((section) => section.trim())
|
||||
.join("\n\n").length,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -640,7 +640,6 @@ export async function runCodexAppServerAttempt(
|
||||
buildDeveloperInstructions(params, {
|
||||
dynamicTools: toolBridge.availableSpecs,
|
||||
}),
|
||||
workspaceBootstrapContext.developerInstructions,
|
||||
);
|
||||
const openClawPromptContext = buildCodexOpenClawPromptContext({
|
||||
params,
|
||||
@@ -771,9 +770,14 @@ export async function runCodexAppServerAttempt(
|
||||
heartbeatCollaborationInstructions:
|
||||
workspaceBootstrapContext.heartbeatCollaborationInstructions,
|
||||
}).settings.developer_instructions ?? undefined;
|
||||
const buildRenderedCodexDeveloperInstructions = () =>
|
||||
const buildCodexThreadDeveloperInstructions = () =>
|
||||
joinPresentSections(
|
||||
promptBuild.developerInstructions,
|
||||
workspaceBootstrapContext.developerInstructions,
|
||||
);
|
||||
const buildRenderedCodexDeveloperInstructions = () =>
|
||||
joinPresentSections(
|
||||
buildCodexThreadDeveloperInstructions(),
|
||||
buildCodexTurnCollaborationDeveloperInstructions(),
|
||||
);
|
||||
const systemPromptReport = buildCodexSystemPromptReport({
|
||||
@@ -871,7 +875,7 @@ export async function runCodexAppServerAttempt(
|
||||
effectiveWorkspace,
|
||||
effectiveCwd,
|
||||
dynamicTools: toolBridge.specs,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
developerInstructions: buildCodexThreadDeveloperInstructions(),
|
||||
buildFinalConfigPatch: buildNativeHookRelayFinalConfigPatch,
|
||||
bundleMcpThreadConfig,
|
||||
nativeToolSurfaceEnabled,
|
||||
@@ -914,7 +918,7 @@ export async function runCodexAppServerAttempt(
|
||||
recordCodexTrajectoryContext(trajectoryRecorder, {
|
||||
attempt: params,
|
||||
cwd: effectiveCwd,
|
||||
developerInstructions: promptBuild.developerInstructions,
|
||||
developerInstructions: buildRenderedCodexDeveloperInstructions(),
|
||||
prompt: codexTurnPromptText,
|
||||
tools: toolBridge.availableSpecs,
|
||||
});
|
||||
|
||||
@@ -436,50 +436,6 @@ describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed live numeric metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
makeAgentModelEntry({
|
||||
id: "bad/chat",
|
||||
metadata: {
|
||||
description: "bad chat",
|
||||
context_length: -1,
|
||||
max_tokens: 1.5,
|
||||
pricing: { input_tokens: 3, output_tokens: 15 },
|
||||
tags: ["chat"],
|
||||
},
|
||||
}),
|
||||
makeAgentModelEntry({
|
||||
id: "bad/image",
|
||||
metadata: {
|
||||
description: "bad image",
|
||||
pricing: { per_image_unit: 0.003 },
|
||||
tags: ["image-gen"],
|
||||
default_width: Number.POSITIVE_INFINITY,
|
||||
default_height: 1024.5,
|
||||
default_iterations: 0,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
|
||||
const catalog = await discoverDeepInfraSurfaces();
|
||||
|
||||
expect(catalog.chat[0]).toMatchObject({ id: "bad/chat" });
|
||||
expect(catalog.chat[0]?.contextWindow).toBeUndefined();
|
||||
expect(catalog.chat[0]?.maxTokens).toBeUndefined();
|
||||
expect(catalog.imageGen[0]).toMatchObject({ id: "bad/image" });
|
||||
expect(catalog.imageGen[0]?.defaultWidth).toBeUndefined();
|
||||
expect(catalog.imageGen[0]?.defaultHeight).toBeUndefined();
|
||||
expect(catalog.imageGen[0]?.defaultIterations).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the manifest static fallback (live=false) when no API key is configured", async () => {
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fetchWithTimeout } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { asPositiveSafeInteger } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const log = createSubsystemLogger("deepinfra-models");
|
||||
@@ -132,12 +131,15 @@ function entryToSurfaceModel(entry: DeepInfraAgentModelEntry): DeepInfraSurfaceM
|
||||
name: id,
|
||||
description: metadata.description ?? undefined,
|
||||
tags,
|
||||
contextWindow: asPositiveSafeInteger(metadata.context_length),
|
||||
maxTokens: asPositiveSafeInteger(metadata.max_tokens),
|
||||
contextWindow:
|
||||
typeof metadata.context_length === "number" ? metadata.context_length : undefined,
|
||||
maxTokens: typeof metadata.max_tokens === "number" ? metadata.max_tokens : undefined,
|
||||
pricing,
|
||||
defaultWidth: asPositiveSafeInteger(metadata.default_width),
|
||||
defaultHeight: asPositiveSafeInteger(metadata.default_height),
|
||||
defaultIterations: asPositiveSafeInteger(metadata.default_iterations),
|
||||
defaultWidth: typeof metadata.default_width === "number" ? metadata.default_width : undefined,
|
||||
defaultHeight:
|
||||
typeof metadata.default_height === "number" ? metadata.default_height : undefined,
|
||||
defaultIterations:
|
||||
typeof metadata.default_iterations === "number" ? metadata.default_iterations : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -123,62 +123,6 @@ describe("deepinfra video generation provider", () => {
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not forward malformed video seed values", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "/generated/video.mp4",
|
||||
request_id: "req_seed",
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
providerOptions: {
|
||||
seed: 1.5,
|
||||
},
|
||||
});
|
||||
|
||||
expect(postJsonRequestMock).toHaveBeenCalledOnce();
|
||||
const postRequest = requireFirstPostJsonRequest();
|
||||
expect(Reflect.get(Reflect.get(postRequest ?? {}, "body") ?? {}, "seed")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops malformed response seed metadata", async () => {
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: {
|
||||
json: async () => ({
|
||||
video_url: "/generated/video.mp4",
|
||||
request_id: "req_bad_seed",
|
||||
seed: 1.5,
|
||||
inference_status: { status: "succeeded" },
|
||||
}),
|
||||
},
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const provider = buildDeepInfraVideoGenerationProvider();
|
||||
const result = await provider.generateVideo({
|
||||
provider: "deepinfra",
|
||||
model: "deepinfra/Pixverse/Pixverse-T2V",
|
||||
prompt: "A bicycle weaving through a rainy neon street",
|
||||
cfg: {},
|
||||
});
|
||||
|
||||
expect(result.metadata).toEqual({
|
||||
requestId: "req_bad_seed",
|
||||
seed: undefined,
|
||||
status: "succeeded",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed native video JSON as a provider error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
resolveProviderHttpRequestConfig,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
asFiniteNumber,
|
||||
asSafeIntegerInRange,
|
||||
asFiniteNumber as coerceProviderNumber,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
@@ -94,10 +93,6 @@ function resolveDurationSeconds(value: number | undefined): number | undefined {
|
||||
return value <= 6.5 ? 5 : 8;
|
||||
}
|
||||
|
||||
function resolveSeed(value: unknown): number | undefined {
|
||||
return asSafeIntegerInRange(value, { min: 0, max: 4_294_967_295 });
|
||||
}
|
||||
|
||||
function buildDeepInfraVideoBody(
|
||||
req: VideoGenerationRequest,
|
||||
model: string,
|
||||
@@ -114,7 +109,7 @@ function buildDeepInfraVideoBody(
|
||||
if (duration) {
|
||||
body.duration = duration;
|
||||
}
|
||||
const seed = resolveSeed(options.seed);
|
||||
const seed = coerceProviderNumber(options.seed);
|
||||
if (seed != null) {
|
||||
body.seed = seed;
|
||||
}
|
||||
@@ -129,7 +124,7 @@ function buildDeepInfraVideoBody(
|
||||
body.style = style;
|
||||
}
|
||||
const guidanceScale =
|
||||
asFiniteNumber(options.guidance_scale) ?? asFiniteNumber(options.guidanceScale);
|
||||
coerceProviderNumber(options.guidance_scale) ?? coerceProviderNumber(options.guidanceScale);
|
||||
if (guidanceScale != null && model.startsWith("Wan-AI/")) {
|
||||
body.guidance_scale = guidanceScale;
|
||||
}
|
||||
@@ -285,7 +280,7 @@ export function buildDeepInfraVideoGenerationProvider(options?: {
|
||||
model,
|
||||
metadata: {
|
||||
requestId: normalizeOptionalString(payload.request_id),
|
||||
seed: resolveSeed(payload.seed),
|
||||
seed: payload.seed,
|
||||
status: payload.inference_status?.status ?? payload.status,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
readNonNegativeIntegerParam,
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
@@ -243,7 +242,9 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(actionParams, "name", { required: true });
|
||||
const position = readNonNegativeIntegerParam(actionParams, "position");
|
||||
const position = readNumberParam(actionParams, "position", {
|
||||
integer: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "categoryCreate",
|
||||
@@ -262,7 +263,9 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(actionParams, "name");
|
||||
const position = readNonNegativeIntegerParam(actionParams, "position");
|
||||
const position = readNumberParam(actionParams, "position", {
|
||||
integer: true,
|
||||
});
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "categoryEdit",
|
||||
@@ -349,10 +352,9 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
if (isDiscordModerationAction(action)) {
|
||||
const moderation = readDiscordModerationCommand(action, {
|
||||
...actionParams,
|
||||
durationMinutes: readNonNegativeIntegerParam(actionParams, "durationMin"),
|
||||
deleteMessageDays: readNonNegativeIntegerParam(actionParams, "deleteDays", {
|
||||
max: 7,
|
||||
message: "deleteDays must be an integer from 0 to 7",
|
||||
durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }),
|
||||
deleteMessageDays: readNumberParam(actionParams, "deleteDays", {
|
||||
integer: true,
|
||||
}),
|
||||
});
|
||||
const senderUserId = normalizeOptionalString(ctx.requesterSenderId);
|
||||
@@ -381,7 +383,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
const includeArchived =
|
||||
typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined;
|
||||
const before = readStringParam(actionParams, "before");
|
||||
const limit = readPositiveIntegerParam(actionParams, "limit");
|
||||
const limit = readNumberParam(actionParams, "limit", { integer: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "threadList",
|
||||
@@ -440,7 +442,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
channelIds: readStringArrayParam(actionParams, "channelIds"),
|
||||
authorId: readStringParam(actionParams, "authorId"),
|
||||
authorIds: readStringArrayParam(actionParams, "authorIds"),
|
||||
limit: readPositiveIntegerParam(actionParams, "limit"),
|
||||
limit: readNumberParam(actionParams, "limit", { integer: true }),
|
||||
},
|
||||
cfg,
|
||||
);
|
||||
|
||||
@@ -79,24 +79,6 @@ describe("handleDiscordMessageAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional moderation durations before invoking Discord runtime", async () => {
|
||||
const cfg = discordConfig({ moderation: true });
|
||||
await expect(
|
||||
handleDiscordMessageAction({
|
||||
action: "timeout",
|
||||
params: {
|
||||
guildId: "guild-1",
|
||||
userId: "user-2",
|
||||
durationMin: 5.5,
|
||||
},
|
||||
cfg,
|
||||
requesterSenderId: "trusted-sender-id",
|
||||
toolContext: { currentChannelProvider: "discord" },
|
||||
}),
|
||||
).rejects.toThrow("durationMin must be a non-negative integer");
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses Discord requesterSenderId for guild admin actions and ignores params senderUserId", async () => {
|
||||
const cfg = discordConfig({ channels: true });
|
||||
await handleDiscordMessageAction({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
@@ -187,7 +187,10 @@ export async function handleDiscordMessageAction(
|
||||
});
|
||||
const answers = readStringArrayParam(params, "pollOption", { required: true });
|
||||
const allowMultiselect = readBooleanParam(params, "pollMulti");
|
||||
const durationHours = readPositiveIntegerParam(params, "pollDurationHours");
|
||||
const durationHours = readNumberParam(params, "pollDurationHours", {
|
||||
integer: true,
|
||||
strict: true,
|
||||
});
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
action: "poll",
|
||||
@@ -232,7 +235,7 @@ export async function handleDiscordMessageAction(
|
||||
|
||||
if (action === "reactions") {
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const limit = readPositiveIntegerParam(params, "limit");
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "reactions",
|
||||
@@ -247,7 +250,7 @@ export async function handleDiscordMessageAction(
|
||||
}
|
||||
|
||||
if (action === "read") {
|
||||
const limit = readPositiveIntegerParam(params, "limit");
|
||||
const limit = readNumberParam(params, "limit", { integer: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "readMessages",
|
||||
@@ -324,7 +327,9 @@ export async function handleDiscordMessageAction(
|
||||
const name = readStringParam(params, "threadName", { required: true });
|
||||
const messageId = readStringParam(params, "messageId");
|
||||
const content = readStringParam(params, "message");
|
||||
const autoArchiveMinutes = readPositiveIntegerParam(params, "autoArchiveMin");
|
||||
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
|
||||
integer: true,
|
||||
});
|
||||
const appliedTags = readStringArrayParam(params, "appliedTags");
|
||||
const result = await handleDiscordAction(
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getPresence } from "../monitor/presence-cache.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readNonNegativeIntegerParam,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
@@ -624,7 +624,7 @@ export async function handleDiscordGuildAction(
|
||||
}
|
||||
const guildId = readStringParam(params, "guildId", { required: true });
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const position = readNonNegativeIntegerParam(params, "position");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = await discordGuildActionRuntime.createChannelDiscord(
|
||||
{
|
||||
guildId,
|
||||
@@ -644,7 +644,7 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const name = readStringParam(params, "name");
|
||||
const position = readNonNegativeIntegerParam(params, "position");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = await discordGuildActionRuntime.editChannelDiscord(
|
||||
{
|
||||
channelId: categoryId,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
jsonResult,
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "../runtime-api.js";
|
||||
@@ -101,7 +101,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
|
||||
const channelId = ctx.resolveChannelId();
|
||||
await ctx.assertReadTargetAllowed({ channelId });
|
||||
const query = {
|
||||
limit: readPositiveIntegerParam(ctx.params, "limit"),
|
||||
limit: readNumberParam(ctx.params, "limit"),
|
||||
before: readStringParam(ctx.params, "before"),
|
||||
after: readStringParam(ctx.params, "after"),
|
||||
around: readStringParam(ctx.params, "around"),
|
||||
@@ -193,7 +193,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
|
||||
const channelIds = readStringArrayParam(ctx.params, "channelIds");
|
||||
const authorId = readStringParam(ctx.params, "authorId");
|
||||
const authorIds = readStringArrayParam(ctx.params, "authorIds");
|
||||
const limit = readPositiveIntegerParam(ctx.params, "limit");
|
||||
const limit = readNumberParam(ctx.params, "limit");
|
||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||
if (channelIdList.length > 0) {
|
||||
for (const targetChannelId of channelIdList) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
jsonResult,
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringParam,
|
||||
} from "../runtime-api.js";
|
||||
@@ -53,7 +53,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging
|
||||
const messageId = readStringParam(ctx.params, "messageId", {
|
||||
required: true,
|
||||
});
|
||||
const limit = readPositiveIntegerParam(ctx.params, "limit");
|
||||
const limit = readNumberParam(ctx.params, "limit");
|
||||
await ctx.assertReadTargetAllowed({ channelId });
|
||||
const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord(
|
||||
channelId,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
assertMediaNotDataUrl,
|
||||
jsonResult,
|
||||
readBooleanParam,
|
||||
readPositiveIntegerParam,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
resolvePollMaxSelections,
|
||||
@@ -119,7 +119,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
|
||||
label: "answers",
|
||||
});
|
||||
const allowMultiselect = readBooleanParam(ctx.params, "allowMultiselect");
|
||||
const durationHours = readPositiveIntegerParam(ctx.params, "durationHours");
|
||||
const durationHours = readNumberParam(ctx.params, "durationHours");
|
||||
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
|
||||
await discordMessagingActionRuntime.sendPollDiscord(
|
||||
to,
|
||||
@@ -255,7 +255,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
|
||||
const name = readStringParam(ctx.params, "name", { required: true });
|
||||
const messageId = readStringParam(ctx.params, "messageId");
|
||||
const content = readStringParam(ctx.params, "content");
|
||||
const autoArchiveMinutes = readPositiveIntegerParam(ctx.params, "autoArchiveMinutes");
|
||||
const autoArchiveMinutes = readNumberParam(ctx.params, "autoArchiveMinutes");
|
||||
const appliedTags = readStringArrayParam(ctx.params, "appliedTags");
|
||||
const payload = {
|
||||
name,
|
||||
@@ -294,7 +294,7 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction
|
||||
const channelId = readStringParam(ctx.params, "channelId");
|
||||
const includeArchived = readBooleanParam(ctx.params, "includeArchived");
|
||||
const before = readStringParam(ctx.params, "before");
|
||||
const limit = readPositiveIntegerParam(ctx.params, "limit");
|
||||
const limit = readNumberParam(ctx.params, "limit");
|
||||
const threads = await discordMessagingActionRuntime.listThreadsDiscord(
|
||||
{
|
||||
guildId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PermissionFlagsBits } from "discord-api-types/v10";
|
||||
import { readNonNegativeIntegerParam, readStringParam } from "../runtime-api.js";
|
||||
import { readNumberParam, readStringParam } from "../runtime-api.js";
|
||||
|
||||
export type DiscordModerationAction = "timeout" | "kick" | "ban";
|
||||
|
||||
@@ -40,12 +40,9 @@ export function readDiscordModerationCommand(
|
||||
action,
|
||||
guildId: readStringParam(params, "guildId", { required: true }),
|
||||
userId: readStringParam(params, "userId", { required: true }),
|
||||
durationMinutes: readNonNegativeIntegerParam(params, "durationMinutes"),
|
||||
durationMinutes: readNumberParam(params, "durationMinutes", { integer: true }),
|
||||
until: readStringParam(params, "until"),
|
||||
reason: readStringParam(params, "reason"),
|
||||
deleteMessageDays: readNonNegativeIntegerParam(params, "deleteMessageDays", {
|
||||
max: 7,
|
||||
message: "deleteMessageDays must be an integer from 0 to 7",
|
||||
}),
|
||||
deleteMessageDays: readNumberParam(params, "deleteMessageDays", { integer: true }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
parseAvailableTags,
|
||||
readNonNegativeIntegerParam,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
} from "../runtime-api.js";
|
||||
import { parseAvailableTags, readNumberParam, readStringParam } from "../runtime-api.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type {
|
||||
DiscordChannelCreate,
|
||||
@@ -52,12 +47,12 @@ export function readDiscordChannelCreateParams(
|
||||
guildId: readStringParam(params, "guildId", { required: true }),
|
||||
name: readStringParam(params, "name", { required: true }),
|
||||
type:
|
||||
readNonNegativeIntegerParam(params, "channelType") ??
|
||||
readNonNegativeIntegerParam(params, "type") ??
|
||||
readNumberParam(params, "channelType", { integer: true }) ??
|
||||
readNumberParam(params, "type", { integer: true }) ??
|
||||
undefined,
|
||||
parentId: parentId ?? undefined,
|
||||
topic: readStringParam(params, "topic") ?? undefined,
|
||||
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
|
||||
position: readNumberParam(params, "position", { integer: true }) ?? undefined,
|
||||
nsfw: readDiscordBooleanParam(params, "nsfw"),
|
||||
};
|
||||
}
|
||||
@@ -68,13 +63,14 @@ export function readDiscordChannelEditParams(params: Record<string, unknown>): D
|
||||
channelId: readStringParam(params, "channelId", { required: true }),
|
||||
name: readStringParam(params, "name") ?? undefined,
|
||||
topic: readStringParam(params, "topic") ?? undefined,
|
||||
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
|
||||
position: readNumberParam(params, "position", { integer: true }) ?? undefined,
|
||||
parentId: parentId === undefined ? undefined : parentId,
|
||||
nsfw: readDiscordBooleanParam(params, "nsfw"),
|
||||
rateLimitPerUser: readNonNegativeIntegerParam(params, "rateLimitPerUser") ?? undefined,
|
||||
rateLimitPerUser: readNumberParam(params, "rateLimitPerUser", { integer: true }) ?? undefined,
|
||||
archived: readDiscordBooleanParam(params, "archived"),
|
||||
locked: readDiscordBooleanParam(params, "locked"),
|
||||
autoArchiveDuration: readPositiveIntegerParam(params, "autoArchiveDuration") ?? undefined,
|
||||
autoArchiveDuration:
|
||||
readNumberParam(params, "autoArchiveDuration", { integer: true }) ?? undefined,
|
||||
availableTags: parseAvailableTags(params.availableTags),
|
||||
};
|
||||
}
|
||||
@@ -85,6 +81,6 @@ export function readDiscordChannelMoveParams(params: Record<string, unknown>): D
|
||||
guildId: readStringParam(params, "guildId", { required: true }),
|
||||
channelId: readStringParam(params, "channelId", { required: true }),
|
||||
parentId: parentId === undefined ? undefined : parentId,
|
||||
position: readNonNegativeIntegerParam(params, "position") ?? undefined,
|
||||
position: readNumberParam(params, "position", { integer: true }) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -320,21 +320,6 @@ describe("handleDiscordMessagingAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional Discord reaction limits before fetching reactions", async () => {
|
||||
await expect(
|
||||
handleMessagingAction(
|
||||
"reactions",
|
||||
{
|
||||
channelId: "C1",
|
||||
messageId: "M1",
|
||||
limit: 2.5,
|
||||
},
|
||||
enableAllActions,
|
||||
),
|
||||
).rejects.toThrow("limit must be a positive integer");
|
||||
expect(fetchReactionsDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects Discord reaction reads for non-allowlisted target channels", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -516,20 +501,6 @@ describe("handleDiscordMessagingAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fractional Discord read limits before reading messages", async () => {
|
||||
await expect(
|
||||
handleMessagingAction(
|
||||
"readMessages",
|
||||
{
|
||||
channelId: "C1",
|
||||
limit: "3.5",
|
||||
},
|
||||
enableAllActions,
|
||||
),
|
||||
).rejects.toThrow("limit must be a positive integer");
|
||||
expect(readMessagesDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads from allowlisted Discord target channels", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -1787,20 +1758,6 @@ describe("handleDiscordGuildAction - channel management", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fractional Discord channel edit integers before editing channels", async () => {
|
||||
await expect(
|
||||
handleGuildAction(
|
||||
"channelEdit",
|
||||
{
|
||||
channelId: "C1",
|
||||
position: 1.5,
|
||||
},
|
||||
channelsEnabled,
|
||||
),
|
||||
).rejects.toThrow("position must be a non-negative integer");
|
||||
expect(editChannelDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["parentId is null", { parentId: null }],
|
||||
["clearParent is true", { clearParent: true }],
|
||||
@@ -1857,21 +1814,6 @@ describe("handleDiscordGuildAction - channel management", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fractional Discord channel move positions before moving channels", async () => {
|
||||
await expect(
|
||||
handleGuildAction(
|
||||
"channelMove",
|
||||
{
|
||||
guildId: "G1",
|
||||
channelId: "C1",
|
||||
position: "5.5",
|
||||
},
|
||||
channelsEnabled,
|
||||
),
|
||||
).rejects.toThrow("position must be a non-negative integer");
|
||||
expect(moveChannelDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["parentId is null", { parentId: null }],
|
||||
["clearParent is true", { clearParent: true }],
|
||||
@@ -2066,36 +2008,6 @@ describe("handleDiscordModerationAction", () => {
|
||||
accountId: "ops",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional Discord moderation durations before timing out members", async () => {
|
||||
await expect(
|
||||
handleModerationAction(
|
||||
"timeout",
|
||||
{
|
||||
guildId: "G1",
|
||||
userId: "U1",
|
||||
durationMinutes: 5.5,
|
||||
},
|
||||
moderationEnabled,
|
||||
),
|
||||
).rejects.toThrow("durationMinutes must be a non-negative integer");
|
||||
expect(timeoutMemberDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves zero-minute Discord timeouts for clearing existing timeouts", async () => {
|
||||
await handleModerationAction(
|
||||
"timeout",
|
||||
{
|
||||
guildId: "G1",
|
||||
userId: "U1",
|
||||
durationMinutes: 0,
|
||||
},
|
||||
moderationEnabled,
|
||||
);
|
||||
expect(timeoutMemberDiscord).toHaveBeenCalledTimes(1);
|
||||
const params = mockObjectArg(timeoutMemberDiscord, "timeoutMemberDiscord", 0, 0);
|
||||
expect(params.durationMinutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDiscordAction per-account gating", () => {
|
||||
|
||||
@@ -233,16 +233,14 @@ export function createDiscordDraftPreviewController(params: {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
},
|
||||
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
||||
async pushReasoningProgress(text?: string) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !text) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
|
||||
snapshot: options?.snapshot === true,
|
||||
});
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text);
|
||||
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
|
||||
if (!normalized) {
|
||||
return;
|
||||
@@ -405,11 +403,7 @@ function normalizeReasoningProgressLine(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
options?: { snapshot?: boolean },
|
||||
): string {
|
||||
function mergeReasoningProgressText(current: string, incoming: string): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
@@ -418,11 +412,7 @@ function mergeReasoningProgressText(
|
||||
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
options?.snapshot === true ||
|
||||
isReasoningSnapshotText(incoming) ||
|
||||
normalizedIncoming.startsWith(normalizedCurrent)
|
||||
) {
|
||||
if (isReasoningSnapshotText(incoming) || normalizedIncoming.startsWith(normalizedCurrent)) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
|
||||
@@ -115,10 +115,7 @@ type DispatchInboundParams = {
|
||||
waitForIdle: () => Promise<void>;
|
||||
};
|
||||
replyOptions?: {
|
||||
onReasoningStream?: (payload?: {
|
||||
text?: string;
|
||||
isReasoningSnapshot?: boolean;
|
||||
}) => Promise<void> | void;
|
||||
onReasoningStream?: (payload?: { text?: string }) => Promise<void> | void;
|
||||
onReasoningEnd?: () => Promise<void> | void;
|
||||
onToolStart?: (payload: {
|
||||
name?: string;
|
||||
@@ -2741,13 +2738,9 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await params?.replyOptions?.onReasoningStream?.({ text: "Checking files" });
|
||||
await params?.replyOptions?.onReasoningStream?.({
|
||||
text: "Checking ",
|
||||
isReasoningSnapshot: true,
|
||||
});
|
||||
await params?.replyOptions?.onReasoningStream?.({
|
||||
text: "Reading \n\nChecking ",
|
||||
isReasoningSnapshot: true,
|
||||
text: "Checking files and tests",
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
@@ -2766,10 +2759,11 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith(
|
||||
"Clawing...\n\n🛠️ Exec\n• _Reading _ _Checking_",
|
||||
"Clawing...\n\n🛠️ Exec\n• _Checking files and tests_",
|
||||
);
|
||||
const updates = draftStream.update.mock.calls.map((call) => call[0]);
|
||||
expect(updates.join("\n")).not.toContain("_Checking Reading");
|
||||
expect(updates.join("\n")).not.toContain("_Checking files_Reasoning:");
|
||||
expect(updates.join("\n")).not.toContain("_Checking files_Thinking");
|
||||
});
|
||||
|
||||
it("keeps Discord progress lines across assistant boundaries", async () => {
|
||||
|
||||
@@ -955,9 +955,7 @@ export async function processDiscordMessage(
|
||||
onReasoningStream: async (payload) => {
|
||||
await statusReactions.setThinking();
|
||||
const formattedText = payload?.text ? formatReasoningMessage(payload.text) : undefined;
|
||||
await draftPreview.pushReasoningProgress(formattedText, {
|
||||
snapshot: payload?.isReasoningSnapshot === true,
|
||||
});
|
||||
await draftPreview.pushReasoningProgress(formattedText);
|
||||
},
|
||||
onToolStart: async (payload) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
|
||||
@@ -24,9 +24,7 @@ export type {
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export {
|
||||
jsonResult,
|
||||
readNonNegativeIntegerParam,
|
||||
readNumberParam,
|
||||
readPositiveIntegerParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
resolvePollMaxSelections,
|
||||
|
||||
@@ -5505,7 +5505,7 @@ describe("DiscordVoiceManager", () => {
|
||||
await vi.waitFor(() => expect(release).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("passes per-channel system prompt context to voice agent runs", async () => {
|
||||
it("passes per-channel system prompt overrides to voice agent runs", async () => {
|
||||
const client = createClient();
|
||||
client.fetchMember.mockResolvedValue({
|
||||
nickname: "Guest Nick",
|
||||
|
||||
@@ -85,24 +85,6 @@ describe("duckduckgo web search provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional and out-of-range counts before searching", async () => {
|
||||
const provider = createDuckDuckGoWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: { test: true },
|
||||
} as never);
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "openclaw docs", count: 4.5 })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 10.",
|
||||
);
|
||||
await expect(tool.execute({ query: "openclaw docs", count: 11 })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 10.",
|
||||
);
|
||||
expect(runDuckDuckGoSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads region from plugin config and normalizes empty values away", () => {
|
||||
expect(
|
||||
resolveDdgRegion({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createDuckDuckGoWebSearchProviderBase } from "./ddg-search-provider.shared.js";
|
||||
|
||||
@@ -16,7 +16,7 @@ const DuckDuckGoSearchSchema = {
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
type: "number",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
@@ -45,10 +45,7 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return await runDuckDuckGoSearch({
|
||||
config: ctx.config,
|
||||
query: readStringParam(args, "query", { required: true }),
|
||||
count: readPositiveIntegerParam(args, "count", {
|
||||
max: 10,
|
||||
message: "count must be an integer from 1 to 10.",
|
||||
}),
|
||||
count: readNumberParam(args, "count", { integer: true }),
|
||||
region: readStringParam(args, "region"),
|
||||
safeSearch: readStringParam(args, "safeSearch") as
|
||||
| "strict"
|
||||
|
||||
@@ -39,58 +39,6 @@ describe("buildElevenLabsRealtimeTranscriptionProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed numeric realtime config values", () => {
|
||||
const provider = buildElevenLabsRealtimeTranscriptionProvider();
|
||||
const resolved = provider.resolveConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawConfig: {
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
sample_rate: "8000.5",
|
||||
vad_silence_threshold_secs: "999",
|
||||
vad_threshold: "0",
|
||||
min_speech_duration_ms: "0",
|
||||
min_silence_duration_ms: "10.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
sampleRate: undefined,
|
||||
vadSilenceThresholdSecs: undefined,
|
||||
vadThreshold: undefined,
|
||||
minSpeechDurationMs: undefined,
|
||||
minSilenceDurationMs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps realtime VAD numeric config inside provider ranges", () => {
|
||||
const provider = buildElevenLabsRealtimeTranscriptionProvider();
|
||||
const resolved = provider.resolveConfig?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawConfig: {
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
sample_rate: "8000",
|
||||
vad_silence_threshold_secs: "3",
|
||||
vad_threshold: "0.9",
|
||||
min_speech_duration_ms: "50",
|
||||
min_silence_duration_ms: "2000",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
sampleRate: 8000,
|
||||
vadSilenceThresholdSecs: 3,
|
||||
vadThreshold: 0.9,
|
||||
minSpeechDurationMs: 50,
|
||||
minSilenceDurationMs: 2000,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an ElevenLabs realtime websocket URL", () => {
|
||||
const url = testing.toElevenLabsRealtimeWsUrl({
|
||||
apiKey: "eleven-key",
|
||||
|
||||
@@ -78,23 +78,6 @@ function normalizeCommitStrategy(value: unknown): "manual" | "vad" | undefined {
|
||||
throw new Error(`Invalid ElevenLabs realtime transcription commit strategy: ${normalized}`);
|
||||
}
|
||||
|
||||
function normalizePositiveSafeInteger(value: unknown): number | undefined {
|
||||
const parsed = readFiniteNumber(value);
|
||||
return parsed !== undefined && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function normalizeFiniteRange(value: unknown, min: number, max: number): number | undefined {
|
||||
const parsed = readFiniteNumber(value);
|
||||
return parsed !== undefined && parsed >= min && parsed <= max ? parsed : undefined;
|
||||
}
|
||||
|
||||
function normalizeIntegerRange(value: unknown, min: number, max: number): number | undefined {
|
||||
const parsed = readFiniteNumber(value);
|
||||
return parsed !== undefined && Number.isSafeInteger(parsed) && parsed >= min && parsed <= max
|
||||
? parsed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeProviderConfig(
|
||||
config: RealtimeTranscriptionProviderConfig,
|
||||
): ElevenLabsRealtimeTranscriptionProviderConfig {
|
||||
@@ -107,25 +90,15 @@ function normalizeProviderConfig(
|
||||
baseUrl: normalizeOptionalString(raw.baseUrl),
|
||||
modelId: normalizeOptionalString(raw.modelId ?? raw.model ?? raw.sttModel),
|
||||
audioFormat: normalizeOptionalString(raw.audioFormat ?? raw.audio_format ?? raw.encoding),
|
||||
sampleRate: normalizePositiveSafeInteger(raw.sampleRate ?? raw.sample_rate),
|
||||
sampleRate: readFiniteNumber(raw.sampleRate ?? raw.sample_rate),
|
||||
languageCode: normalizeOptionalString(raw.languageCode ?? raw.language),
|
||||
commitStrategy: normalizeCommitStrategy(raw.commitStrategy ?? raw.commit_strategy),
|
||||
vadSilenceThresholdSecs: normalizeFiniteRange(
|
||||
vadSilenceThresholdSecs: readFiniteNumber(
|
||||
raw.vadSilenceThresholdSecs ?? raw.vad_silence_threshold_secs,
|
||||
0.3,
|
||||
3,
|
||||
),
|
||||
vadThreshold: normalizeFiniteRange(raw.vadThreshold ?? raw.vad_threshold, 0.1, 0.9),
|
||||
minSpeechDurationMs: normalizeIntegerRange(
|
||||
raw.minSpeechDurationMs ?? raw.min_speech_duration_ms,
|
||||
50,
|
||||
2_000,
|
||||
),
|
||||
minSilenceDurationMs: normalizeIntegerRange(
|
||||
raw.minSilenceDurationMs ?? raw.min_silence_duration_ms,
|
||||
50,
|
||||
2_000,
|
||||
),
|
||||
vadThreshold: readFiniteNumber(raw.vadThreshold ?? raw.vad_threshold),
|
||||
minSpeechDurationMs: readFiniteNumber(raw.minSpeechDurationMs ?? raw.min_speech_duration_ms),
|
||||
minSilenceDurationMs: readFiniteNumber(raw.minSilenceDurationMs ?? raw.min_silence_duration_ms),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -121,91 +121,4 @@ describe("elevenlabs speech provider", () => {
|
||||
expect(result?.outputFormat).toBe("pcm_22050");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops out-of-range voice settings before synthesis", async () => {
|
||||
const provider = buildElevenLabsSpeechProvider();
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = parseRequestBody(init);
|
||||
expect(body.voice_settings).toEqual({
|
||||
stability: 0.5,
|
||||
similarity_boost: 0.75,
|
||||
style: 0,
|
||||
use_speaker_boost: true,
|
||||
speed: 1,
|
||||
});
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await provider.synthesizeTelephony?.({
|
||||
text: "hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "xi-test",
|
||||
voiceSettings: {
|
||||
stability: -1,
|
||||
similarityBoost: 2,
|
||||
style: Number.NaN,
|
||||
speed: 3,
|
||||
},
|
||||
},
|
||||
providerOverrides: {
|
||||
voiceSettings: {
|
||||
speed: 0.1,
|
||||
},
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops malformed seed values before synthesis", async () => {
|
||||
const provider = buildElevenLabsSpeechProvider();
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = parseRequestBody(init);
|
||||
expect(body).not.toHaveProperty("seed");
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await provider.synthesizeTelephony?.({
|
||||
text: "hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "xi-test",
|
||||
seed: 1.5,
|
||||
},
|
||||
providerOverrides: {
|
||||
seed: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops malformed latency tier overrides before synthesis", async () => {
|
||||
const provider = buildElevenLabsSpeechProvider();
|
||||
const fetchMock = vi.fn(async (url: string) => {
|
||||
expect(new URL(url).searchParams.has("optimize_streaming_latency")).toBe(false);
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await provider.synthesize?.({
|
||||
text: "hello",
|
||||
target: "audio-file",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "xi-test",
|
||||
},
|
||||
providerOverrides: {
|
||||
latencyTier: 2.5,
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,50 +78,6 @@ function parseNumberValue(value: string): number | undefined {
|
||||
|
||||
export const isValidVoiceId = isValidElevenLabsVoiceId;
|
||||
|
||||
function normalizeVoiceSetting(value: unknown, min: number, max: number): number | undefined {
|
||||
const number = asFiniteNumber(value);
|
||||
return number !== undefined && number >= min && number <= max ? number : undefined;
|
||||
}
|
||||
|
||||
function normalizeElevenLabsSeed(value: unknown): number | undefined {
|
||||
const seed = asFiniteNumber(value);
|
||||
return seed !== undefined && Number.isSafeInteger(seed) && seed >= 0 && seed <= 4_294_967_295
|
||||
? seed
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeElevenLabsLatencyTier(value: unknown): number | undefined {
|
||||
const latencyTier = asFiniteNumber(value);
|
||||
return latencyTier !== undefined &&
|
||||
Number.isSafeInteger(latencyTier) &&
|
||||
latencyTier >= 0 &&
|
||||
latencyTier <= 4
|
||||
? latencyTier
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeVoiceSettings(
|
||||
rawVoiceSettings: Record<string, unknown> | undefined,
|
||||
): Partial<ElevenLabsProviderConfig["voiceSettings"]> {
|
||||
return {
|
||||
...(normalizeVoiceSetting(rawVoiceSettings?.stability, 0, 1) == null
|
||||
? {}
|
||||
: { stability: normalizeVoiceSetting(rawVoiceSettings?.stability, 0, 1) }),
|
||||
...(normalizeVoiceSetting(rawVoiceSettings?.similarityBoost, 0, 1) == null
|
||||
? {}
|
||||
: { similarityBoost: normalizeVoiceSetting(rawVoiceSettings?.similarityBoost, 0, 1) }),
|
||||
...(normalizeVoiceSetting(rawVoiceSettings?.style, 0, 1) == null
|
||||
? {}
|
||||
: { style: normalizeVoiceSetting(rawVoiceSettings?.style, 0, 1) }),
|
||||
...(asBoolean(rawVoiceSettings?.useSpeakerBoost) == null
|
||||
? {}
|
||||
: { useSpeakerBoost: asBoolean(rawVoiceSettings?.useSpeakerBoost) }),
|
||||
...(normalizeVoiceSetting(rawVoiceSettings?.speed, 0.5, 2) == null
|
||||
? {}
|
||||
: { speed: normalizeVoiceSetting(rawVoiceSettings?.speed, 0.5, 2) }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeElevenLabsProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): ElevenLabsProviderConfig {
|
||||
@@ -136,7 +92,7 @@ function normalizeElevenLabsProviderConfig(
|
||||
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(raw?.baseUrl)),
|
||||
voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_ELEVENLABS_VOICE_ID,
|
||||
modelId: trimToUndefined(raw?.modelId) ?? DEFAULT_ELEVENLABS_MODEL_ID,
|
||||
seed: normalizeElevenLabsSeed(raw?.seed),
|
||||
seed: asFiniteNumber(raw?.seed),
|
||||
applyTextNormalization: trimToUndefined(raw?.applyTextNormalization) as
|
||||
| "auto"
|
||||
| "on"
|
||||
@@ -144,8 +100,16 @@ function normalizeElevenLabsProviderConfig(
|
||||
| undefined,
|
||||
languageCode: trimToUndefined(raw?.languageCode),
|
||||
voiceSettings: {
|
||||
...DEFAULT_ELEVENLABS_VOICE_SETTINGS,
|
||||
...normalizeVoiceSettings(rawVoiceSettings),
|
||||
stability:
|
||||
asFiniteNumber(rawVoiceSettings?.stability) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.stability,
|
||||
similarityBoost:
|
||||
asFiniteNumber(rawVoiceSettings?.similarityBoost) ??
|
||||
DEFAULT_ELEVENLABS_VOICE_SETTINGS.similarityBoost,
|
||||
style: asFiniteNumber(rawVoiceSettings?.style) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.style,
|
||||
useSpeakerBoost:
|
||||
asBoolean(rawVoiceSettings?.useSpeakerBoost) ??
|
||||
DEFAULT_ELEVENLABS_VOICE_SETTINGS.useSpeakerBoost,
|
||||
speed: asFiniteNumber(rawVoiceSettings?.speed) ?? DEFAULT_ELEVENLABS_VOICE_SETTINGS.speed,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -158,14 +122,19 @@ function readElevenLabsProviderConfig(config: SpeechProviderConfig): ElevenLabsP
|
||||
baseUrl: normalizeElevenLabsBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl),
|
||||
voiceId: trimToUndefined(config.voiceId) ?? defaults.voiceId,
|
||||
modelId: trimToUndefined(config.modelId) ?? defaults.modelId,
|
||||
seed: normalizeElevenLabsSeed(config.seed) ?? defaults.seed,
|
||||
seed: asFiniteNumber(config.seed) ?? defaults.seed,
|
||||
applyTextNormalization:
|
||||
(trimToUndefined(config.applyTextNormalization) as "auto" | "on" | "off" | undefined) ??
|
||||
defaults.applyTextNormalization,
|
||||
languageCode: trimToUndefined(config.languageCode) ?? defaults.languageCode,
|
||||
voiceSettings: {
|
||||
...defaults.voiceSettings,
|
||||
...normalizeVoiceSettings(voiceSettings),
|
||||
stability: asFiniteNumber(voiceSettings?.stability) ?? defaults.voiceSettings.stability,
|
||||
similarityBoost:
|
||||
asFiniteNumber(voiceSettings?.similarityBoost) ?? defaults.voiceSettings.similarityBoost,
|
||||
style: asFiniteNumber(voiceSettings?.style) ?? defaults.voiceSettings.style,
|
||||
useSpeakerBoost:
|
||||
asBoolean(voiceSettings?.useSpeakerBoost) ?? defaults.voiceSettings.useSpeakerBoost,
|
||||
speed: asFiniteNumber(voiceSettings?.speed) ?? defaults.voiceSettings.speed,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -190,7 +159,21 @@ function resolveVoiceSettingsOverride(
|
||||
const voiceSettings = asObject(overrides);
|
||||
return {
|
||||
...base,
|
||||
...normalizeVoiceSettings(voiceSettings),
|
||||
...(asFiniteNumber(voiceSettings?.stability) == null
|
||||
? {}
|
||||
: { stability: asFiniteNumber(voiceSettings?.stability) }),
|
||||
...(asFiniteNumber(voiceSettings?.similarityBoost) == null
|
||||
? {}
|
||||
: { similarityBoost: asFiniteNumber(voiceSettings?.similarityBoost) }),
|
||||
...(asFiniteNumber(voiceSettings?.style) == null
|
||||
? {}
|
||||
: { style: asFiniteNumber(voiceSettings?.style) }),
|
||||
...(asBoolean(voiceSettings?.useSpeakerBoost) == null
|
||||
? {}
|
||||
: { useSpeakerBoost: asBoolean(voiceSettings?.useSpeakerBoost) }),
|
||||
...(asFiniteNumber(voiceSettings?.speed) == null
|
||||
? {}
|
||||
: { speed: asFiniteNumber(voiceSettings?.speed) }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,9 +389,9 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
...(trimToUndefined(talkProviderConfig.modelId) == null
|
||||
? {}
|
||||
: { modelId: trimToUndefined(talkProviderConfig.modelId) }),
|
||||
...(normalizeElevenLabsSeed(talkProviderConfig.seed) == null
|
||||
...(asFiniteNumber(talkProviderConfig.seed) == null
|
||||
? {}
|
||||
: { seed: normalizeElevenLabsSeed(talkProviderConfig.seed) }),
|
||||
: { seed: asFiniteNumber(talkProviderConfig.seed) }),
|
||||
...(trimToUndefined(talkProviderConfig.applyTextNormalization) == null
|
||||
? {}
|
||||
: {
|
||||
@@ -423,27 +406,37 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
}),
|
||||
voiceSettings: {
|
||||
...base.voiceSettings,
|
||||
...normalizeVoiceSettings(talkVoiceSettings),
|
||||
...(asFiniteNumber(talkVoiceSettings?.stability) == null
|
||||
? {}
|
||||
: { stability: asFiniteNumber(talkVoiceSettings?.stability) }),
|
||||
...(asFiniteNumber(talkVoiceSettings?.similarityBoost) == null
|
||||
? {}
|
||||
: { similarityBoost: asFiniteNumber(talkVoiceSettings?.similarityBoost) }),
|
||||
...(asFiniteNumber(talkVoiceSettings?.style) == null
|
||||
? {}
|
||||
: { style: asFiniteNumber(talkVoiceSettings?.style) }),
|
||||
...(asBoolean(talkVoiceSettings?.useSpeakerBoost) == null
|
||||
? {}
|
||||
: { useSpeakerBoost: asBoolean(talkVoiceSettings?.useSpeakerBoost) }),
|
||||
...(asFiniteNumber(talkVoiceSettings?.speed) == null
|
||||
? {}
|
||||
: { speed: asFiniteNumber(talkVoiceSettings?.speed) }),
|
||||
},
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => {
|
||||
const normalize = trimToUndefined(params.normalize);
|
||||
const language = normalizeLowercaseStringOrEmpty(trimToUndefined(params.language));
|
||||
const latencyTier = normalizeElevenLabsLatencyTier(params.latencyTier);
|
||||
const latencyTier = asFiniteNumber(params.latencyTier);
|
||||
const voiceSettings = {
|
||||
...(normalizeVoiceSetting(params.speed, 0.5, 2) == null
|
||||
...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }),
|
||||
...(asFiniteNumber(params.stability) == null
|
||||
? {}
|
||||
: { speed: normalizeVoiceSetting(params.speed, 0.5, 2) }),
|
||||
...(normalizeVoiceSetting(params.stability, 0, 1) == null
|
||||
: { stability: asFiniteNumber(params.stability) }),
|
||||
...(asFiniteNumber(params.similarity) == null
|
||||
? {}
|
||||
: { stability: normalizeVoiceSetting(params.stability, 0, 1) }),
|
||||
...(normalizeVoiceSetting(params.similarity, 0, 1) == null
|
||||
? {}
|
||||
: { similarityBoost: normalizeVoiceSetting(params.similarity, 0, 1) }),
|
||||
...(normalizeVoiceSetting(params.style, 0, 1) == null
|
||||
? {}
|
||||
: { style: normalizeVoiceSetting(params.style, 0, 1) }),
|
||||
: { similarityBoost: asFiniteNumber(params.similarity) }),
|
||||
...(asFiniteNumber(params.style) == null ? {} : { style: asFiniteNumber(params.style) }),
|
||||
...(asBoolean(params.speakerBoost) == null
|
||||
? {}
|
||||
: { useSpeakerBoost: asBoolean(params.speakerBoost) }),
|
||||
@@ -458,9 +451,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
...(trimToUndefined(params.outputFormat) == null
|
||||
? {}
|
||||
: { outputFormat: trimToUndefined(params.outputFormat) }),
|
||||
...(normalizeElevenLabsSeed(params.seed) == null
|
||||
? {}
|
||||
: { seed: normalizeElevenLabsSeed(params.seed) }),
|
||||
...(asFiniteNumber(params.seed) == null ? {} : { seed: asFiniteNumber(params.seed) }),
|
||||
...(normalize == null
|
||||
? {}
|
||||
: { applyTextNormalization: normalizeApplyTextNormalization(normalize) }),
|
||||
@@ -503,7 +494,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
const outputFormat =
|
||||
trimToUndefined(overrides.outputFormat) ??
|
||||
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
|
||||
const latencyTier = normalizeElevenLabsLatencyTier(overrides.latencyTier);
|
||||
const latencyTier = asFiniteNumber(overrides.latencyTier);
|
||||
const audioBuffer = await elevenLabsTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
@@ -511,7 +502,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
|
||||
modelId: trimToUndefined(overrides.modelId) ?? config.modelId,
|
||||
outputFormat,
|
||||
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
|
||||
seed: asFiniteNumber(overrides.seed) ?? config.seed,
|
||||
applyTextNormalization:
|
||||
(trimToUndefined(overrides.applyTextNormalization) as
|
||||
| "auto"
|
||||
@@ -541,7 +532,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
const outputFormat =
|
||||
trimToUndefined(overrides.outputFormat) ??
|
||||
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
|
||||
const latencyTier = normalizeElevenLabsLatencyTier(overrides.latencyTier);
|
||||
const latencyTier = asFiniteNumber(overrides.latencyTier);
|
||||
const stream = await elevenLabsTTSStream({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
@@ -549,7 +540,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
|
||||
modelId: trimToUndefined(overrides.modelId) ?? config.modelId,
|
||||
outputFormat,
|
||||
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
|
||||
seed: asFiniteNumber(overrides.seed) ?? config.seed,
|
||||
applyTextNormalization:
|
||||
(trimToUndefined(overrides.applyTextNormalization) as
|
||||
| "auto"
|
||||
@@ -586,7 +577,7 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
|
||||
modelId: trimToUndefined(overrides.modelId) ?? config.modelId,
|
||||
outputFormat,
|
||||
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
|
||||
seed: asFiniteNumber(overrides.seed) ?? config.seed,
|
||||
applyTextNormalization:
|
||||
(trimToUndefined(overrides.applyTextNormalization) as
|
||||
| "auto"
|
||||
|
||||
@@ -160,20 +160,6 @@ describe("elevenlabs tts diagnostics", () => {
|
||||
expect(body.latency_optimization_level).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects fractional latency optimization instead of truncating it", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
elevenLabsTTS({
|
||||
...createDefaultTtsRequest(),
|
||||
latencyTier: 3.9,
|
||||
}),
|
||||
).rejects.toThrow("latencyTier must be an integer");
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits latency optimization for eleven_v3 because the API rejects it", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(Buffer.from("mp3")));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user