mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-08 15:01:17 +08:00
Compare commits
93 Commits
codeql-app
...
refactor/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b0825d8c | ||
|
|
b1b9f0094c | ||
|
|
9a6a663d84 | ||
|
|
db365d531f | ||
|
|
b469092d92 | ||
|
|
064ec5f07c | ||
|
|
c3f5af897c | ||
|
|
aa1834a3ff | ||
|
|
d770a3b786 | ||
|
|
6a387afc53 | ||
|
|
94fc91e235 | ||
|
|
5a1ff1347d | ||
|
|
a722da3ed0 | ||
|
|
d70191f8af | ||
|
|
7150acba69 | ||
|
|
35bc13f9ef | ||
|
|
32c987626b | ||
|
|
92016b82ae | ||
|
|
7727e102a5 | ||
|
|
1bd4b7ac4d | ||
|
|
7950a18025 | ||
|
|
e2f3044b8f | ||
|
|
f12dedb5c8 | ||
|
|
1b13f53047 | ||
|
|
77192572f6 | ||
|
|
6cc6996a1c | ||
|
|
c9ead1b928 | ||
|
|
ade9aaae89 | ||
|
|
1fcf0a422f | ||
|
|
9da76c4255 | ||
|
|
17ef9ef895 | ||
|
|
5915489631 | ||
|
|
6f8792f3f1 | ||
|
|
0bc8b9a95a | ||
|
|
ab3feca0d5 | ||
|
|
9207660c87 | ||
|
|
ae63f76bbd | ||
|
|
c5cd7aabcf | ||
|
|
210cccb0fe | ||
|
|
a6bb0265f0 | ||
|
|
17811480da | ||
|
|
cfbf4d1fa4 | ||
|
|
058b57867e | ||
|
|
b4ffef5c5f | ||
|
|
1346a31861 | ||
|
|
f5922e6eb1 | ||
|
|
5820a48fca | ||
|
|
1f1b98e33b | ||
|
|
aa2f964bda | ||
|
|
ad954dd1ca | ||
|
|
5f3b8b4100 | ||
|
|
0f24a8d8e1 | ||
|
|
fac116cfa4 | ||
|
|
5741e40c14 | ||
|
|
9cdae734a7 | ||
|
|
1912e309f7 | ||
|
|
62997f7fce | ||
|
|
0876ff481b | ||
|
|
8f277e4b7f | ||
|
|
bca30b62be | ||
|
|
249cb54373 | ||
|
|
7fd9c152d1 | ||
|
|
47dc9f7fc0 | ||
|
|
6f3b5f8666 | ||
|
|
2790825ae5 | ||
|
|
11f0244cf4 | ||
|
|
b6a21cde34 | ||
|
|
76cd97289b | ||
|
|
02908db62b | ||
|
|
3ed3248d7b | ||
|
|
4c61040c52 | ||
|
|
fe7865aad6 | ||
|
|
8a98c08c8a | ||
|
|
28bf71d74b | ||
|
|
a3bbcf2792 | ||
|
|
3ee5490c60 | ||
|
|
e2bcec33b3 | ||
|
|
7e028917c0 | ||
|
|
5ac6d7661c | ||
|
|
f76c8322d3 | ||
|
|
474859aaaa | ||
|
|
99ceaaa76e | ||
|
|
a68ca1ae0b | ||
|
|
8178b62187 | ||
|
|
2276f660f3 | ||
|
|
8ff0ea50b0 | ||
|
|
bab403d0ee | ||
|
|
169dba2042 | ||
|
|
4f6dab852e | ||
|
|
09ec5d2c4d | ||
|
|
2a1e47ffcb | ||
|
|
732e5805e3 | ||
|
|
7092313b2f |
234
.agents/skills/openclaw-pre-release-plugin-testing/SKILL.md
Normal file
234
.agents/skills/openclaw-pre-release-plugin-testing/SKILL.md
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
name: openclaw-pre-release-plugin-testing
|
||||
description: Plan and run pre-release OpenClaw plugin validation across bundled plugins, package artifacts, lifecycle commands, doctor/fix, config round-trip, gateway startup, SDK compatibility, Docker E2E, Package Acceptance, and Testbox proof.
|
||||
---
|
||||
|
||||
# OpenClaw Pre-Release Plugin Testing
|
||||
|
||||
Use this skill when the user asks for plugin release confidence, plugin lifecycle
|
||||
sweeps, package-artifact plugin proof, or "what else should we test before
|
||||
release?" It complements `openclaw-testing`; use that skill too when choosing
|
||||
the cheapest safe runner or debugging a failing lane.
|
||||
|
||||
## Goal
|
||||
|
||||
Prove the plugin system as a product surface, not just as source tests:
|
||||
|
||||
- bundled plugin lifecycle: install, inspect, enable, disable, uninstall
|
||||
- package artifact behavior from a clean `HOME`
|
||||
- doctor/fix/config validation and idempotence
|
||||
- config discovery and config round-trip
|
||||
- status/log visibility and diagnostics
|
||||
- gateway startup/bootstrap with plugin metadata snapshots
|
||||
- public SDK compatibility for real external plugins
|
||||
- live-ish provider/channel probes only when safe credentials exist
|
||||
|
||||
## First Checks
|
||||
|
||||
From the OpenClaw repo root:
|
||||
|
||||
```bash
|
||||
pnpm docs:list
|
||||
git status --short --branch
|
||||
readlink node_modules
|
||||
pnpm changed:lanes --json
|
||||
```
|
||||
|
||||
In Codex worktrees under `.codex/worktrees`, `node_modules` must be a symlink to
|
||||
the main OpenClaw checkout. Do not run `pnpm install` there. For broad or
|
||||
package-heavy proof, use Blacksmith Testbox or GitHub Actions.
|
||||
|
||||
## Runner Choice
|
||||
|
||||
Prefer this order:
|
||||
|
||||
1. **GitHub Package Acceptance** for installable-package product proof.
|
||||
2. **`ci-build-artifacts-testbox.yml` Testbox** when Docker/package lanes need
|
||||
seeded `dist`, `dist-runtime`, and package caches.
|
||||
3. **`ci-check-testbox.yml` Testbox** for source checks, targeted Vitest,
|
||||
package-boundary checks, or focused Docker lanes.
|
||||
4. **Local targeted commands only** for small format/static/unit probes.
|
||||
|
||||
Avoid long package Docker runs from a stale sparse worktree. If Testbox sync
|
||||
reports hundreds of changed files or starts deleting package inputs, stop and
|
||||
warm a fresh box from current `main`, or switch to Package Acceptance.
|
||||
|
||||
## Existing Baseline
|
||||
|
||||
Run or verify these before inventing new coverage:
|
||||
|
||||
```bash
|
||||
OPENCLAW_TESTBOX=1 pnpm check:changed
|
||||
pnpm run test:extensions:package-boundary:canary
|
||||
pnpm run test:extensions:package-boundary:compile
|
||||
pnpm test:docker:plugins
|
||||
OPENCLAW_PLUGINS_E2E_CLAWHUB=0 pnpm test:docker:plugins
|
||||
pnpm test:docker:plugin-update
|
||||
pnpm test:docker:bundled-channel-deps:fast
|
||||
```
|
||||
|
||||
For full bundled install/uninstall proof, shard the packaged sweep:
|
||||
|
||||
```bash
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL=8 \
|
||||
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX=<0-7> \
|
||||
pnpm test:docker:bundled-plugin-install-uninstall
|
||||
```
|
||||
|
||||
Expected current packaged scope: 116 public bundled plugins over shards `0-7`.
|
||||
Private QA plugins are source-mode only unless a package explicitly includes
|
||||
them.
|
||||
|
||||
## Confidence Matrix
|
||||
|
||||
Use this matrix for pre-release signoff. Record pass/fail, run URL/Testbox ID,
|
||||
package SHA/version, and skipped-live reason.
|
||||
|
||||
| Surface | Proof | Preferred runner |
|
||||
| --- | --- | --- |
|
||||
| Package artifact | Package Acceptance `suite_profile=package` or custom lanes | GitHub Actions |
|
||||
| Bundled lifecycle | 8-shard `test:docker:bundled-plugin-install-uninstall` | Testbox or release Docker |
|
||||
| External plugins | `test:docker:plugins` and `plugins-offline` | Testbox/package acceptance |
|
||||
| Update no-op | `test:docker:plugin-update` | Testbox/package acceptance |
|
||||
| Channel runtime deps | `test:docker:bundled-channel-deps:fast` plus key channels | Testbox/package acceptance |
|
||||
| Doctor/fix | seeded bad configs + `doctor --fix --non-interactive` | new Docker/Testbox harness |
|
||||
| Config round-trip | `config set/get`, inspect, doctor, reload, diff hash | new Docker/Testbox harness |
|
||||
| Gateway bootstrap | clean `HOME`, plugin groups enabled/disabled, status JSON | new Docker/Testbox harness |
|
||||
| SDK compatibility | directory, tgz, and `file:` external plugins using SDK subpaths | `test:docker:plugins` plus new smoke |
|
||||
| Live-ish | redacted provider/channel probes only for present env | Testbox live lanes |
|
||||
|
||||
## Package Acceptance Plan
|
||||
|
||||
Use this when validating a release branch, beta, or candidate package:
|
||||
|
||||
```bash
|
||||
gh workflow run package-acceptance.yml \
|
||||
--repo openclaw/openclaw \
|
||||
--ref main \
|
||||
-f workflow_ref=main \
|
||||
-f source=ref \
|
||||
-f package_ref=<branch-or-sha> \
|
||||
-f suite_profile=custom \
|
||||
-f docker_lanes='plugins-offline plugin-update bundled-channel-deps-compat doctor-switch update-channel-switch config-reload mcp-channels npm-onboard-channel-agent' \
|
||||
-f telegram_mode=mock-openai
|
||||
```
|
||||
|
||||
Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
|
||||
`workflow_ref` as trusted current harness code unless the release process says
|
||||
otherwise.
|
||||
|
||||
## New Testbox Harness Plan
|
||||
|
||||
If more certainty is needed, add or run a `plugin-lifecycle-matrix` Docker lane
|
||||
that uses one package tarball and sharded plugin lists. Per plugin:
|
||||
|
||||
1. Start with a clean `HOME`.
|
||||
2. Capture `plugins list --json`.
|
||||
3. `plugins install <id>`.
|
||||
4. `plugins inspect <id> --json`.
|
||||
5. `plugins disable <id>`, then assert disabled visibility.
|
||||
6. `plugins enable <id>`, except config-required plugins without config.
|
||||
7. `plugins registry --refresh`.
|
||||
8. `doctor --non-interactive`.
|
||||
9. `plugins uninstall <id> --force`.
|
||||
10. Assert no config entry, allow/deny residue, install record, managed dir, or
|
||||
bundled `dist/extensions/...` load path remains.
|
||||
11. Assert diagnostics contain no `level: "error"` and output redacts
|
||||
secret-looking values.
|
||||
|
||||
Keep `memory-lancedb` special: it is config-required. First assert install does
|
||||
not enable it without embedding config, then run a second configured case.
|
||||
|
||||
## Doctor/Fix Matrix
|
||||
|
||||
Seed bad states and require `doctor --fix --non-interactive` to repair them,
|
||||
then run doctor again and require idempotence:
|
||||
|
||||
- stale `plugins.allow`
|
||||
- stale `plugins.entries`
|
||||
- stale channel config for missing channel plugin
|
||||
- invalid `plugins.entries.<id>.config`
|
||||
- packaged bundled path in `plugins.load.paths`
|
||||
- legacy `plugins.installs`
|
||||
- disabled channel/plugin config that must not stage runtime deps
|
||||
- root-owned global package tree that must remain unmodified
|
||||
|
||||
## Gateway Bootstrap Matrix
|
||||
|
||||
Start packaged OpenClaw in Docker with clean state:
|
||||
|
||||
- provider plugins enabled, no credentials: ready with warnings, no crash
|
||||
- channel plugins configured disabled: no runtime deps staged
|
||||
- startup-activation plugins enabled: ready and reflected in status
|
||||
- invalid single plugin config: bad plugin skipped/quarantined, others remain
|
||||
|
||||
Assert:
|
||||
|
||||
- gateway reaches ready
|
||||
- `openclaw status --json` includes plugin diagnostics
|
||||
- `openclaw plugins inspect --all --json` is parseable
|
||||
- package tree is not mutated
|
||||
- logs contain no raw tokens
|
||||
|
||||
## Config Round-Trip Representatives
|
||||
|
||||
Use representative plugin families instead of every plugin for deep config
|
||||
round-trip:
|
||||
|
||||
- providers: `openai`, `anthropic`, `mistral`, `openrouter`
|
||||
- channels: `telegram`, `discord`, `slack`, `whatsapp`
|
||||
- memory: `memory-lancedb`
|
||||
- feature/runtime: `browser`, `acpx`, `tokenjuice`
|
||||
|
||||
For each representative:
|
||||
|
||||
1. Write config through CLI when possible.
|
||||
2. Read it back through `config get` or JSON.
|
||||
3. Run `plugins inspect`.
|
||||
4. Run `doctor --non-interactive`.
|
||||
5. Trigger gateway config reload if applicable.
|
||||
6. Compare config hash before/after no-op commands.
|
||||
|
||||
## External SDK Smoke
|
||||
|
||||
In a package Docker lane, create tiny external plugins and install them from:
|
||||
|
||||
- local directory
|
||||
- `.tgz`
|
||||
- `file:` npm spec
|
||||
|
||||
Cover CJS and ESM shapes, plus at least one plugin importing focused
|
||||
`openclaw/plugin-sdk/*` subpaths. Assert `plugins inspect` sees its tool,
|
||||
gateway method, CLI command, or service.
|
||||
|
||||
## Live-Ish Probe Rules
|
||||
|
||||
Before live-ish work, source allowed env in Testbox and generate a redacted
|
||||
availability matrix: present/missing only, never values.
|
||||
|
||||
Only run probes for credentials that exist. Prefer auth/catalog/status probes
|
||||
over sending user-visible messages. If a probe might contact an external user,
|
||||
channel, or workspace, stop and ask the user.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report in this shape:
|
||||
|
||||
```text
|
||||
package/ref:
|
||||
tbx ids / run urls:
|
||||
matrix:
|
||||
bundled lifecycle:
|
||||
package acceptance:
|
||||
doctor/fix:
|
||||
gateway bootstrap:
|
||||
config round-trip:
|
||||
sdk external:
|
||||
live-ish:
|
||||
failures:
|
||||
skips:
|
||||
next highest-value gap:
|
||||
```
|
||||
|
||||
Say clearly when a failure is Testbox sync/env damage rather than product
|
||||
behavior, and prove that with a clean rerun or current-main comparison.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Plugin Pre-Release Testing"
|
||||
short_description: "Plan plugin release validation"
|
||||
default_prompt: "Use $openclaw-pre-release-plugin-testing to plan or run pre-release OpenClaw plugin validation across package, lifecycle, doctor, gateway, SDK, and live-ish proof."
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -9,6 +9,7 @@
|
||||
/.github/dependabot.yml @openclaw/secops
|
||||
/.github/codeql/ @openclaw/secops
|
||||
/.github/workflows/codeql.yml @openclaw/secops
|
||||
/.github/workflows/codeql-android-critical-security.yml @openclaw/secops
|
||||
/.github/workflows/codeql-critical-quality.yml @openclaw/secops
|
||||
/src/security/ @openclaw/secops
|
||||
/src/secrets/ @openclaw/secops
|
||||
|
||||
76
.github/codeql/codeql-plugin-boundary-critical-quality.yml
vendored
Normal file
76
.github/codeql/codeql-plugin-boundary-critical-quality.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: openclaw-codeql-plugin-boundary-critical-quality
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-and-quality
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
problem.severity:
|
||||
- error
|
||||
- exclude:
|
||||
tags:
|
||||
- security
|
||||
|
||||
paths:
|
||||
- src/plugins/activation-planner.ts
|
||||
- src/plugins/api-builder.ts
|
||||
- src/plugins/bundled-compat.ts
|
||||
- src/plugins/bundled-dir.ts
|
||||
- src/plugins/bundled-plugin-metadata.ts
|
||||
- src/plugins/bundled-public-surface-runtime-root.ts
|
||||
- src/plugins/bundled-runtime-deps.ts
|
||||
- src/plugins/bundled-runtime-root.ts
|
||||
- src/plugins/captured-registration.ts
|
||||
- src/plugins/config-activation-shared.ts
|
||||
- src/plugins/config-contracts.ts
|
||||
- src/plugins/config-normalization-shared.ts
|
||||
- src/plugins/config-policy.ts
|
||||
- src/plugins/config-schema.ts
|
||||
- src/plugins/config-state.ts
|
||||
- src/plugins/discovery.ts
|
||||
- src/plugins/effective-plugin-ids.ts
|
||||
- src/plugins/externalized-bundled-plugins.ts
|
||||
- src/plugins/installed-plugin-index*.ts
|
||||
- src/plugins/loader*.ts
|
||||
- src/plugins/manifest*.ts
|
||||
- src/plugins/module-export.ts
|
||||
- src/plugins/package-entrypoints.ts
|
||||
- src/plugins/plugin-registry*.ts
|
||||
- src/plugins/provider-contract-public-artifacts.ts
|
||||
- src/plugins/provider-public-artifacts.ts
|
||||
- src/plugins/public-surface*.ts
|
||||
- src/plugins/registry.ts
|
||||
- src/plugins/registry-types.ts
|
||||
- src/plugins/runtime
|
||||
- src/plugins/runtime-state.ts
|
||||
- src/plugins/runtime.ts
|
||||
- src/plugins/sdk-alias.ts
|
||||
- src/plugins/source-loader.ts
|
||||
- src/plugins/types.ts
|
||||
- src/plugins/validation-diagnostics.ts
|
||||
- src/plugins/web-provider-public-artifacts*.ts
|
||||
- src/plugin-sdk/*entry*.ts
|
||||
- src/plugin-sdk/*facade*.ts
|
||||
- src/plugin-sdk/api-baseline.ts
|
||||
- src/plugin-sdk/config-schema.ts
|
||||
- src/plugin-sdk/config-types.ts
|
||||
- src/plugin-sdk/core.ts
|
||||
- src/plugin-sdk/extension-shared.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*-runtime.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 35
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@v2
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
@@ -218,7 +218,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@v2
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: always()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
4
.github/workflows/ci-check-testbox.yml
vendored
4
.github/workflows/ci-check-testbox.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@v2
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Checkout
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@v2
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: always()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
checkout_sha: ${{ steps.checkout_ref.outputs.sha }}
|
||||
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
||||
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
|
||||
run_node: ${{ steps.manifest.outputs.run_node }}
|
||||
@@ -468,7 +468,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -540,7 +540,7 @@ jobs:
|
||||
path: |
|
||||
dist/
|
||||
dist-runtime/
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_sha }}
|
||||
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
|
||||
|
||||
- name: Pack built runtime artifacts
|
||||
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
|
||||
@@ -669,7 +669,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -764,7 +764,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -867,7 +867,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -935,7 +935,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1055,7 +1055,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1135,7 +1135,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1322,7 +1322,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1454,7 +1454,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1652,7 +1652,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -1715,7 +1715,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
@@ -1758,7 +1758,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
@@ -1863,7 +1863,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
@@ -1904,7 +1904,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
@@ -2005,7 +2005,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_sha }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
22
.github/workflows/clawsweeper-dispatch.yml
vendored
22
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -9,18 +9,29 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
|
||||
steps:
|
||||
- name: Debounce bursty metadata events
|
||||
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
|
||||
run: sleep 20
|
||||
|
||||
- name: Create ClawSweeper dispatch token
|
||||
id: token
|
||||
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
uses: actions/create-github-app-token@v2
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: 3306130
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
|
||||
owner: openclaw
|
||||
repositories: clawsweeper
|
||||
@@ -31,6 +42,8 @@ jobs:
|
||||
TARGET_REPO: ${{ github.repository }}
|
||||
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
|
||||
SOURCE_EVENT: ${{ github.event_name }}
|
||||
SOURCE_ACTION: ${{ github.event.action }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
|
||||
@@ -40,7 +53,10 @@ jobs:
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--argjson item_number "$ITEM_NUMBER" \
|
||||
--arg item_kind "$ITEM_KIND" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind}}')"
|
||||
--arg source_event "$SOURCE_EVENT" \
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
|
||||
if gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"; then
|
||||
|
||||
51
.github/workflows/codeql-android-critical-security.yml
vendored
Normal file
51
.github/workflows/codeql-android-critical-security.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: CodeQL Android Critical Security
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 7 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Critical Security (android)
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
config-file: ./.github/codeql/codeql-android-critical-security.yml
|
||||
|
||||
- name: Build Android for CodeQL
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
21
.github/workflows/codeql-critical-quality.yml
vendored
21
.github/workflows/codeql-critical-quality.yml
vendored
@@ -38,3 +38,24 @@ jobs:
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/javascript-typescript"
|
||||
|
||||
plugin-boundary:
|
||||
name: Critical Quality (plugin-boundary)
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
config-file: ./.github/codeql/codeql-plugin-boundary-critical-quality.yml
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-quality/plugin-boundary"
|
||||
|
||||
89
.github/workflows/codeql-macos-critical-security.yml
vendored
Normal file
89
.github/workflows/codeql-macos-critical-security.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: CodeQL macOS Critical Security
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
name: Critical Security (macOS)
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
config-file: ./.github/codeql/codeql-macos-critical-security.yml
|
||||
|
||||
- name: Build macOS for CodeQL
|
||||
run: swift build --package-path apps/macos --product OpenClaw
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
- name: Remove dependency build results
|
||||
env:
|
||||
SARIF_OUTPUT: sarif-results
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
if [ ! -d "$SARIF_OUTPUT" ]; then
|
||||
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p sarif-results-filtered
|
||||
|
||||
files=("$SARIF_OUTPUT"/*.sarif)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No SARIF files found in $SARIF_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
jq '
|
||||
def in_dependency_build:
|
||||
((.locations // []) | length > 0)
|
||||
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
|
||||
|
||||
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
|
||||
' "$file" > "sarif-results-filtered/$(basename "$file")"
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
106
.github/workflows/codeql.yml
vendored
106
.github/workflows/codeql.yml
vendored
@@ -11,8 +11,6 @@ on:
|
||||
options:
|
||||
- all
|
||||
- security
|
||||
- android-security
|
||||
- macos-security
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -62,107 +60,3 @@ jobs:
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/${{ matrix.language }}"
|
||||
|
||||
android-security:
|
||||
name: Critical Security (android)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'android-security' }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: java-kotlin
|
||||
build-mode: manual
|
||||
config-file: ./.github/codeql/codeql-android-critical-security.yml
|
||||
|
||||
- name: Build Android for CodeQL
|
||||
working-directory: apps/android
|
||||
run: ./gradlew --no-daemon :app:assemblePlayDebug
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-critical-security/android"
|
||||
|
||||
macos-security:
|
||||
name: Critical Security (macOS)
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile == 'macos-security' }}
|
||||
runs-on: blacksmith-6vcpu-macos-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: swift
|
||||
build-mode: manual
|
||||
config-file: ./.github/codeql/codeql-macos-critical-security.yml
|
||||
|
||||
- name: Build macOS for CodeQL
|
||||
run: swift build --package-path apps/macos --product OpenClaw
|
||||
|
||||
- name: Analyze
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
output: sarif-results
|
||||
upload: failure-only
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
- name: Remove dependency build results
|
||||
env:
|
||||
SARIF_OUTPUT: sarif-results
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
if [ ! -d "$SARIF_OUTPUT" ]; then
|
||||
echo "SARIF output directory not found: $SARIF_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p sarif-results-filtered
|
||||
|
||||
files=("$SARIF_OUTPUT"/*.sarif)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No SARIF files found in $SARIF_OUTPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
jq '
|
||||
def in_dependency_build:
|
||||
((.locations // []) | length > 0)
|
||||
and all(.locations[]; (.physicalLocation.artifactLocation.uri? // "") | test("^apps/macos/\\.build/"));
|
||||
|
||||
.runs |= map(.results = ((.results // []) | map(select(in_dependency_build | not))))
|
||||
' "$file" > "sarif-results-filtered/$(basename "$file")"
|
||||
done
|
||||
|
||||
- name: Upload filtered SARIF
|
||||
uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
sarif_file: sarif-results-filtered
|
||||
category: "/codeql-critical-security/macos"
|
||||
|
||||
2
.github/workflows/docs-agent.yml
vendored
2
.github/workflows/docs-agent.yml
vendored
@@ -149,7 +149,7 @@ jobs:
|
||||
|
||||
- name: Run Codex docs agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@v1
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
env:
|
||||
DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
|
||||
DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}
|
||||
|
||||
@@ -330,7 +330,7 @@ jobs:
|
||||
fi
|
||||
|
||||
dispatch_and_wait openclaw-release-checks.yml \
|
||||
-f ref="$TARGET_REF" \
|
||||
-f ref="$TARGET_SHA" \
|
||||
-f expected_sha="$TARGET_SHA" \
|
||||
-f provider="$PROVIDER" \
|
||||
-f mode="$MODE" \
|
||||
|
||||
@@ -321,7 +321,7 @@ jobs:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
@@ -496,7 +496,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
run_install: false
|
||||
|
||||
33
.github/workflows/openclaw-release-checks.yml
vendored
33
.github/workflows/openclaw-release-checks.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
ref: ${{ steps.inputs.outputs.ref }}
|
||||
sha: ${{ steps.ref.outputs.sha }}
|
||||
revision: ${{ steps.ref.outputs.sha }}
|
||||
provider: ${{ steps.inputs.outputs.provider }}
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
release_profile: ${{ steps.inputs.outputs.release_profile }}
|
||||
@@ -106,6 +106,7 @@ jobs:
|
||||
- name: Checkout trusted workflow helper
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref_name }}
|
||||
path: workflow
|
||||
fetch-depth: 1
|
||||
@@ -126,6 +127,7 @@ jobs:
|
||||
if: steps.fast_ref.outputs.fallback == 'true'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ inputs.ref }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
@@ -240,6 +242,7 @@ jobs:
|
||||
- name: Checkout trusted workflow ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.ref_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -259,7 +262,7 @@ jobs:
|
||||
id: package
|
||||
shell: bash
|
||||
env:
|
||||
PACKAGE_REF: ${{ needs.resolve_target.outputs.sha }}
|
||||
PACKAGE_REF: ${{ needs.resolve_target.outputs.revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/resolve-openclaw-package-candidate.mjs \
|
||||
@@ -298,7 +301,7 @@ jobs:
|
||||
contents: read
|
||||
uses: ./.github/workflows/install-smoke.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
run_bun_global_install_smoke: true
|
||||
|
||||
cross_os_release_checks:
|
||||
@@ -333,7 +336,7 @@ jobs:
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
include_repo_e2e: true
|
||||
include_release_path_suites: true
|
||||
include_openwebui: ${{ needs.resolve_target.outputs.release_profile != 'minimum' }}
|
||||
@@ -488,7 +491,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -535,7 +539,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.sha }}
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -556,7 +560,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -569,7 +574,7 @@ jobs:
|
||||
- name: Download parity lane artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.sha }}
|
||||
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
merge-multiple: true
|
||||
|
||||
@@ -590,7 +595,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.sha }}
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -612,7 +617,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -669,7 +675,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.sha }}
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -691,7 +697,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.resolve_target.outputs.revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -754,7 +761,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.sha }}
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
4
.github/workflows/parity-gate.yml
vendored
4
.github/workflows/parity-gate.yml
vendored
@@ -57,9 +57,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
15
.github/workflows/plugin-clawhub-release.yml
vendored
15
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
skipped_published_count: ${{ steps.plan.outputs.skipped_published_count }}
|
||||
@@ -44,6 +44,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -150,7 +151,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -164,6 +166,7 @@ jobs:
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
@@ -187,7 +190,7 @@ jobs:
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
@@ -209,7 +212,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -223,6 +227,7 @@ jobs:
|
||||
- name: Checkout ClawHub CLI source
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ env.CLAWHUB_REPOSITORY }}
|
||||
ref: ${{ env.CLAWHUB_REF }}
|
||||
path: clawhub-source
|
||||
@@ -266,7 +271,7 @@ jobs:
|
||||
env:
|
||||
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
|
||||
SOURCE_REPO: ${{ github.repository }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_sha }}
|
||||
SOURCE_COMMIT: ${{ needs.preview_plugins_clawhub.outputs.ref_revision }}
|
||||
SOURCE_REF: ${{ github.ref }}
|
||||
PACKAGE_TAG: ${{ matrix.plugin.publishTag }}
|
||||
PACKAGE_DIR: ${{ matrix.plugin.packageDir }}
|
||||
|
||||
9
.github/workflows/plugin-npm-release.yml
vendored
9
.github/workflows/plugin-npm-release.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
ref_sha: ${{ steps.ref.outputs.sha }}
|
||||
ref_revision: ${{ steps.ref.outputs.sha }}
|
||||
has_candidates: ${{ steps.plan.outputs.has_candidates }}
|
||||
candidate_count: ${{ steps.plan.outputs.candidate_count }}
|
||||
matrix: ${{ steps.plan.outputs.matrix }}
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -151,7 +152,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -185,7 +187,8 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
36
.github/workflows/qa-live-transports-convex.yml
vendored
36
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -81,12 +81,13 @@ jobs:
|
||||
needs: authorize_actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
selected_sha: ${{ steps.validate.outputs.selected_sha }}
|
||||
selected_revision: ${{ steps.validate.outputs.selected_revision }}
|
||||
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -98,27 +99,27 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
selected_sha="$(git rev-parse HEAD)"
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
|
||||
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
|
||||
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
|
||||
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
|
||||
if [[ "$selected_sha" == "$release_branch_sha" ]]; then
|
||||
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
|
||||
trusted_reason="release-branch-head"
|
||||
fi
|
||||
else
|
||||
pr_head_count="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \
|
||||
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length'
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${selected_revision}/pulls" \
|
||||
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_revision}"'")] | length'
|
||||
)"
|
||||
if [[ "$pr_head_count" != "0" ]]; then
|
||||
trusted_reason="open-pr-head"
|
||||
@@ -126,16 +127,16 @@ jobs:
|
||||
fi
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for this secret-bearing QA run." >&2
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA run." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, match a release branch head, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
|
||||
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Validated ref: \`${INPUT_REF}\`"
|
||||
echo "Resolved SHA: \`$selected_sha\`"
|
||||
echo "Resolved SHA: \`$selected_revision\`"
|
||||
echo "Trust reason: \`$trusted_reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -157,7 +158,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -220,7 +222,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -303,7 +306,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -375,7 +379,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
@@ -467,7 +472,8 @@ jobs:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node environment
|
||||
|
||||
2
.github/workflows/test-performance-agent.yml
vendored
2
.github/workflows/test-performance-agent.yml
vendored
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Run Codex test performance agent
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
uses: openai/codex-action@v1
|
||||
uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/test-performance-agent.md
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Sandbox/Docker: add opt-in `sandbox.docker.gpus` passthrough for Docker sandbox containers so local GPU workloads can run inside sandboxed agents when the host Docker runtime supports `--gpus`. Fixes #57976; carries forward #58124. Thanks @cyan-ember.
|
||||
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
|
||||
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
|
||||
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
|
||||
@@ -13,12 +14,48 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock, including dist-runtime canonical roots, so Docker Desktop/WSL cold starts no longer hold `.openclaw-runtime-mirror.lock` while scanning slow persisted volumes. Fixes #73339. Thanks @1yihui.
|
||||
- Gateway/sessions: align `chat.history` and `sessions.list` thinking defaults with owning-agent and catalog-aware resolution so Control UI session defaults match backend runtime state. (#63418) Thanks @jpreagan.
|
||||
- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc.
|
||||
- Agents/media: prefer OpenAI image and video providers when the default model uses the OpenAI Codex auth alias, so auto media generation no longer falls through to Fal before GPT Image or Sora. Thanks @vincentkoc.
|
||||
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
|
||||
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
|
||||
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
|
||||
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
|
||||
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
|
||||
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
|
||||
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
|
||||
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.
|
||||
- Auto-reply: bound the post-run pending tool-result delivery drain with a progress-aware idle timeout, so a never-settling tool-result task no longer leaves the session active forever while slow healthy deliveries can keep draining. Fixes #53889; supersedes #64733 and #73434. Thanks @zijunl and @wujiaming88.
|
||||
- Gateway/startup: start chat channels without waiting for primary model prewarm, keeping model warmup bounded in the background so Slack and other channels come online promptly when provider discovery is slow. Supersedes #73420. Thanks @dorukardahan.
|
||||
- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh.
|
||||
- Auto-reply/commands: stop bare `/reset` and `/new` after reset hooks acknowledge the command, so non-ACP channels no longer fall through into empty provider calls while `/reset <message>` and `/new <message>` still seed the next model turn. Fixes #73367 and #73412. Thanks @hoyanhan, @wenxu007, and @amdhelper.
|
||||
- Providers/DeepSeek: backfill DeepSeek V4 `reasoning_content` on plain assistant replay messages as well as tool-call turns, so thinking sessions with prior tool use no longer fail follow-up requests with missing reasoning content. Fixes #73417; refs #71372. Thanks @34262315716 and @Bartok9.
|
||||
- Agents/gateway tool: strip full config payloads from `config.patch` and `config.apply` tool responses while preserving direct RPC responses, so config-heavy sessions no longer replay large redacted configs into transcript history. Fixes #47610; supersedes #73439. Thanks @HanenVit and @juan-flores077.
|
||||
- Auto-reply: preserve voice-note media from silent turns while continuing to suppress text and non-voice media, so `NO_REPLY` TTS replies still deliver the requested audio bubble. (#73406) Thanks @zqchris.
|
||||
- Channels/Mattermost: stop enqueueing regular inbound posts as system events, so Mattermost user messages reach the model only as user-role inbound-envelope content instead of also appearing as `System: Mattermost message...` directives. Fixes #71795. Thanks @juan-flores077.
|
||||
- Agents/media: qualify bare `agents.defaults.imageModel` and `pdfModel` refs from unique configured image-capable providers, so Ollama vision models such as `moondream` and `qwen2.5vl:7b` do not fall through to the default provider. Fixes #38816; supersedes #73396. Thanks @alainasclaw and @vincentkoc.
|
||||
- Agents/Anthropic: send implicit Anthropic beta headers only to direct public Anthropic endpoints, including OAuth, so custom Anthropic-compatible providers no longer mis-handle unsupported beta flags unless explicitly configured. Refs #73346. Thanks @byBrodowski.
|
||||
- Skills: require explicit `skills.entries.coding-agent.enabled` before exposing the bundled coding-agent skill, so installs with Codex on PATH but no OpenAI auth do not silently offer Codex delegation. Fixes #73358. Thanks @LaFleurAdvertising and @Sanjays2402.
|
||||
- Plugins/startup: treat manifestless Claude bundles as valid installed-plugin registry entries instead of stale missing manifests, so workspace bundles no longer force repeated derived registry rebuilds or noisy `plugins.entries.workspace` warnings during Gateway startup. Fixes #73433. Thanks @AnneVoss.
|
||||
- Agents/subagents: preserve `sessions_yield` as a paused subagent state and ignore its wait text while freezing completion output, so parent sessions wait for the final post-compaction answer instead of receiving intermediate progress or `(no output)`. Fixes #73413. Thanks @Ask-sola.
|
||||
- Plugins/startup: precompute bundled runtime mirror fingerprints before taking the mirror lock and keep Docker bundled plugin runtime deps/mirrors in a Docker-managed volume instead of the Windows/WSL config bind mount, so cold starts avoid slow host-volume mirror writes. Fixes #73339. Thanks @1yihui.
|
||||
- Plugins/runtime deps: refresh bundled runtime mirrors without deleting active import trees, so config-triggered restarts do not see transient missing plugin files during registration. Thanks @shakkernerd.
|
||||
- Channels/LINE: persist inbound image, video, audio, and file downloads in `~/.openclaw/media/inbound/` instead of temporary files so agents can still read LINE media after `/tmp` cleanup. Fixes #73370. Thanks @hijirii and @wenxu007.
|
||||
- CLI/plugins: keep bundled plugin installs out of `plugins.load.paths` while preserving install records, so install/inspect/doctor loops no longer warn about the current bundled plugin directory. Thanks @vincentkoc.
|
||||
- CLI/plugins: scope `plugins inspect <id>` runtime loading to the matched plugin so single-plugin inspection does not load every plugin before checking the target. Thanks @shakkernerd.
|
||||
- CLI/plugins: remove managed copied-path plugin directories during uninstall and plan uninstall from metadata instead of runtime-loading plugins, so plugin lifecycle commands avoid unnecessary bundled runtime-deps work. Thanks @shakkernerd.
|
||||
- Cron tool: infer the creating session's agentId for `cron.add` jobs when `agentId` is omitted or passed as undefined, keeping scheduled agentTurn jobs routed to the session agent; #40571 identified the guard bug and supplied the focused regression coverage. Thanks @ChanningYul.
|
||||
- Cron/Telegram: add `--thread-id` to `openclaw cron add` and `openclaw cron edit`, preserving Telegram forum topic delivery targets across scheduled announcements. Carries forward #51581, #60373, and #60890. Thanks @ChunHao-dev.
|
||||
- Cron/Telegram: preserve session-derived Telegram topic thread IDs when isolated cron delivery explicitly targets the parent chat, keeping bare chat targets in the active forum topic without leaking stale topics to other chats. Carries forward #64708. Thanks @addelh.
|
||||
- Memory/compaction: keep pre-compaction memory-flush prompts runtime-only so session transcripts and `chat.history` no longer expose them as normal user turns. Fixes #54408 and #58956; refs #43567. Thanks @markgong and @guoyuhang9.
|
||||
- Control UI/WebChat: keep large attachment payloads out of Lit state and optimistic chat messages, using object URL previews plus send-time payload serialization so PDF/image uploads no longer trigger `RangeError: Maximum call stack size exceeded`. Fixes #73360; refs #54378 and #63432. Thanks @hejunhui-73, @Ansub, and @christianhernandez3-afk.
|
||||
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
|
||||
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
|
||||
- Sandbox/Docker: stop satisfying a missing default sandbox image by tagging plain Debian as `openclaw-sandbox:bookworm-slim`, preserving the Python tooling required by sandbox write/edit helpers and directing users to build the default image. Fixes #51185; refs #45108, #51099, #51609, and #57713. Thanks @dpalis, @Tin55FoilDev, @jbcohen2-coder, @macminihal-cyber, and @PraxoOnline.
|
||||
- Control UI/WebChat: confirm toolbar New Session button resets before dispatching `/new` while leaving typed `/new` and `/reset` commands immediate. Fixes #45800; refs #27065, #56611, #54499, and #27110. Thanks @aethnova, @kosta228-huli, @adambezemek, and @xss925175263 (xianshishan).
|
||||
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
|
||||
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
|
||||
- Gateway/startup: warn when legacy `CLAWDBOT_*` or `MOLTBOT_*` environment variables are still present, pointing users to `OPENCLAW_*` names instead of failing silently. Fixes #53482; carries forward #53667. Thanks @lndyzwdxhs.
|
||||
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.
|
||||
- Doctor/state: require an interactive confirmation before archiving orphan transcript files, so `openclaw doctor --fix` no longer silently renames recoverable session history after upgrades regenerate `sessions.json`. Fixes #73106. Thanks @scottgl9.
|
||||
- Cron/Telegram: preserve explicit `:topic:` delivery targets over stale session-derived thread IDs when isolated cron announces to Telegram forum topics. Carries forward #59069; refs #49704 and #43808. Thanks @roytong9.
|
||||
@@ -26,11 +63,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Build/runtime: preserve staged bundled-plugin runtime dependency caches across source-checkout tsdown rebuilds, so local CLI and gateway-watch rebuilds no longer recreate large plugin dependency trees before starting. Refs #73205. Thanks @SymbolStar.
|
||||
- CLI/channels: list configured chat channel accounts from read-only setup metadata even when the standalone CLI has not loaded the runtime channel registry, so `openclaw channels list` shows Telegram accounts before auth providers. Fixes #73319 and #73322. Thanks @mlaihk.
|
||||
- CLI/model probes: keep `infer model run --gateway` raw by skipping prior session transcript, bootstrap context, context-engine assembly, tools, and bundled MCP servers, so local backends can be tested without full agent-context overhead. Fixes #73308. Thanks @ScientificProgrammer.
|
||||
- CLI/image describe: pass `--prompt` and `--timeout-ms` through `infer image describe` and `describe-many`, so custom vision instructions and slow local model budgets reach media-understanding providers such as Ollama, OpenAI, Google, and OpenRouter. Addresses #63700. Thanks @cedricjanssens.
|
||||
- Providers/Ollama: reject long non-linguistic Kimi/GLM symbol runs as provider failures instead of storing them as successful visible assistant replies, so fallback or error handling can recover from garbled cloud output. Fixes #64262; refs #67019. Thanks @Kloz813 and @xiaomenger123.
|
||||
- CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge.
|
||||
- Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah.
|
||||
- Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj.
|
||||
- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills.
|
||||
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
|
||||
- Ollama/thinking: validate `/think` commands against live Ollama catalog reasoning metadata and preserve explicit native `params.think`/`params.thinking`, so models whose `/api/show` capabilities include `thinking` expose `low`, `medium`, `high`, and `max` instead of being stuck on `off`. Fixes #73366. Thanks @cymise.
|
||||
- Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf.
|
||||
- Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch.
|
||||
- ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia.
|
||||
@@ -47,6 +86,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/runtime deps: reuse enclosing versioned cache roots when bundled plugins resolve from nested staged paths, so plugin-runtime-deps no longer mints `openclaw-unknown-*` directories or loops on `ENOTEMPTY`. Fixes #72956. (#73205) Thanks @SymbolStar.
|
||||
- Agents/failover: classify CJK provider transport, quota, billing, auth, and overload error text so Chinese-language provider failures trigger fallback and user-facing transport copy instead of surfacing as unclassified raw errors. (#56242) Thanks @tomcatzh.
|
||||
- Agents/failover: seed non-claude-cli fallback prompts with Claude Code session context when a claude-cli attempt fails, so fallback models do not restart cold after billing or quota failover. (#72069) Thanks @stainlu.
|
||||
- Agents/CLI runner: transfer bundle-MCP tempDir cleanup from the per-turn runner finally to the Claude live-session lifecycle, so persistent Claude CLI sessions keep their `--mcp-config` directory until the live subprocess closes. Fixes #73244. Thanks @edwin-rivera-dev.
|
||||
- Gateway/nodes: allow Windows companion nodes to use safe declared commands such as canvas, camera list, location, device info, and screen snapshot by default while keeping dangerous media commands opt-in. (#71884) Thanks @shanselman.
|
||||
- Agents/cron: clarify agent-tool and CLI cron timezone guidance so supplied `tz` values use local wall-clock cron fields and omitted cron `tz` falls back to the Gateway host local timezone. Fixes #53669; carries forward #46177. (#73372) Thanks @chen-zhang-cs-code and @maranello-o.
|
||||
- Providers/Qwen: allow explicitly configured `qwen/qwen3.6-plus` to resolve on Qwen Coding Plan endpoints while keeping the built-in catalog from advertising it there. Fixes #63654; carries forward #63987. Thanks @jepson-liu.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
@@ -402,6 +445,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666.
|
||||
- Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon.
|
||||
- macOS Gateway: detect installed-but-unloaded LaunchAgent split-brain states during status, doctor, and restart, and re-bootstrap launchd supervision before falling back to unmanaged listener restarts. Fixes #67335, #53475, and #71060; refs #58890, #60885, and #70801. Thanks @ze1tgeist88, @dafacto, and @vishutdhar.
|
||||
- WhatsApp: clear cached Web auth and active listener state after terminal 440/401 conflict/logout closes so linked/OK status no longer masks a dead inbound listener after relink or restart. Fixes #45474; refs #49305, #63855, #66920, and #70856. Thanks @juvenalmakoszay and @dsantoreis.
|
||||
- Gateway/restart: keep local restart-health probes on configured local daemon auth without falling back to remote gateway credentials. (#57374, #59439) Thanks @zssggle-rgb and @roytong9.
|
||||
- Plugins/install: treat mirrored core logger dependencies as staged bundled runtime deps so packaged Gateway starts do not crash when the external plugin-runtime-deps root is missing `tslog`. Fixes #72228; supersedes #72493. Thanks @deepujain.
|
||||
- Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc.
|
||||
@@ -413,6 +457,7 @@ Docs: https://docs.openclaw.ai
|
||||
- TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant.
|
||||
- Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the target user scope when `systemctl --user` reports no-medium bus failures, without letting stale `SUDO_USER` override `sudo -u` installs. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, @mssteuer, and @boyuaner.
|
||||
- CLI/nodes: make unfiltered `openclaw nodes list` prefer the effective paired-node view used by `nodes status` while preserving pending rows, pairing-scope fallback, terminal-safe table rendering, and paired JSON metadata. Fixes #46871; carries forward #65772 through the ProjectClownfish #72619 repair. Thanks @skainguyen1412.
|
||||
- Memory Wiki/CLI: route active bridge-mode status, doctor, and bridge imports through Gateway RPC so CLI checks use the runtime memory plugin context while disabled bridge imports stay local/offline. Carries forward #67208 and #71479; related #70185. Thanks @moorsecopers99, @vincentkoc, and @prasad-yashdeep.
|
||||
- CLI/startup: read generated startup metadata from the bundled `dist` layout before falling back to live help rendering, so root/browser help and channel-option bootstrap stay on the fast path. Thanks @vincentkoc.
|
||||
- Feishu/Lark: stop treating broadcast-only `@all`/`@_all` messages as bot mentions while preserving direct bot mentions, including messages that also include `@all`. Fixes #37706. Thanks @JosepLee.
|
||||
- CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras.
|
||||
@@ -923,6 +968,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.
|
||||
- DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.
|
||||
- Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.
|
||||
- Providers/OpenRouter: add native video generation through `video_generate`, so OpenRouter video models work with `OPENROUTER_API_KEY`. (#72700) Thanks @notamicrodose.
|
||||
- Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.
|
||||
- Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.
|
||||
|
||||
|
||||
@@ -258,10 +258,12 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||
&& chmod 755 /app/openclaw.mjs
|
||||
|
||||
# Pre-create the default state dir so first-run Docker named volumes mounted
|
||||
# here inherit node ownership instead of starting as root-owned state.
|
||||
# Pre-create the default state and runtime-deps dirs so first-run Docker named
|
||||
# volumes mounted here inherit node ownership instead of root-owned state.
|
||||
RUN install -d -m 0700 -o node -g node /home/node/.openclaw && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700'
|
||||
install -d -m 0700 -o node -g node /var/lib/openclaw/plugin-runtime-deps && \
|
||||
stat -c '%U:%G %a' /home/node/.openclaw | grep -qx 'node:node 700' && \
|
||||
stat -c '%U:%G %a' /var/lib/openclaw/plugin-runtime-deps | grep -qx 'node:node 700'
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@ indent_style = space
|
||||
indent_size = 2
|
||||
max_line_length = off
|
||||
ktlint_standard_filename = disabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-naming = disabled
|
||||
ktlint_standard_if-else-bracing = disabled
|
||||
ktlint_standard_max-line-length = disabled
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_property-naming = disabled
|
||||
|
||||
@@ -33,10 +33,10 @@ if (wantsAndroidReleaseBuild && !hasAndroidReleaseSigning) {
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -78,13 +78,9 @@ android {
|
||||
productFlavors {
|
||||
create("play") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "false")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "false")
|
||||
}
|
||||
create("thirdParty") {
|
||||
dimension = "store"
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_SMS", "true")
|
||||
buildConfigField("boolean", "OPENCLAW_ENABLE_CALL_LOG", "true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,15 +129,7 @@ android {
|
||||
}
|
||||
|
||||
lint {
|
||||
disable +=
|
||||
setOf(
|
||||
"AndroidGradlePluginVersion",
|
||||
"GradleDependency",
|
||||
"HighAppVersionCode",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
"OldTargetApi",
|
||||
)
|
||||
lintConfig = file("lint.xml")
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
@@ -184,57 +172,57 @@ ktlint {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.04.01")
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
implementation("androidx.core:core-ktx:1.18.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation("androidx.activity:activity-compose:1.13.0")
|
||||
implementation("androidx.webkit:webkit:1.15.0")
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Material Components (XML theme + resources)
|
||||
implementation("com.google.android.material:material:1.13.0")
|
||||
implementation(libs.material)
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.84")
|
||||
implementation("org.commonmark:commonmark:0.28.0")
|
||||
implementation("org.commonmark:commonmark-ext-autolink:0.28.0")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-strikethrough:0.28.0")
|
||||
implementation("org.commonmark:commonmark-ext-gfm-tables:0.28.0")
|
||||
implementation("org.commonmark:commonmark-ext-task-list-items:0.28.0")
|
||||
implementation(libs.androidx.security.crypto)
|
||||
implementation(libs.androidx.exifinterface)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.bcprov)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.commonmark.ext.autolink)
|
||||
implementation(libs.commonmark.ext.gfm.strikethrough)
|
||||
implementation(libs.commonmark.ext.gfm.tables)
|
||||
implementation(libs.commonmark.ext.task.list.items)
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.6.0")
|
||||
implementation("androidx.camera:camera-camera2:1.6.0")
|
||||
implementation("androidx.camera:camera-lifecycle:1.6.0")
|
||||
implementation("androidx.camera:camera-video:1.6.0")
|
||||
implementation("com.google.android.gms:play-services-code-scanner:16.1.0")
|
||||
implementation(libs.androidx.camera.core)
|
||||
implementation(libs.androidx.camera.camera2)
|
||||
implementation(libs.androidx.camera.lifecycle)
|
||||
implementation(libs.androidx.camera.video)
|
||||
implementation(libs.play.services.code.scanner)
|
||||
|
||||
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
|
||||
implementation("dnsjava:dnsjava:3.6.4")
|
||||
implementation(libs.dnsjava)
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
testImplementation("io.kotest:kotest-runner-junit5-jvm:6.1.11")
|
||||
testImplementation("io.kotest:kotest-assertions-core-jvm:6.1.11")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:5.3.2")
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:6.0.3")
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.kotest.runner.junit5)
|
||||
testImplementation(libs.kotest.assertions.core)
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(libs.robolectric)
|
||||
testRuntimeOnly(libs.junit.vintage.engine)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
|
||||
13
apps/android/app/lint.xml
Normal file
13
apps/android/app/lint.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<lint>
|
||||
<issue id="AndroidGradlePluginVersion" severity="ignore" />
|
||||
<issue id="GradleDependency" severity="ignore" />
|
||||
<issue id="IconLauncherShape" severity="ignore" />
|
||||
<issue id="NewerVersionAvailable" severity="ignore" />
|
||||
|
||||
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
|
||||
<issue id="HighAppVersionCode" severity="ignore" />
|
||||
|
||||
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
|
||||
<issue id="OldTargetApi" severity="ignore" />
|
||||
</lint>
|
||||
@@ -13,7 +13,33 @@ import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeResult
|
||||
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.app.node.*
|
||||
import ai.openclaw.app.node.A2UIHandler
|
||||
import ai.openclaw.app.node.CalendarHandler
|
||||
import ai.openclaw.app.node.CallLogHandler
|
||||
import ai.openclaw.app.node.CameraCaptureManager
|
||||
import ai.openclaw.app.node.CameraHandler
|
||||
import ai.openclaw.app.node.CanvasController
|
||||
import ai.openclaw.app.node.ConnectionManager
|
||||
import ai.openclaw.app.node.ContactsHandler
|
||||
import ai.openclaw.app.node.DEFAULT_SEAM_COLOR_ARGB
|
||||
import ai.openclaw.app.node.DebugHandler
|
||||
import ai.openclaw.app.node.DeviceHandler
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.node.InvokeDispatcher
|
||||
import ai.openclaw.app.node.LocationCaptureManager
|
||||
import ai.openclaw.app.node.LocationHandler
|
||||
import ai.openclaw.app.node.MotionHandler
|
||||
import ai.openclaw.app.node.NodePresenceAliveBeacon
|
||||
import ai.openclaw.app.node.NotificationsHandler
|
||||
import ai.openclaw.app.node.PhotosHandler
|
||||
import ai.openclaw.app.node.Quad
|
||||
import ai.openclaw.app.node.SmsHandler
|
||||
import ai.openclaw.app.node.SmsManager
|
||||
import ai.openclaw.app.node.SystemHandler
|
||||
import ai.openclaw.app.node.asObjectOrNull
|
||||
import ai.openclaw.app.node.asStringOrNull
|
||||
import ai.openclaw.app.node.invokeErrorFromThrowable
|
||||
import ai.openclaw.app.node.parseHexColorArgb
|
||||
import ai.openclaw.app.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.app.voice.MicCaptureManager
|
||||
import ai.openclaw.app.voice.TalkModeManager
|
||||
@@ -103,8 +129,8 @@ class NodeRuntime(
|
||||
private val deviceHandler: DeviceHandler =
|
||||
DeviceHandler(
|
||||
appContext = appContext,
|
||||
smsEnabled = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
callLogEnabled = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
smsEnabled = SensitiveFeatureConfig.smsEnabled,
|
||||
callLogEnabled = SensitiveFeatureConfig.callLogEnabled,
|
||||
)
|
||||
|
||||
private val notificationsHandler: NotificationsHandler =
|
||||
@@ -163,10 +189,10 @@ class NodeRuntime(
|
||||
voiceWakeMode = { VoiceWakeMode.Off },
|
||||
motionActivityAvailable = { motionHandler.isActivityAvailable() },
|
||||
motionPedometerAvailable = { motionHandler.isPedometerAvailable() },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
smsSearchPossible = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
|
||||
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
|
||||
smsSearchPossible = { SensitiveFeatureConfig.smsEnabled && sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
@@ -190,11 +216,11 @@ class NodeRuntime(
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
sendSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canSendSms() },
|
||||
readSmsAvailable = { BuildConfig.OPENCLAW_ENABLE_SMS && sms.canReadSms() },
|
||||
smsFeatureEnabled = { BuildConfig.OPENCLAW_ENABLE_SMS },
|
||||
sendSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canSendSms() },
|
||||
readSmsAvailable = { SensitiveFeatureConfig.smsEnabled && sms.canReadSms() },
|
||||
smsFeatureEnabled = { SensitiveFeatureConfig.smsEnabled },
|
||||
smsTelephonyAvailable = { sms.hasTelephonyFeature() },
|
||||
callLogAvailable = { BuildConfig.OPENCLAW_ENABLE_CALL_LOG },
|
||||
callLogAvailable = { SensitiveFeatureConfig.callLogEnabled },
|
||||
debugBuild = { BuildConfig.DEBUG },
|
||||
refreshNodeCanvasCapability = { nodeSession.refreshNodeCanvasCapability() },
|
||||
onCanvasA2uiPush = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.Manifest
|
||||
import android.app.ActivityManager
|
||||
@@ -25,8 +26,8 @@ import java.util.Locale
|
||||
|
||||
class DeviceHandler(
|
||||
private val appContext: Context,
|
||||
private val smsEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_SMS,
|
||||
private val callLogEnabled: Boolean = BuildConfig.OPENCLAW_ENABLE_CALL_LOG,
|
||||
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
|
||||
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
|
||||
) {
|
||||
companion object {
|
||||
internal fun hasAnySmsCapability(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import android.Manifest
|
||||
@@ -248,10 +248,10 @@ fun OnboardingFlow(
|
||||
|
||||
val smsAvailable =
|
||||
remember(context) {
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
SensitiveFeatureConfig.smsEnabled &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val callLogAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
|
||||
val motionAvailable =
|
||||
remember(context) {
|
||||
hasMotionCapabilities(context)
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NotificationPackageFilterMode
|
||||
import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.normalizeLocalHourMinute
|
||||
import android.Manifest
|
||||
@@ -204,10 +205,10 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
|
||||
val smsPermissionAvailable =
|
||||
remember {
|
||||
BuildConfig.OPENCLAW_ENABLE_SMS &&
|
||||
SensitiveFeatureConfig.smsEnabled &&
|
||||
context.packageManager?.hasSystemFeature(PackageManager.FEATURE_TELEPHONY) == true
|
||||
}
|
||||
val callLogPermissionAvailable = remember { BuildConfig.OPENCLAW_ENABLE_CALL_LOG }
|
||||
val callLogPermissionAvailable = remember { SensitiveFeatureConfig.callLogEnabled }
|
||||
val photosPermission =
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.READ_MEDIA_IMAGES
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
object SensitiveFeatureConfig {
|
||||
const val smsEnabled: Boolean = false
|
||||
const val callLogEnabled: Boolean = false
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import android.content.Context
|
||||
|
||||
internal data class CallLogRecord(
|
||||
val number: String?,
|
||||
val cachedName: String?,
|
||||
val date: Long,
|
||||
val duration: Long,
|
||||
val type: Int,
|
||||
)
|
||||
|
||||
internal data class CallLogSearchRequest(
|
||||
val limit: Int,
|
||||
val offset: Int,
|
||||
val cachedName: String?,
|
||||
val number: String?,
|
||||
val date: Long?,
|
||||
val dateStart: Long?,
|
||||
val dateEnd: Long?,
|
||||
val duration: Long?,
|
||||
val type: Int?,
|
||||
)
|
||||
|
||||
internal interface CallLogDataSource {
|
||||
fun hasReadPermission(context: Context): Boolean
|
||||
|
||||
fun search(
|
||||
context: Context,
|
||||
request: CallLogSearchRequest,
|
||||
): List<CallLogRecord>
|
||||
}
|
||||
|
||||
class CallLogHandler private constructor() {
|
||||
constructor(
|
||||
@Suppress("unused") appContext: Context,
|
||||
) : this()
|
||||
|
||||
fun handleCallLogSearch(
|
||||
@Suppress("unused") paramsJson: String?,
|
||||
): GatewaySession.InvokeResult =
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "CALL_LOG_UNAVAILABLE",
|
||||
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
|
||||
)
|
||||
|
||||
companion object {
|
||||
internal fun forTesting(
|
||||
@Suppress("unused") appContext: Context,
|
||||
@Suppress("unused") dataSource: CallLogDataSource,
|
||||
): CallLogHandler = CallLogHandler()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.PermissionRequester
|
||||
import android.content.Context
|
||||
|
||||
class SmsManager(
|
||||
@Suppress("unused") private val context: Context,
|
||||
) {
|
||||
data class SendResult(
|
||||
val ok: Boolean,
|
||||
val to: String,
|
||||
val message: String?,
|
||||
val error: String? = null,
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
data class SmsMessage(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val address: String?,
|
||||
val person: String?,
|
||||
val date: Long,
|
||||
val dateSent: Long,
|
||||
val read: Boolean,
|
||||
val type: Int,
|
||||
val body: String?,
|
||||
val status: Int,
|
||||
val transportType: String? = null,
|
||||
)
|
||||
|
||||
data class SearchResult(
|
||||
val ok: Boolean,
|
||||
val messages: List<SmsMessage>,
|
||||
val error: String? = null,
|
||||
val payloadJson: String,
|
||||
)
|
||||
|
||||
fun attachPermissionRequester(
|
||||
@Suppress("unused") requester: PermissionRequester,
|
||||
) {
|
||||
}
|
||||
|
||||
fun canSendSms(): Boolean = false
|
||||
|
||||
fun canSearchSms(): Boolean = false
|
||||
|
||||
fun canReadSms(): Boolean = false
|
||||
|
||||
fun hasTelephonyFeature(): Boolean = false
|
||||
|
||||
suspend fun send(paramsJson: String?): SendResult =
|
||||
SendResult(
|
||||
ok = false,
|
||||
to = "",
|
||||
message = null,
|
||||
error = "SMS_PERMISSION_REQUIRED: grant SMS permission",
|
||||
payloadJson = unavailablePayload(paramsJson),
|
||||
)
|
||||
|
||||
suspend fun search(paramsJson: String?): SearchResult =
|
||||
SearchResult(
|
||||
ok = false,
|
||||
messages = emptyList(),
|
||||
error = "SMS_PERMISSION_REQUIRED: grant READ_SMS permission",
|
||||
payloadJson = unavailablePayload(paramsJson),
|
||||
)
|
||||
|
||||
private fun unavailablePayload(paramsJson: String?): String = """{"ok":false,"error":"SMS_UNAVAILABLE","paramsProvided":${!paramsJson.isNullOrBlank()}}"""
|
||||
}
|
||||
6
apps/android/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt
vendored
Normal file
6
apps/android/app/src/thirdParty/java/ai/openclaw/app/SensitiveFeatureConfig.kt
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
object SensitiveFeatureConfig {
|
||||
const val smsEnabled: Boolean = true
|
||||
const val callLogEnabled: Boolean = true
|
||||
}
|
||||
39
apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt
vendored
Normal file
39
apps/android/app/src/thirdParty/java/ai/openclaw/app/node/SmsHandler.kt
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
package ai.openclaw.app.node
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
|
||||
class SmsHandler(
|
||||
private val sms: SmsManager,
|
||||
) {
|
||||
suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
return errorResult(res.error, defaultCode = "SMS_SEND_FAILED")
|
||||
}
|
||||
|
||||
suspend fun handleSmsSearch(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.search(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
return errorResult(res.error, defaultCode = "SMS_SEARCH_FAILED")
|
||||
}
|
||||
|
||||
private fun errorResult(
|
||||
error: String?,
|
||||
defaultCode: String,
|
||||
): GatewaySession.InvokeResult {
|
||||
val rawMessage = error ?: defaultCode
|
||||
val idx = rawMessage.indexOf(':')
|
||||
val code = if (idx > 0) rawMessage.substring(0, idx).trim() else defaultCode
|
||||
val message =
|
||||
if (idx > 0 && code == rawMessage.substring(0, idx).trim()) {
|
||||
rawMessage.substring(idx + 1).trim().ifEmpty { rawMessage }
|
||||
} else {
|
||||
rawMessage
|
||||
}
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
plugins {
|
||||
id("com.android.test")
|
||||
id("org.jlleitschuh.gradle.ktlint")
|
||||
alias(libs.plugins.android.test)
|
||||
alias(libs.plugins.ktlint)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -39,7 +39,7 @@ ktlint {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.benchmark:benchmark-macro-junit4:1.4.1")
|
||||
implementation("androidx.test.ext:junit:1.3.0")
|
||||
implementation("androidx.test.uiautomator:uiautomator:2.4.0-beta02")
|
||||
implementation(libs.androidx.benchmark.macro.junit4)
|
||||
implementation(libs.androidx.test.ext.junit)
|
||||
implementation(libs.androidx.uiautomator)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
id("com.android.test") version "9.2.0" apply false
|
||||
id("org.jlleitschuh.gradle.ktlint") version "14.2.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.21" apply false
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.test) apply false
|
||||
alias(libs.plugins.ktlint) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
|
||||
74
apps/android/gradle/libs.versions.toml
Normal file
74
apps/android/gradle/libs.versions.toml
Normal file
@@ -0,0 +1,74 @@
|
||||
[versions]
|
||||
agp = "9.2.0"
|
||||
androidx-activity = "1.13.0"
|
||||
androidx-benchmark = "1.4.1"
|
||||
androidx-camera = "1.6.0"
|
||||
androidx-compose-bom = "2026.04.01"
|
||||
androidx-core = "1.18.0"
|
||||
androidx-exifinterface = "1.4.2"
|
||||
androidx-lifecycle = "2.10.0"
|
||||
androidx-security = "1.1.0"
|
||||
androidx-test-ext = "1.3.0"
|
||||
androidx-uiautomator = "2.4.0-beta02"
|
||||
androidx-webkit = "1.15.0"
|
||||
bcprov = "1.84"
|
||||
commonmark = "0.28.0"
|
||||
coroutines = "1.10.2"
|
||||
dnsjava = "3.6.4"
|
||||
junit = "4.13.2"
|
||||
junit-vintage = "6.0.3"
|
||||
kotest = "6.1.11"
|
||||
ktlint-gradle = "14.2.0"
|
||||
kotlin = "2.3.21"
|
||||
material = "1.13.0"
|
||||
okhttp = "5.3.2"
|
||||
play-services-code-scanner = "16.1.0"
|
||||
robolectric = "4.16.1"
|
||||
serialization-json = "1.11.0"
|
||||
|
||||
[libraries]
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
|
||||
androidx-benchmark-macro-junit4 = { module = "androidx.benchmark:benchmark-macro-junit4", version.ref = "androidx-benchmark" }
|
||||
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" }
|
||||
androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" }
|
||||
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" }
|
||||
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" }
|
||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
|
||||
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "androidx-exifinterface" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
|
||||
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security" }
|
||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext" }
|
||||
androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
|
||||
androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" }
|
||||
bcprov = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprov" }
|
||||
commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" }
|
||||
commonmark-ext-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" }
|
||||
commonmark-ext-gfm-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" }
|
||||
commonmark-ext-gfm-tables = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" }
|
||||
commonmark-ext-task-list-items = { module = "org.commonmark:commonmark-ext-task-list-items", version.ref = "commonmark" }
|
||||
dnsjava = { module = "dnsjava:dnsjava", version.ref = "dnsjava" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
junit-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-vintage" }
|
||||
kotest-assertions-core = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest" }
|
||||
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5-jvm", version.ref = "kotest" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
|
||||
material = { module = "com.google.android.material:material", version.ref = "material" }
|
||||
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" }
|
||||
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
|
||||
@@ -22,10 +22,12 @@ services:
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
|
||||
TZ: ${OPENCLAW_TZ:-UTC}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
|
||||
## Uncomment the lines below to enable sandbox isolation
|
||||
## (agents.defaults.sandbox). Requires Docker CLI in the image
|
||||
## (build with --build-arg OPENCLAW_INSTALL_DOCKER_CLI=1) or use
|
||||
@@ -84,13 +86,18 @@ services:
|
||||
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY:-}
|
||||
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY:-}
|
||||
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE:-}
|
||||
OPENCLAW_PLUGIN_STAGE_DIR: /var/lib/openclaw/plugin-runtime-deps
|
||||
TZ: ${OPENCLAW_TZ:-UTC}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
|
||||
- openclaw-plugin-runtime-deps:/var/lib/openclaw/plugin-runtime-deps
|
||||
stdin_open: true
|
||||
tty: true
|
||||
init: true
|
||||
entrypoint: ["node", "dist/index.js"]
|
||||
depends_on:
|
||||
- openclaw-gateway
|
||||
|
||||
volumes:
|
||||
openclaw-plugin-runtime-deps:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
b1d76b9451b21434325e64d5bb531b9b995ba3bbf8f7b1628c09cce18f24c8e2 config-baseline.json
|
||||
78888d302b2263583430e41b9811277aab91937201d4de90cfbd5761e9b95727 config-baseline.json
|
||||
58e98b59498060d301104b3772332de5600eb674687b06d0d32a202370709ee0 config-baseline.core.json
|
||||
a9f058ee9616e189dab7fc223e1207a49ae52b8490b8028935c9d0a2b16f81b2 config-baseline.channel.json
|
||||
323a9fd49a669951ca5b3442d95aad243bd1330083f9857e83a8dcfae2bbc9d0 config-baseline.channel.json
|
||||
1f5592bfd141ba1e982ce31763a253c10afb080ab4ea2b6538299b114e29cee1 config-baseline.plugin.json
|
||||
|
||||
@@ -105,6 +105,7 @@ openclaw gateway
|
||||
```
|
||||
|
||||
If OpenClaw is already running as a background service, restart it via the OpenClaw Mac app or by stopping and restarting the `openclaw gateway run` process.
|
||||
For managed service installs, run `openclaw gateway install` from a shell where `DISCORD_BOT_TOKEN` is present, or store the variable in `~/.openclaw/.env`, so the service can resolve the env SecretRef after restart.
|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ Only the owner number (from `channels.whatsapp.allowFrom`, or the bot’s own E.
|
||||
- Heartbeats are intentionally skipped for groups to avoid noisy broadcasts.
|
||||
- Echo suppression uses the combined batch string; if you send identical text twice without mentions, only the first will get a response.
|
||||
- Session store entries will appear as `agent:<agentId>:whatsapp:group:<jid>` in the session store (`~/.openclaw/agents/<agentId>/sessions/sessions.json` by default); a missing entry just means the group hasn’t triggered a run yet.
|
||||
- Typing indicators in groups follow `agents.defaults.typingMode` (default: `message` when unmentioned).
|
||||
- Typing indicators in groups follow `agents.defaults.typingMode`. When visible replies use the default message-tool-only mode, typing starts immediately by default so group members can see the agent is working even if no automatic final reply is posted. Explicit typing-mode config still wins.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ That means the agent still processes the turn and can update memory/session stat
|
||||
|
||||
This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool.
|
||||
|
||||
Typing indicators are still sent while the agent works in tool-only mode. The default group typing mode is upgraded from "message" to "instant" for these turns because there may never be normal assistant message text before the agent decides whether to call the message tool. Explicit typing-mode config still wins.
|
||||
|
||||
To restore legacy automatic final replies for group/channel rooms:
|
||||
|
||||
```json5
|
||||
|
||||
@@ -117,6 +117,27 @@ openclaw gateway
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Socket Mode transport tuning
|
||||
|
||||
OpenClaw sets the Slack SDK client pong timeout to 15 seconds by default for Socket Mode. Override the transport settings only when you need workspace- or host-specific tuning:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
mode: "socket",
|
||||
socketMode: {
|
||||
clientPingTimeout: 20000,
|
||||
serverPingTimeout: 30000,
|
||||
pingPongLoggingEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use this only for Socket Mode workspaces that log Slack websocket pong/server-ping timeouts or run on hosts with known event-loop starvation. `clientPingTimeout` is the pong wait after the SDK sends a client ping; `serverPingTimeout` is the wait for Slack server pings. App messages and events remain application state, not transport liveness signals.
|
||||
|
||||
## Manifest and scope checklist
|
||||
|
||||
The base Slack app manifest is the same for Socket Mode and HTTP Request URLs. Only the `settings` block (and the slash command `url`) differs.
|
||||
@@ -611,6 +632,8 @@ Notes:
|
||||
<Accordion title="Inbound attachments">
|
||||
Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit. File placeholders include the Slack `fileId` so agents can fetch the original file with `download-file`.
|
||||
|
||||
Downloads use bounded idle and total timeouts. If Slack file retrieval stalls or fails, OpenClaw keeps processing the message and falls back to the file placeholder.
|
||||
|
||||
Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
29
docs/ci.md
29
docs/ci.md
@@ -230,18 +230,29 @@ or overlapping changed hunks.
|
||||
The `CodeQL` workflow is intentionally a narrow first-pass security scanner,
|
||||
not the full repository sweep. Daily and manual runs scan Actions workflow code
|
||||
plus the highest-risk JavaScript/TypeScript auth, secrets, sandbox, cron, and
|
||||
gateway surfaces with high-precision security queries. Android and macOS remain
|
||||
manual security shards so their runtime and alert quality can be tracked
|
||||
separately.
|
||||
gateway surfaces with high-precision security queries.
|
||||
|
||||
The `CodeQL Android Critical Security` workflow is the scheduled Android
|
||||
security shard. It builds the Android app manually for CodeQL on the smallest
|
||||
Blacksmith Linux runner label accepted by workflow sanity and uploads results
|
||||
under the `/codeql-critical-security/android` category.
|
||||
|
||||
The `CodeQL macOS Critical Security` workflow is the weekly/manual macOS
|
||||
security shard. It builds the macOS app manually for CodeQL on Blacksmith macOS,
|
||||
filters dependency build results out of the uploaded SARIF, and uploads results
|
||||
under the `/codeql-critical-security/macos` category. Keep it outside the daily
|
||||
default workflow because the macOS build dominates runtime even when clean.
|
||||
|
||||
The `CodeQL Critical Quality` workflow is the matching non-security shard. It
|
||||
runs only error-severity, non-security JavaScript/TypeScript quality queries
|
||||
over the same narrow auth, secrets, sandbox, cron, and gateway surface. Keep it
|
||||
separate from the security workflow so quality findings can be scheduled,
|
||||
measured, disabled, or expanded without obscuring security signal. Swift,
|
||||
Android, Python, UI, and bundled-plugin CodeQL expansion should be added back as
|
||||
scoped or sharded follow-up work only after the narrow profiles have stable
|
||||
runtime and signal.
|
||||
over narrow high-value surfaces. Its baseline job scans the same auth, secrets,
|
||||
sandbox, cron, and gateway surface as the security workflow. The plugin-boundary
|
||||
job scans loader, registry, public-surface, and Plugin SDK entrypoint contracts
|
||||
under a separate `/codeql-critical-quality/plugin-boundary` category. Keep the
|
||||
workflow separate from security so quality findings can be scheduled, measured,
|
||||
disabled, or expanded without obscuring security signal. Swift, Python, UI, and
|
||||
bundled-plugin CodeQL expansion should be added back as scoped or sharded
|
||||
follow-up work only after the narrow profiles have stable runtime and signal.
|
||||
|
||||
The `Docs Agent` workflow is an event-driven Codex maintenance lane for keeping
|
||||
existing docs aligned with recently landed changes. It has no pure schedule: a
|
||||
|
||||
@@ -183,6 +183,12 @@ Announce to a specific channel:
|
||||
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
|
||||
```
|
||||
|
||||
Announce to a Telegram forum topic:
|
||||
|
||||
```bash
|
||||
openclaw cron edit <job-id> --announce --channel telegram --to "-1001234567890" --thread-id 42
|
||||
```
|
||||
|
||||
Create an isolated job with lightweight bootstrap context:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -107,18 +107,18 @@ and the shared capability runtime before the provider request is made.
|
||||
|
||||
This table maps common inference tasks to the corresponding infer command.
|
||||
|
||||
| Task | Command | Notes |
|
||||
| ----------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
|
||||
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
|
||||
| Describe an image file | `openclaw infer image describe --file ./image.png --json` | `--model` must be an image-capable `<provider/model>` |
|
||||
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
|
||||
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
|
||||
| Generate a video | `openclaw infer video generate --prompt "..." --json` | Supports provider hints such as `--resolution` |
|
||||
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
|
||||
| Search the web | `openclaw infer web search --query "..." --json` | |
|
||||
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
|
||||
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
|
||||
| Task | Command | Notes |
|
||||
| ----------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------- |
|
||||
| Run a text/model prompt | `openclaw infer model run --prompt "..." --json` | Uses the normal local path by default |
|
||||
| Generate an image | `openclaw infer image generate --prompt "..." --json` | Use `image edit` when starting from an existing file |
|
||||
| Describe an image file | `openclaw infer image describe --file ./image.png --prompt "..." --json` | `--model` must be an image-capable `<provider/model>` |
|
||||
| Transcribe audio | `openclaw infer audio transcribe --file ./memo.m4a --json` | `--model` must be `<provider/model>` |
|
||||
| Synthesize speech | `openclaw infer tts convert --text "..." --output ./speech.mp3 --json` | `tts status` is gateway-oriented |
|
||||
| Generate a video | `openclaw infer video generate --prompt "..." --json` | Supports provider hints such as `--resolution` |
|
||||
| Describe a video file | `openclaw infer video describe --file ./clip.mp4 --json` | `--model` must be `<provider/model>` |
|
||||
| Search the web | `openclaw infer web search --query "..." --json` | |
|
||||
| Fetch a web page | `openclaw infer web fetch --url https://example.com --json` | |
|
||||
| Create embeddings | `openclaw infer embedding create --text "..." --json` | |
|
||||
|
||||
## Behavior
|
||||
|
||||
@@ -176,8 +176,10 @@ openclaw infer image generate --prompt "slow image backend" --timeout-ms 180000
|
||||
openclaw infer image edit --file ./logo.png --model openai/gpt-image-1.5 --output-format png --background transparent --prompt "keep the logo, remove the background" --json
|
||||
openclaw infer image edit --file ./poster.png --prompt "make this a vertical story ad" --size 2160x3840 --aspect-ratio 9:16 --resolution 4K --json
|
||||
openclaw infer image describe --file ./photo.jpg --json
|
||||
openclaw infer image describe --file ./receipt.jpg --prompt "Extract the merchant, date, and total" --json
|
||||
openclaw infer image describe-many --file ./before.png --file ./after.png --prompt "Compare the screenshots and list visible UI changes" --json
|
||||
openclaw infer image describe --file ./ui-screenshot.png --model openai/gpt-4.1-mini --json
|
||||
openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --json
|
||||
openclaw infer image describe --file ./photo.jpg --model ollama/qwen2.5vl:7b --prompt "Describe the image in one sentence" --timeout-ms 300000 --json
|
||||
```
|
||||
|
||||
Notes:
|
||||
@@ -208,6 +210,8 @@ Notes:
|
||||
output paths. When `--output` is set, the final extension may follow the
|
||||
provider's returned MIME type.
|
||||
|
||||
- For `image describe` and `image describe-many`, use `--prompt` to give the vision model a task-specific instruction such as OCR, comparison, UI inspection, or concise captioning.
|
||||
- Use `--timeout-ms` with slow local vision models or cold Ollama starts.
|
||||
- For `image describe`, `--model` must be an image-capable `<provider/model>`.
|
||||
- For local Ollama vision models, pull the model first and set `OLLAMA_API_KEY` to any placeholder value, for example `ollama-local`. See [Ollama](/providers/ollama#vision-and-image-description).
|
||||
|
||||
|
||||
@@ -68,10 +68,18 @@ Inspect current vault mode, health, and Obsidian CLI availability.
|
||||
Use this first when you are unsure whether the vault is initialized, bridge mode
|
||||
is healthy, or Obsidian integration is available.
|
||||
|
||||
When bridge mode is active and configured to read memory artifacts, this command
|
||||
queries the running Gateway so it sees the same active memory plugin context as
|
||||
agent/runtime memory.
|
||||
|
||||
### `wiki doctor`
|
||||
|
||||
Run wiki health checks and surface configuration or vault problems.
|
||||
|
||||
When bridge mode is active and configured to read memory artifacts, this command
|
||||
queries the running Gateway before building the report. Disabled bridge imports
|
||||
and bridge configs that do not read memory artifacts remain local/offline.
|
||||
|
||||
Typical issues include:
|
||||
|
||||
- bridge mode enabled without public memory artifacts
|
||||
@@ -168,6 +176,11 @@ source pages.
|
||||
Use this in `bridge` mode when you want the latest exported memory artifacts
|
||||
pulled into the wiki vault.
|
||||
|
||||
For active bridge artifact reads, the CLI routes the import through Gateway RPC
|
||||
so the import uses the runtime memory plugin context. If bridge imports are
|
||||
disabled or artifact reads are turned off, the command keeps the local/offline
|
||||
zero-import behavior.
|
||||
|
||||
### `wiki unsafe-local import`
|
||||
|
||||
Import from explicitly configured local paths in `unsafe-local` mode.
|
||||
|
||||
@@ -674,6 +674,7 @@ Example (OpenAI‑compatible):
|
||||
- For slow local models or remote LAN/tailnet hosts, set `models.providers.<id>.timeoutSeconds`. This extends provider model HTTP request handling, including connect, headers, body streaming, and the total guarded-fetch abort, without increasing the whole agent runtime timeout.
|
||||
- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
|
||||
- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
|
||||
- For `api: "anthropic-messages"` on non-direct endpoints (any provider other than canonical `anthropic`, or a custom `models.providers.anthropic.baseUrl` whose host is not a public `api.anthropic.com` endpoint), OpenClaw suppresses implicit Anthropic beta headers such as `claude-code-20250219`, `interleaved-thinking-2025-05-14`, and OAuth markers, so custom Anthropic-compatible proxies do not reject unsupported beta flags. Set `models.providers.<id>.headers["anthropic-beta"]` explicitly if your proxy needs specific beta features.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -224,6 +224,27 @@ Serialization notes:
|
||||
rotation does not cut the stored CLI session. If a CLI does not expose a
|
||||
stable OAuth account id, OpenClaw lets that CLI enforce resume permissions.
|
||||
|
||||
## Fallback prelude from claude-cli sessions
|
||||
|
||||
When a `claude-cli` attempt fails over to a non-CLI candidate in
|
||||
[`agents.defaults.model.fallbacks`](/concepts/model-failover), OpenClaw seeds
|
||||
the next attempt with a context prelude harvested from Claude Code's local
|
||||
JSONL transcript at `~/.claude/projects/`. Without this seed, the fallback
|
||||
provider would start cold because OpenClaw's own session transcript is empty
|
||||
for `claude-cli` runs.
|
||||
|
||||
- The prelude prefers the latest `/compact` summary or `compact_boundary`
|
||||
marker, then appends the most recent post-boundary turns up to a char
|
||||
budget. Pre-boundary turns are dropped because the summary already represents
|
||||
them.
|
||||
- Tool blocks are coalesced to compact `(tool call: name)` and
|
||||
`(tool result: …)` hints to keep the prompt budget honest. The summary is
|
||||
labeled `(truncated)` if it overflows.
|
||||
- Same-provider `claude-cli` to `claude-cli` fallbacks rely on Claude's own
|
||||
`--resume` and skip the prelude.
|
||||
- The seed reuses the existing Claude session-file path validation, so
|
||||
arbitrary paths cannot be read.
|
||||
|
||||
## Images (pass-through)
|
||||
|
||||
If your CLI accepts image paths, set `imageArg`:
|
||||
|
||||
@@ -125,8 +125,9 @@ knob.
|
||||
`agents.defaults.bootstrapTotalMaxChars`:
|
||||
normal workspace bootstrap injection.
|
||||
- `agents.defaults.startupContext.*`:
|
||||
one-shot `/new` and `/reset` startup prelude, including recent daily
|
||||
`memory/*.md` files.
|
||||
one-shot reset/startup model-run prelude, including recent daily
|
||||
`memory/*.md` files. Bare chat `/new` and `/reset` commands are
|
||||
acknowledged without invoking the model.
|
||||
- `skills.limits.*`:
|
||||
the compact skills list injected into the system prompt.
|
||||
- `agents.defaults.contextLimits.*`:
|
||||
@@ -142,8 +143,9 @@ budget:
|
||||
|
||||
#### `agents.defaults.startupContext`
|
||||
|
||||
Controls the first-turn startup prelude injected on bare `/new` and `/reset`
|
||||
runs.
|
||||
Controls the first-turn startup prelude injected on reset/startup model runs.
|
||||
Bare chat `/new` and `/reset` commands acknowledge the reset without invoking
|
||||
the model, so they do not load this prelude.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -340,6 +342,7 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
- `imageModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
- Used by the `image` tool path as its vision-model config.
|
||||
- Also used as fallback routing when the selected/default model cannot accept image input.
|
||||
- Prefer explicit `provider/model` refs. Bare IDs are accepted for compatibility; if a bare ID uniquely matches a configured image-capable entry in `models.providers.*.models`, OpenClaw qualifies it to that provider. Ambiguous configured matches require an explicit provider prefix.
|
||||
- `imageGenerationModel`: accepts either a string (`"provider/model"`) or an object (`{ primary, fallbacks }`).
|
||||
- Used by the shared image-generation capability and any future tool/plugin surface that generates images.
|
||||
- Typical values: `google/gemini-3.1-flash-image-preview` for native Gemini image generation, `fal/fal-ai/flux/dev` for fal, `openai/gpt-image-2` for OpenAI Images, or `openai/gpt-image-1.5` for transparent-background OpenAI PNG/WebP output.
|
||||
|
||||
@@ -390,6 +390,11 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
enabled: true,
|
||||
botToken: "xoxb-...",
|
||||
appToken: "xapp-...",
|
||||
socketMode: {
|
||||
clientPingTimeout: 15000,
|
||||
serverPingTimeout: 30000,
|
||||
pingPongLoggingEnabled: false,
|
||||
},
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["U123", "U456", "*"],
|
||||
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
|
||||
@@ -448,6 +453,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
|
||||
|
||||
- **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env fallback).
|
||||
- **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
|
||||
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior.
|
||||
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
|
||||
strings or SecretRef objects.
|
||||
- Slack account snapshots expose per-credential source/status fields such as
|
||||
|
||||
@@ -451,7 +451,7 @@ See [Plugins](/tools/plugin).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS or inject forwarded-client headers. Only list proxies you control. Loopback entries are still valid for same-host proxy/local-detection setups (for example Tailscale Serve or a local reverse proxy), but they do **not** make loopback requests eligible for `gateway.auth.mode: "trusted-proxy"`.
|
||||
- `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
|
||||
- `gateway.nodes.pairing.autoApproveCidrs`: optional CIDR/IP allowlist for auto-approving first-time node device pairing with no requested scopes. It is disabled when unset. This does not auto-approve operator/browser/Control UI/WebChat pairing, and it does not auto-approve role, scope, metadata, or public-key upgrades.
|
||||
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and allowlist evaluation.
|
||||
- `gateway.nodes.allowCommands` / `gateway.nodes.denyCommands`: global allow/deny shaping for declared node commands after pairing and platform allowlist evaluation. Use `allowCommands` to opt into dangerous node commands such as `camera.snap`, `camera.clip`, and `screen.record`; `denyCommands` removes a command even if a platform default or explicit allow would otherwise include it. After a node changes its declared command list, reject and re-approve that device pairing so the gateway stores the updated command snapshot.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ SSH-specific config lives under `agents.defaults.sandbox.ssh`. OpenShell-specifi
|
||||
|
||||
Sandboxing is off by default. If you enable sandboxing and do not choose a backend, OpenClaw uses the Docker backend. It executes tools and sandbox browsers locally via the Docker daemon socket (`/var/run/docker.sock`). Sandbox container isolation is determined by Docker namespaces.
|
||||
|
||||
To expose host GPUs to Docker sandboxes, set `agents.defaults.sandbox.docker.gpus` or the per-agent `agents.list[].sandbox.docker.gpus` override. The value is passed to Docker's `--gpus` flag as a separate argument, for example `"all"` or `"device=GPU-uuid"`, and requires a compatible host runtime such as NVIDIA Container Toolkit.
|
||||
|
||||
<Warning>
|
||||
**Docker-out-of-Docker (DooD) constraints**
|
||||
|
||||
@@ -369,6 +371,8 @@ Default Docker image: `openclaw-sandbox:bookworm-slim`
|
||||
|
||||
The default image does **not** include Node. If a skill needs Node (or other runtimes), either bake a custom image or install via `sandbox.docker.setupCommand` (requires network egress + writable root + root user).
|
||||
|
||||
OpenClaw does not silently substitute plain `debian:bookworm-slim` when `openclaw-sandbox:bookworm-slim` is missing. Sandbox runs that target the default image fail fast with a build instruction until you run `scripts/sandbox-setup.sh`, because the bundled image carries `python3` for sandbox write/edit helpers.
|
||||
|
||||
</Step>
|
||||
<Step title="Optional: build the common image">
|
||||
For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`):
|
||||
|
||||
@@ -116,18 +116,19 @@ Expected output:
|
||||
OpenClaw runs in Docker, but Docker is not the source of truth.
|
||||
All long-lived state must survive restarts, rebuilds, and reboots.
|
||||
|
||||
| Component | Location | Persistence mechanism | Notes |
|
||||
| ------------------- | --------------------------------- | ---------------------- | ------------------------------------------------------------- |
|
||||
| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` |
|
||||
| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents/<agentId>/agent/auth-profiles.json` (OAuth, API keys) |
|
||||
| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state |
|
||||
| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts |
|
||||
| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login |
|
||||
| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
|
||||
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
|
||||
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
|
||||
| OS packages | Container filesystem | Docker image | Do not install at runtime |
|
||||
| Docker container | Ephemeral | Restartable | Safe to destroy |
|
||||
| Component | Location | Persistence mechanism | Notes |
|
||||
| ------------------- | ---------------------------------------- | ---------------------- | ------------------------------------------------------------- |
|
||||
| Gateway config | `/home/node/.openclaw/` | Host volume mount | Includes `openclaw.json`, `.env` |
|
||||
| Model auth profiles | `/home/node/.openclaw/agents/` | Host volume mount | `agents/<agentId>/agent/auth-profiles.json` (OAuth, API keys) |
|
||||
| Skill configs | `/home/node/.openclaw/skills/` | Host volume mount | Skill-level state |
|
||||
| Agent workspace | `/home/node/.openclaw/workspace/` | Host volume mount | Code and agent artifacts |
|
||||
| WhatsApp session | `/home/node/.openclaw/` | Host volume mount | Preserves QR login |
|
||||
| Gmail keyring | `/home/node/.openclaw/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
|
||||
| Plugin runtime deps | `/var/lib/openclaw/plugin-runtime-deps/` | Docker named volume | Generated bundled plugin deps and runtime mirrors |
|
||||
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
|
||||
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
|
||||
| OS packages | Container filesystem | Docker image | Do not install at runtime |
|
||||
| Docker container | Ephemeral | Restartable | Safe to destroy |
|
||||
|
||||
## Updates
|
||||
|
||||
|
||||
@@ -129,6 +129,7 @@ The setup script accepts these optional environment variables:
|
||||
| `OPENCLAW_EXTENSIONS` | Pre-install plugin deps at build time (space-separated names) |
|
||||
| `OPENCLAW_EXTRA_MOUNTS` | Extra host bind mounts (comma-separated `source:target[:opts]`) |
|
||||
| `OPENCLAW_HOME_VOLUME` | Persist `/home/node` in a named Docker volume |
|
||||
| `OPENCLAW_PLUGIN_STAGE_DIR` | Container path for generated bundled plugin deps and mirrors |
|
||||
| `OPENCLAW_SANDBOX` | Opt in to sandbox bootstrap (`1`, `true`, `yes`, `on`) |
|
||||
| `OPENCLAW_DOCKER_SOCKET` | Override Docker socket path |
|
||||
| `OPENCLAW_DISABLE_BONJOUR` | Disable Bonjour/mDNS advertising (defaults to `1` for Docker) |
|
||||
@@ -267,11 +268,24 @@ That mounted config directory is where OpenClaw keeps:
|
||||
- `agents/<agentId>/agent/auth-profiles.json` for stored provider OAuth/API-key auth
|
||||
- `.env` for env-backed runtime secrets such as `OPENCLAW_GATEWAY_TOKEN`
|
||||
|
||||
Bundled plugin runtime dependencies and mirrored runtime files are generated
|
||||
state, not user config. Compose stores them in the named Docker volume
|
||||
`openclaw-plugin-runtime-deps` mounted at
|
||||
`/var/lib/openclaw/plugin-runtime-deps`. Keeping that high-churn tree out of the
|
||||
host config bind mount avoids slow Docker Desktop/WSL file operations and stale
|
||||
Windows handles during cold Gateway startup.
|
||||
|
||||
The default Compose file sets `OPENCLAW_PLUGIN_STAGE_DIR` to that path for both
|
||||
`openclaw-gateway` and `openclaw-cli`, so `openclaw doctor --fix`, channel
|
||||
login/setup commands, and Gateway startup all use the same generated runtime
|
||||
volume.
|
||||
|
||||
For full persistence details on VM deployments, see
|
||||
[Docker VM Runtime - What persists where](/install/docker-vm-runtime#what-persists-where).
|
||||
|
||||
**Disk growth hotspots:** watch `media/`, session JSONL files, `cron/runs/*.jsonl`,
|
||||
and rolling file logs under `/tmp/openclaw/`.
|
||||
the `openclaw-plugin-runtime-deps` Docker volume, and rolling file logs under
|
||||
`/tmp/openclaw/`.
|
||||
|
||||
### Shell helpers (optional)
|
||||
|
||||
|
||||
@@ -188,6 +188,23 @@ openclaw nodes invoke --node <idOrNameOrIp> --command canvas.eval --params '{"ja
|
||||
|
||||
Higher-level helpers exist for the common “give the agent a MEDIA attachment” workflows.
|
||||
|
||||
## Command policy
|
||||
|
||||
Node commands must pass two gates before they can be invoked:
|
||||
|
||||
1. The node must declare the command in its WebSocket `connect.commands` list.
|
||||
2. The gateway's platform policy must allow the declared command.
|
||||
|
||||
Windows and macOS companion nodes allow safe declared commands such as
|
||||
`canvas.*`, `camera.list`, `location.get`, and `screen.snapshot` by default.
|
||||
Dangerous or privacy-heavy commands such as `camera.snap`, `camera.clip`, and
|
||||
`screen.record` still require explicit opt-in with
|
||||
`gateway.nodes.allowCommands`. `gateway.nodes.denyCommands` always wins over
|
||||
defaults and extra allowlist entries.
|
||||
|
||||
After a node changes its declared command list, reject the old device pairing
|
||||
and approve the new request so the gateway stores the updated command snapshot.
|
||||
|
||||
## Screenshots (canvas snapshots)
|
||||
|
||||
If the node is showing the Canvas (WebView), `canvas.snapshot` returns `{ format, base64 }`.
|
||||
|
||||
@@ -172,6 +172,7 @@ If `tools.media.<capability>.enabled` is **not** set to `false` and you haven't
|
||||
</Step>
|
||||
<Step title="agents.defaults.imageModel">
|
||||
`agents.defaults.imageModel` primary/fallback refs (image only).
|
||||
Prefer `provider/model` refs. Bare refs are qualified from configured image-capable provider model entries only when the match is unique.
|
||||
</Step>
|
||||
<Step title="Local CLIs (audio only)">
|
||||
Local CLIs (if installed):
|
||||
|
||||
@@ -65,6 +65,12 @@ If bridge mode reports zero exported artifacts, the active memory plugin is not
|
||||
currently exposing public bridge inputs yet. Run `openclaw wiki doctor` first,
|
||||
then confirm the active memory plugin supports public artifacts.
|
||||
|
||||
When bridge mode is active and `bridge.readMemoryArtifacts` is enabled,
|
||||
`openclaw wiki status`, `openclaw wiki doctor`, and `openclaw wiki bridge
|
||||
import` read through the running Gateway. That keeps CLI bridge checks aligned
|
||||
with the runtime memory plugin context. If bridge is disabled or artifact reads
|
||||
are turned off, those commands keep their local/offline behavior.
|
||||
|
||||
## Vault modes
|
||||
|
||||
`memory-wiki` supports three vault modes:
|
||||
|
||||
@@ -84,17 +84,17 @@ calls can continue.
|
||||
## Thinking and tools
|
||||
|
||||
DeepSeek V4 thinking sessions have a stricter replay contract than most
|
||||
OpenAI-compatible providers: when a thinking-enabled assistant message includes
|
||||
tool calls, DeepSeek expects the prior assistant `reasoning_content` to be sent
|
||||
back on the follow-up request. OpenClaw handles this inside the DeepSeek plugin,
|
||||
so normal multi-turn tool use works with `deepseek/deepseek-v4-flash` and
|
||||
`deepseek/deepseek-v4-pro`.
|
||||
OpenAI-compatible providers: after a thinking-enabled turn uses tools, DeepSeek
|
||||
expects replayed assistant messages from that turn to include
|
||||
`reasoning_content` on follow-up requests. OpenClaw handles this inside the
|
||||
DeepSeek plugin, so normal multi-turn tool use works with
|
||||
`deepseek/deepseek-v4-flash` and `deepseek/deepseek-v4-pro`.
|
||||
|
||||
If you switch an existing session from another OpenAI-compatible provider to a
|
||||
DeepSeek V4 model, older assistant tool-call turns may not have native
|
||||
DeepSeek `reasoning_content`. OpenClaw fills that missing field for DeepSeek V4
|
||||
thinking requests so the provider can accept the replayed tool-call history
|
||||
without requiring `/new`.
|
||||
DeepSeek `reasoning_content`. OpenClaw fills that missing field on replayed
|
||||
assistant messages for DeepSeek V4 thinking requests so the provider can accept
|
||||
the history without requiring `/new`.
|
||||
|
||||
When thinking is disabled in OpenClaw (including the UI **None** selection),
|
||||
OpenClaw sends DeepSeek `thinking: { type: "disabled" }` and strips replayed
|
||||
|
||||
@@ -283,6 +283,8 @@ To make Ollama the default image-understanding model for inbound media, configur
|
||||
}
|
||||
```
|
||||
|
||||
Prefer the full `ollama/<model>` ref. If the same model is listed under `models.providers.ollama.models` with `input: ["text", "image"]` and no other configured image provider exposes that bare model ID, OpenClaw also normalizes a bare `imageModel` ref such as `qwen2.5vl:7b` to `ollama/qwen2.5vl:7b`. If more than one configured image provider has the same bare ID, use the provider prefix explicitly.
|
||||
|
||||
Slow local vision models can need a longer image-understanding timeout than cloud models. They can also crash or stop when Ollama tries to allocate the full advertised vision context on constrained hardware. Set a capability timeout, and cap `num_ctx` on the model entry when you only need a normal image-description turn:
|
||||
|
||||
```json5
|
||||
@@ -859,7 +861,7 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
|
||||
}
|
||||
```
|
||||
|
||||
Per-model `params.think` or `params.thinking` can disable or force Ollama API thinking for a specific configured model. Runtime commands such as `/think off` still apply to the active run.
|
||||
Per-model `params.think` or `params.thinking` can disable or force Ollama API thinking for a specific configured model. OpenClaw preserves those explicit model params when the active run only has the implicit default `off`; non-off runtime commands such as `/think medium` still override the active run.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -934,7 +936,7 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
|
||||
<Accordion title="Streaming configuration">
|
||||
OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by default, which fully supports streaming and tool calling simultaneously. No special configuration is needed.
|
||||
|
||||
For native `/api/chat` requests, OpenClaw also forwards thinking control directly to Ollama: `/think off` and `openclaw agent --thinking off` send top-level `think: false`, while `/think low|medium|high` send the matching top-level `think` effort string. `/think max` maps to Ollama's highest native effort, `think: "high"`.
|
||||
For native `/api/chat` requests, OpenClaw also forwards thinking control directly to Ollama: `/think off` and `openclaw agent --thinking off` send top-level `think: false` unless an explicit model `params.think`/`params.thinking` value is configured, while `/think low|medium|high` send the matching top-level `think` effort string. `/think max` maps to Ollama's highest native effort, `think: "high"`.
|
||||
|
||||
<Tip>
|
||||
If you need to use the OpenAI-compatible endpoint, see the "Legacy OpenAI-compatible mode" section above. Streaming and tool calling may not work simultaneously in that mode.
|
||||
@@ -1060,6 +1062,18 @@ For the full setup and behavior details, see [Ollama Web Search](/tools/ollama-s
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Kimi or GLM returns garbled symbols">
|
||||
Hosted Kimi/GLM responses that are long, non-linguistic symbol runs are treated as failed provider output instead of a successful assistant answer. That lets normal retry, fallback, or error handling take over without persisting the corrupted text into the session.
|
||||
|
||||
If it happens repeatedly, capture the raw model name, the current session file, and whether the run used `Cloud + Local` or `Cloud only`, then try a fresh session and a fallback model:
|
||||
|
||||
```bash
|
||||
openclaw infer model run --model ollama/kimi-k2.5:cloud --prompt "Reply with exactly: ok" --json
|
||||
openclaw models set ollama/gemma4
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Cold local model times out">
|
||||
Large local models can need a long first load before streaming begins. Keep the timeout scoped to the Ollama provider, and optionally ask Ollama to keep the model loaded between turns:
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ read_when:
|
||||
- You want a single API key for many LLMs
|
||||
- You want to run models via OpenRouter in OpenClaw
|
||||
- You want to use OpenRouter for image generation
|
||||
- You want to use OpenRouter for video generation
|
||||
title: "OpenRouter"
|
||||
---
|
||||
|
||||
@@ -78,6 +79,33 @@ OpenRouter can also back the `image_generate` tool. Use an OpenRouter image mode
|
||||
|
||||
OpenClaw sends image requests to OpenRouter's chat completions image API with `modalities: ["image", "text"]`. Gemini image models receive supported `aspectRatio` and `resolution` hints through OpenRouter's `image_config`. Use `agents.defaults.imageGenerationModel.timeoutMs` for slower OpenRouter image models; the `image_generate` tool's per-call `timeoutMs` parameter still wins.
|
||||
|
||||
## Video generation
|
||||
|
||||
OpenRouter can also back the `video_generate` tool through its asynchronous `/videos` API. Use an OpenRouter video model under `agents.defaults.videoGenerationModel`:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { OPENROUTER_API_KEY: "sk-or-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
videoGenerationModel: {
|
||||
primary: "openrouter/google/veo-3.1-fast",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw submits text-to-video and image-to-video jobs to OpenRouter, polls
|
||||
the returned `polling_url`, and downloads the completed video from
|
||||
OpenRouter's `unsigned_urls` or the documented job content endpoint.
|
||||
Reference images are sent as first/last frame images by default; images
|
||||
tagged with `reference_image` are sent as OpenRouter input references. The
|
||||
bundled `google/veo-3.1-fast` default advertises the currently supported 4/6/8
|
||||
second durations, `720P`/`1080P` resolutions, and `16:9`/`9:16` aspect
|
||||
ratios. Video-to-video is not registered for OpenRouter because the upstream
|
||||
video generation API currently accepts text and image references.
|
||||
|
||||
## Text-to-speech
|
||||
|
||||
OpenRouter can also be used as a TTS provider through its OpenAI-compatible
|
||||
|
||||
@@ -127,9 +127,9 @@ Use the table below to pick the right model for your use case.
|
||||
|
||||
If Venice exposes DeepSeek V4 models such as `venice/deepseek-v4-pro` or
|
||||
`venice/deepseek-v4-flash`, OpenClaw fills the required DeepSeek V4
|
||||
`reasoning_content` replay placeholder on assistant tool-call turns when the
|
||||
proxy omits it. Venice rejects DeepSeek's native top-level `thinking` control,
|
||||
so OpenClaw keeps that provider-specific replay fix separate from the native
|
||||
`reasoning_content` replay placeholder on assistant messages when the proxy
|
||||
omits it. Venice rejects DeepSeek's native top-level `thinking` control, so
|
||||
OpenClaw keeps that provider-specific replay fix separate from the native
|
||||
DeepSeek provider's thinking controls.
|
||||
|
||||
## Built-in catalog (41 total)
|
||||
|
||||
@@ -16,6 +16,7 @@ title: "Tests"
|
||||
- `pnpm check:changed`: runs the smart changed check gate for the diff against `origin/main`. It runs typecheck, lint, and guard commands for the affected architectural lanes, but does not run Vitest tests. Use `pnpm test:changed` or explicit `pnpm test <target>` for test proof.
|
||||
- `pnpm test`: routes explicit file/directory targets through scoped Vitest lanes. Untargeted runs use fixed shard groups and expand to leaf configs for local parallel execution; the extension group always expands to the per-extension shard configs instead of one giant root-project process.
|
||||
- Test wrapper runs end with a short `[test] passed|failed|skipped ... in ...` summary. Vitest's own duration line stays the per-shard detail.
|
||||
- Shared OpenClaw test state: use `src/test-utils/openclaw-test-state.ts` from Vitest when a test needs an isolated `HOME`, `OPENCLAW_STATE_DIR`, `OPENCLAW_CONFIG_PATH`, config fixture, workspace, agent dir, or auth-profile store. Docker/Bash E2E lanes can use `scripts/lib/openclaw-test-state.mjs shell --label <name> --scenario <name>` for an in-container shell snippet, or `node scripts/lib/openclaw-test-state.mjs -- create --label <name> --scenario <name> --env-file <path> --json` for a sourceable host env file. The `--` before `create` keeps newer Node runtimes from treating `--env-file` as a Node flag.
|
||||
- Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact.
|
||||
- Selected `plugin-sdk` and `commands` test files now route through dedicated light lanes that keep only `test/setup.ts`, leaving runtime-heavy cases on their existing lanes.
|
||||
- Source files with sibling tests map to that sibling before falling back to wider directory globs. Helper edits under `src/channels/plugins/contracts/test-helpers`, `src/plugin-sdk/test-helpers`, and `src/plugins/contracts` use a local import graph to run importing tests instead of broad-running every shard when the dependency path is precise.
|
||||
@@ -34,7 +35,7 @@ title: "Tests"
|
||||
- Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `threads` + `isolate: false` with adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
|
||||
- `pnpm test:docker:all`: Builds the shared live-test image, packs OpenClaw once as an npm tarball, builds/reuses a bare Node/Git runner image plus a functional image that installs that tarball into `/app`, then runs Docker smoke lanes with `OPENCLAW_SKIP_DOCKER_BUILD=1` through a weighted scheduler. The bare image (`OPENCLAW_DOCKER_E2E_BARE_IMAGE`) is used for installer/update/plugin-dependency lanes; those lanes mount the prebuilt tarball instead of using copied repo sources. The functional image (`OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE`) is used for normal built-app functionality lanes. `scripts/package-openclaw-for-docker.mjs` is the single local/CI package packer and validates the tarball plus `dist/postinstall-inventory.json` before Docker consumes it. Docker lane definitions live in `scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in `scripts/lib/docker-e2e-plan.mjs`; `scripts/test-docker-all.mjs` executes the selected plan. `node scripts/test-docker-all.mjs --plan-json` emits the scheduler-owned CI plan for selected lanes, image kinds, package/live-image needs, state scenarios, and credential checks without building or running Docker. `OPENCLAW_DOCKER_ALL_PARALLELISM=<n>` controls process slots and defaults to 10; `OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM=<n>` controls the provider-sensitive tail pool and defaults to 10. Heavy lane caps default to `OPENCLAW_DOCKER_ALL_LIVE_LIMIT=9`, `OPENCLAW_DOCKER_ALL_NPM_LIMIT=10`, and `OPENCLAW_DOCKER_ALL_SERVICE_LIMIT=7`; provider caps default to one heavy lane per provider via `OPENCLAW_DOCKER_ALL_LIVE_CLAUDE_LIMIT=4`, `OPENCLAW_DOCKER_ALL_LIVE_CODEX_LIMIT=4`, and `OPENCLAW_DOCKER_ALL_LIVE_GEMINI_LIMIT=4`. Use `OPENCLAW_DOCKER_ALL_WEIGHT_LIMIT` or `OPENCLAW_DOCKER_ALL_DOCKER_LIMIT` for larger hosts. If one lane exceeds the effective weight or resource cap on a low-parallelism host, it can still start from an empty pool and will run alone until it releases capacity. Lane starts are staggered by 2 seconds by default to avoid local Docker daemon create storms; override with `OPENCLAW_DOCKER_ALL_START_STAGGER_MS=<ms>`. The runner preflights Docker by default, cleans stale OpenClaw E2E containers, emits active-lane status every 30 seconds, shares provider CLI tool caches between compatible lanes, retries transient live-provider failures once by default (`OPENCLAW_DOCKER_ALL_LIVE_RETRIES=<n>`), and stores lane timings in `.artifacts/docker-tests/lane-timings.json` for longest-first ordering on later runs. Use `OPENCLAW_DOCKER_ALL_DRY_RUN=1` to print the lane manifest without running Docker, `OPENCLAW_DOCKER_ALL_STATUS_INTERVAL_MS=<ms>` to tune status output, or `OPENCLAW_DOCKER_ALL_TIMINGS=0` to disable timing reuse. Use `OPENCLAW_DOCKER_ALL_LIVE_MODE=skip` for deterministic/local lanes only or `OPENCLAW_DOCKER_ALL_LIVE_MODE=only` for live-provider lanes only; package aliases are `pnpm test:docker:local:all` and `pnpm test:docker:live:all`. Live-only mode merges main and tail live lanes into one longest-first pool so provider buckets can pack Claude, Codex, and Gemini work together. The runner stops scheduling new pooled lanes after the first failure unless `OPENCLAW_DOCKER_ALL_FAIL_FAST=0` is set, and each lane has a 120-minute fallback timeout overrideable with `OPENCLAW_DOCKER_ALL_LANE_TIMEOUT_MS`; selected live/tail lanes use tighter per-lane caps. CLI backend Docker setup commands have their own timeout via `OPENCLAW_LIVE_CLI_BACKEND_SETUP_TIMEOUT_SECONDS` (default 180). Per-lane logs, `summary.json`, `failures.json`, and phase timings are written under `.artifacts/docker-tests/<run-id>/`; use `pnpm test:docker:timings <summary.json>` to inspect slow lanes and `pnpm test:docker:rerun <run-id|summary.json|failures.json>` to print cheap targeted rerun commands.
|
||||
- `pnpm test:docker:browser-cdp-snapshot`: Builds a Chromium-backed source E2E container, starts raw CDP plus an isolated Gateway, runs `browser doctor --deep`, and verifies CDP role snapshots include link URLs, cursor-promoted clickables, iframe refs, and frame metadata.
|
||||
- CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:codex`, `pnpm test:docker:live-cli-backend:codex:resume`, or `pnpm test:docker:live-cli-backend:codex:mcp`. Claude and Gemini have matching `:resume` and `:mcp` aliases.
|
||||
- `pnpm test:docker:openwebui`: Starts Dockerized OpenClaw + Open WebUI, signs in through Open WebUI, checks `/api/models`, then runs a real proxied chat through `/api/chat/completions`. Requires a usable live model key (for example OpenAI in `~/.profile`), pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
|
||||
@@ -21,7 +21,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
|
||||
with optional per-agent override at
|
||||
`agents.list[].skillsLimits.maxSkillsPromptChars`.
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but bare `/new` and `/reset` can prepend a one-shot startup-context block with recent daily memory for that first turn. That startup prelude is controlled by `agents.defaults.startupContext`.
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` when present). Lowercase root `memory.md` is not injected; it is legacy repair input for `openclaw doctor --fix` when paired with `MEMORY.md`. Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 12000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 60000). `memory/*.md` daily files are not part of the normal bootstrap prompt; they remain on-demand via memory tools on ordinary turns, but reset/startup model runs can prepend a one-shot startup-context block with recent daily memory for that first turn. Bare chat `/new` and `/reset` commands are acknowledged without invoking the model. The startup prelude is controlled by `agents.defaults.startupContext`.
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
|
||||
@@ -159,7 +159,7 @@ Example:
|
||||
|
||||
- Session files: `~/.openclaw/agents/<agentId>/sessions/{{SessionId}}.jsonl`
|
||||
- Session metadata (token usage, last route, etc): `~/.openclaw/agents/<agentId>/sessions/sessions.json` (legacy: `~/.openclaw/sessions/sessions.json`)
|
||||
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, the agent replies with a short hello to confirm the reset.
|
||||
- `/new` or `/reset` starts a fresh session for that chat (configurable via `resetTriggers`). If sent alone, OpenClaw acknowledges the reset without invoking the model.
|
||||
- `/compact [instructions]` compacts the session context and reports the remaining context budget.
|
||||
|
||||
## Heartbeats (proactive mode)
|
||||
|
||||
@@ -61,6 +61,7 @@ provider is configured.
|
||||
| MiniMax | ✓ | ✓ | ✓ | ✓ | | | |
|
||||
| Mistral | | | | | ✓ | | |
|
||||
| OpenAI | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ |
|
||||
| OpenRouter | ✓ | ✓ | | ✓ | | | ✓ |
|
||||
| Qwen | | ✓ | | | | | |
|
||||
| Runway | | ✓ | | | | | |
|
||||
| SenseAudio | | | | | ✓ | | |
|
||||
|
||||
@@ -324,6 +324,10 @@ under `skills.entries` in `~/.openclaw/openclaw.json`:
|
||||
|
||||
<ParamField path="enabled" type="boolean">
|
||||
`false` disables the skill even if it is bundled or installed.
|
||||
The bundled `coding-agent` skill is opt-in: set
|
||||
`skills.entries.coding-agent.enabled: true` before exposing it to agents,
|
||||
then make sure one of `claude`, `codex`, `opencode`, or `pi` is installed and
|
||||
authenticated for its own CLI.
|
||||
</ParamField>
|
||||
<ParamField path="apiKey" type='string | { source, provider, id }'>
|
||||
Convenience for skills that declare `metadata.openclaw.primaryEnv`. Supports plaintext or SecretRef.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Generate videos via video_generate from text, image, or video references across 14 provider backends"
|
||||
summary: "Generate videos via video_generate from text, image, or video references across 16 provider backends"
|
||||
read_when:
|
||||
- Generating videos via the agent
|
||||
- Configuring video-generation providers and models
|
||||
@@ -9,7 +9,7 @@ sidebarTitle: "Video generation"
|
||||
---
|
||||
|
||||
OpenClaw agents can generate videos from text prompts, reference images, or
|
||||
existing videos. Fifteen provider backends are supported, each with
|
||||
existing videos. Sixteen provider backends are supported, each with
|
||||
different model options, input modes, and feature sets. The agent picks the
|
||||
right provider automatically based on your configuration and available API
|
||||
keys.
|
||||
@@ -116,6 +116,7 @@ generation.
|
||||
| Google | `veo-3.1-fast-generate-preview` | ✓ | 1 image | 1 video | `GEMINI_API_KEY` |
|
||||
| MiniMax | `MiniMax-Hailuo-2.3` | ✓ | 1 image | — | `MINIMAX_API_KEY` or MiniMax OAuth |
|
||||
| OpenAI | `sora-2` | ✓ | 1 image | 1 video | `OPENAI_API_KEY` |
|
||||
| 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` | ✓ | 1 image | — | `TOGETHER_API_KEY` |
|
||||
@@ -133,21 +134,22 @@ runtime modes at runtime.
|
||||
The explicit mode contract used by `video_generate`, contract tests, and
|
||||
the shared live sweep:
|
||||
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| --------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
|
||||
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
|
||||
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
|
||||
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
|
||||
| Qwen | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Vydra | ✓ | ✓ | — | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
|
||||
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
| Provider | `generate` | `imageToVideo` | `videoToVideo` | Shared live lanes today |
|
||||
| ---------- | :--------: | :------------: | :------------: | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Alibaba | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| BytePlus | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| ComfyUI | ✓ | ✓ | — | Not in the shared sweep; workflow-specific coverage lives with Comfy tests |
|
||||
| DeepInfra | ✓ | — | — | `generate`; native DeepInfra video schemas are text-to-video in the bundled contract |
|
||||
| fal | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` only when using Seedance reference-to-video |
|
||||
| Google | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because the current buffer-backed Gemini/Veo sweep does not accept that input |
|
||||
| MiniMax | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| OpenAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; shared `videoToVideo` skipped because this org/input path currently needs provider-side inpaint/remix access |
|
||||
| OpenRouter | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Qwen | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider needs remote `http(s)` video URLs |
|
||||
| Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` |
|
||||
| Together | ✓ | ✓ | — | `generate`, `imageToVideo` |
|
||||
| Vydra | ✓ | ✓ | — | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL |
|
||||
| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL |
|
||||
|
||||
## Tool parameters
|
||||
|
||||
@@ -389,6 +391,13 @@ only the explicit `model`, `primary`, and `fallbacks` entries.
|
||||
(`aspectRatio`, `resolution`, `audio`, `watermark`) are ignored with
|
||||
a warning.
|
||||
</Accordion>
|
||||
<Accordion title="OpenRouter">
|
||||
Uses OpenRouter's asynchronous `/videos` API. OpenClaw submits the
|
||||
job, polls `polling_url`, and downloads either `unsigned_urls` or the
|
||||
documented job content endpoint. The bundled `google/veo-3.1-fast` default
|
||||
advertises 4/6/8 second durations, `720P`/`1080P` resolutions, and
|
||||
`16:9`/`9:16` aspect ratios.
|
||||
</Accordion>
|
||||
<Accordion title="Qwen">
|
||||
Same DashScope backend as Alibaba. Reference inputs must be remote
|
||||
`http(s)` URLs; local files are rejected upfront.
|
||||
|
||||
1
extensions/bluebubbles/doctor-contract-api.ts
Normal file
1
extensions/bluebubbles/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
@@ -59,6 +59,27 @@ describe("codex plugin", () => {
|
||||
expect(onConversationBindingResolved).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
|
||||
it("registers with capture APIs that do not expose conversation binding hooks yet", () => {
|
||||
const api = createTestPluginApi({
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: {} as never,
|
||||
registerAgentHarness: vi.fn(),
|
||||
registerCommand: vi.fn(),
|
||||
registerMediaUnderstandingProvider: vi.fn(),
|
||||
registerProvider: vi.fn(),
|
||||
on: vi.fn(),
|
||||
}) as ReturnType<typeof createTestPluginApi> & {
|
||||
onConversationBindingResolved?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
delete (api as { onConversationBindingResolved?: unknown }).onConversationBindingResolved;
|
||||
|
||||
expect(() => plugin.register(api)).not.toThrow();
|
||||
});
|
||||
|
||||
it("only claims the codex provider by default", () => {
|
||||
const harness = createCodexAppServerAgentHarness();
|
||||
|
||||
|
||||
@@ -34,6 +34,6 @@ export default definePluginEntry({
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
}),
|
||||
);
|
||||
api.onConversationBindingResolved(handleCodexConversationBindingResolved);
|
||||
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -176,4 +176,59 @@ describeLive("deepseek plugin live", () => {
|
||||
});
|
||||
expect(extractNonEmptyAssistantText(result.content).length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
|
||||
it("accepts V4 thinking replay after a prior plain assistant message", async () => {
|
||||
const context: Context = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Say hello.",
|
||||
timestamp: Date.now() - 2,
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
content: [{ type: "text", text: "Hello." }],
|
||||
usage: ZERO_USAGE,
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now() - 1,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with exactly: ok",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const streamFn = createDeepSeekV4ThinkingWrapper(streamSimple, "high");
|
||||
expect(streamFn).toBeDefined();
|
||||
|
||||
const stream = streamFn?.(resolveDeepSeekV4LiveModel(), context, {
|
||||
apiKey: DEEPSEEK_KEY,
|
||||
maxTokens: 64,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
expect(stream).toBeDefined();
|
||||
|
||||
const result = await (await stream!).result();
|
||||
if (result.stopReason === "error") {
|
||||
throw new Error(
|
||||
result.errorMessage || "DeepSeek V4 plain replay returned error with no message",
|
||||
);
|
||||
}
|
||||
|
||||
const messages = capturedPayload?.messages;
|
||||
expect(Array.isArray(messages)).toBe(true);
|
||||
expect((messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello.",
|
||||
reasoning_content: "",
|
||||
});
|
||||
expect(extractNonEmptyAssistantText(result.content).length).toBeGreaterThan(0);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -309,6 +309,73 @@ describe("deepseek provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds blank reasoning_content for replayed plain assistant messages", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
provider: "deepseek",
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 384_000,
|
||||
compat: {
|
||||
supportsUsageInStreaming: true,
|
||||
supportsReasoningEffort: true,
|
||||
maxTokensField: "max_tokens",
|
||||
},
|
||||
} as Model<"openai-completions">;
|
||||
const context = {
|
||||
messages: [
|
||||
{ role: "user", content: "hi", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
content: [{ type: "text", text: "Hello." }],
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
{ role: "user", content: "next", timestamp: 3 },
|
||||
],
|
||||
} as Context;
|
||||
const baseStreamFn = (
|
||||
streamModel: Model<"openai-completions">,
|
||||
streamContext: Context,
|
||||
options?: { onPayload?: (payload: unknown, model: unknown) => unknown },
|
||||
) => {
|
||||
capturedPayload = buildOpenAICompletionsParams(streamModel, streamContext, {
|
||||
reasoning: "high",
|
||||
} as never);
|
||||
options?.onPayload?.(capturedPayload, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
|
||||
const wrapThinkingHigh = createDeepSeekV4ThinkingWrapper(baseStreamFn as never, "high");
|
||||
expect(wrapThinkingHigh).toBeDefined();
|
||||
await wrapThinkingHigh?.(model, context, {});
|
||||
|
||||
expect((capturedPayload?.messages as Array<Record<string, unknown>>)[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hello.",
|
||||
reasoning_content: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips replayed reasoning_content when DeepSeek V4 thinking is disabled", async () => {
|
||||
let capturedPayload: Record<string, unknown> | undefined;
|
||||
const model = {
|
||||
|
||||
@@ -543,13 +543,11 @@ const UpdateRecordSchema = Type.Object({
|
||||
|
||||
export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_bitable: No config available, skipping bitable tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_bitable: No Feishu accounts configured, skipping bitable tools");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -732,6 +730,4 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
api.logger.debug?.("feishu_bitable: Registered bitable tools");
|
||||
}
|
||||
|
||||
@@ -123,20 +123,17 @@ export async function getFeishuMemberInfo(
|
||||
|
||||
export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_chat: No config available, skipping chat tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_chat: No Feishu accounts configured, skipping chat tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstAccount = accounts[0];
|
||||
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
||||
if (!toolsCfg.chat) {
|
||||
api.logger.debug?.("feishu_chat: chat tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,6 +185,4 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
||||
},
|
||||
{ name: "feishu_chat" },
|
||||
);
|
||||
|
||||
api.logger.debug?.("feishu_chat: Registered feishu_chat tool");
|
||||
}
|
||||
|
||||
@@ -1386,14 +1386,12 @@ async function listAppScopes(client: Lark.Client) {
|
||||
|
||||
export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_doc: No config available, skipping doc tools");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any account is configured
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_doc: No Feishu accounts configured, skipping doc tools");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1615,8 +1613,4 @@ export function registerFeishuDocTools(api: OpenClawPluginApi) {
|
||||
);
|
||||
registered.push("feishu_app_scopes");
|
||||
}
|
||||
|
||||
if (registered.length > 0) {
|
||||
api.logger.debug?.(`feishu_doc: Registered ${registered.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,19 +733,16 @@ export async function deliverCommentThreadText(
|
||||
|
||||
export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_drive: No config available, skipping drive tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_drive: No Feishu accounts configured, skipping drive tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.drive) {
|
||||
api.logger.debug?.("feishu_drive: drive tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -829,6 +826,4 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
|
||||
},
|
||||
{ name: "feishu_drive" },
|
||||
);
|
||||
|
||||
api.logger.debug?.(`feishu_drive: Registered feishu_drive tool`);
|
||||
}
|
||||
|
||||
@@ -114,19 +114,16 @@ async function removeMember(
|
||||
|
||||
export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.perm) {
|
||||
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,6 +167,4 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
||||
},
|
||||
{ name: "feishu_perm" },
|
||||
);
|
||||
|
||||
api.logger.debug?.(`feishu_perm: Registered feishu_perm tool`);
|
||||
}
|
||||
|
||||
@@ -153,19 +153,16 @@ async function renameNode(client: Lark.Client, spaceId: string, nodeToken: strin
|
||||
|
||||
export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
if (!api.config) {
|
||||
api.logger.debug?.("feishu_wiki: No config available, skipping wiki tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const accounts = listEnabledFeishuAccounts(api.config);
|
||||
if (accounts.length === 0) {
|
||||
api.logger.debug?.("feishu_wiki: No Feishu accounts configured, skipping wiki tools");
|
||||
return;
|
||||
}
|
||||
|
||||
const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
|
||||
if (!toolsCfg.wiki) {
|
||||
api.logger.debug?.("feishu_wiki: wiki tool disabled in config");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -227,6 +224,4 @@ export function registerFeishuWikiTools(api: OpenClawPluginApi) {
|
||||
},
|
||||
{ name: "feishu_wiki" },
|
||||
);
|
||||
|
||||
api.logger.debug?.(`feishu_wiki: Registered feishu_wiki tool`);
|
||||
}
|
||||
|
||||
1
extensions/googlechat/doctor-contract-api.ts
Normal file
1
extensions/googlechat/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
1
extensions/matrix/doctor-contract-api.ts
Normal file
1
extensions/matrix/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
1
extensions/mattermost/doctor-contract-api.ts
Normal file
1
extensions/mattermost/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
@@ -0,0 +1,337 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
class FakeWebSocket {
|
||||
public readonly sent: string[] = [];
|
||||
private readonly openListeners: Array<() => void> = [];
|
||||
private readonly messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
|
||||
private readonly closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
||||
private readonly errorListeners: Array<(err: unknown) => void> = [];
|
||||
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
|
||||
if (event === "open") {
|
||||
this.openListeners.push(listener as () => void);
|
||||
return;
|
||||
}
|
||||
if (event === "message") {
|
||||
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
|
||||
return;
|
||||
}
|
||||
if (event === "close") {
|
||||
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
|
||||
return;
|
||||
}
|
||||
this.errorListeners.push(listener as (err: unknown) => void);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
|
||||
terminate(): void {}
|
||||
|
||||
get openListenerCount(): number {
|
||||
return this.openListeners.length;
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
for (const listener of this.openListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
async emitMessage(payload: unknown): Promise<void> {
|
||||
const buffer = Buffer.from(JSON.stringify(payload), "utf8");
|
||||
await Promise.all(this.messageListeners.map((listener) => Promise.resolve(listener(buffer))));
|
||||
}
|
||||
|
||||
emitClose(code: number, reason = ""): void {
|
||||
const buffer = Buffer.from(reason, "utf8");
|
||||
for (const listener of this.closeListeners) {
|
||||
listener(code, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(err: unknown): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
abortController: undefined as AbortController | undefined,
|
||||
createMattermostClient: vi.fn(),
|
||||
createMattermostDraftStream: vi.fn(),
|
||||
dispatchReplyFromConfig: vi.fn(),
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
fetchMattermostMe: vi.fn(),
|
||||
registerMattermostMonitorSlashCommands: vi.fn(),
|
||||
registerPluginHttpRoute: vi.fn(),
|
||||
resolveChannelInfo: vi.fn(),
|
||||
resolveMattermostMedia: vi.fn(),
|
||||
resolveUserInfo: vi.fn(),
|
||||
runtimeCore: undefined as unknown,
|
||||
updateMattermostPost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||
return {
|
||||
...actual,
|
||||
createMattermostClient: mockState.createMattermostClient,
|
||||
fetchMattermostMe: mockState.fetchMattermostMe,
|
||||
normalizeMattermostBaseUrl: (value: string | undefined) => value?.trim() ?? "",
|
||||
updateMattermostPost: mockState.updateMattermostPost,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./draft-stream.js", () => ({
|
||||
buildMattermostToolStatusText: () => "Working",
|
||||
createMattermostDraftStream: mockState.createMattermostDraftStream,
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-resources.js", () => ({
|
||||
createMattermostMonitorResources: () => ({
|
||||
resolveMattermostMedia: mockState.resolveMattermostMedia,
|
||||
sendTypingIndicator: vi.fn(async () => {}),
|
||||
resolveChannelInfo: mockState.resolveChannelInfo,
|
||||
resolveUserInfo: mockState.resolveUserInfo,
|
||||
updateModelPickerPost: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor-slash.js", () => ({
|
||||
registerMattermostMonitorSlashCommands: mockState.registerMattermostMonitorSlashCommands,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
|
||||
return {
|
||||
...actual,
|
||||
buildAgentMediaPayload: vi.fn(() => ({})),
|
||||
createChannelPairingController: vi.fn(() => ({
|
||||
readStoreForDmPolicy: vi.fn(async () => []),
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
|
||||
})),
|
||||
createChannelReplyPipeline: vi.fn(() => ({
|
||||
onModelSelected: vi.fn(),
|
||||
typingCallbacks: {},
|
||||
})),
|
||||
readStoreAllowFromForDmPolicy: vi.fn(async () => []),
|
||||
registerPluginHttpRoute: mockState.registerPluginHttpRoute,
|
||||
resolveChannelMediaMaxBytes: vi.fn(() => 8 * 1024 * 1024),
|
||||
warnMissingProviderGroupPolicyFallbackOnce: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
function createRuntimeCore(cfg: OpenClawConfig) {
|
||||
return {
|
||||
config: {
|
||||
current: () => cfg,
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
getChildLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
media: {
|
||||
mediaKindFromMime: () => "document",
|
||||
},
|
||||
system: {
|
||||
enqueueSystemEvent: mockState.enqueueSystemEvent,
|
||||
},
|
||||
channel: {
|
||||
activity: {
|
||||
record: vi.fn(),
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: () => false,
|
||||
},
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: <T>(params: {
|
||||
onFlush: (entries: T[]) => Promise<void> | void;
|
||||
}) => ({
|
||||
enqueue: async (entry: T) => {
|
||||
await params.onFlush([entry]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => false,
|
||||
},
|
||||
media: {
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: () => [],
|
||||
matchesMentionPatterns: () => false,
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: () => "pairing required",
|
||||
},
|
||||
reply: {
|
||||
createReplyDispatcherWithTyping: vi.fn(() => ({
|
||||
dispatcher: {},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
})),
|
||||
dispatchReplyFromConfig: mockState.dispatchReplyFromConfig,
|
||||
finalizeInboundContext: (context: unknown) => context,
|
||||
formatInboundEnvelope: (params: { channel: string; from: string; body: string }) =>
|
||||
`${params.channel} ${params.from}\n${params.body}`,
|
||||
resolveHumanDelayConfig: () => ({}),
|
||||
withReplyDispatcher: async (params: { run: () => unknown; onSettled?: () => void }) => {
|
||||
try {
|
||||
return await params.run();
|
||||
} finally {
|
||||
params.onSettled?.();
|
||||
}
|
||||
},
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: () => ({
|
||||
accountId: "default",
|
||||
agentId: "main",
|
||||
mainSessionKey: "mattermost:default:channel:chan-1",
|
||||
sessionKey: "mattermost:default:channel:chan-1",
|
||||
}),
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: () => "/tmp/openclaw-test-sessions.json",
|
||||
updateLastRoute: vi.fn(async () => {}),
|
||||
},
|
||||
text: {
|
||||
chunkMarkdownTextWithMode: (text: string) => [text],
|
||||
convertMarkdownTables: (text: string) => text,
|
||||
hasControlCommand: () => false,
|
||||
resolveChunkMode: () => "off",
|
||||
resolveMarkdownTableMode: () => "off",
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const testConfig: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
botToken: "bot-token",
|
||||
chatmode: "onmessage",
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
getMattermostRuntime: () => mockState.runtimeCore,
|
||||
}));
|
||||
|
||||
const testRuntime = (): RuntimeEnv =>
|
||||
({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
}) satisfies RuntimeEnv;
|
||||
|
||||
describe("mattermost inbound user posts", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.abortController = undefined;
|
||||
mockState.runtimeCore = createRuntimeCore(testConfig);
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostDraftStream.mockReturnValue({
|
||||
update: vi.fn(),
|
||||
stop: vi.fn(async () => {}),
|
||||
});
|
||||
mockState.fetchMattermostMe.mockResolvedValue({
|
||||
id: "bot-user",
|
||||
username: "openclaw",
|
||||
update_at: 1,
|
||||
});
|
||||
mockState.registerMattermostMonitorSlashCommands.mockResolvedValue(undefined);
|
||||
mockState.registerPluginHttpRoute.mockReturnValue(vi.fn());
|
||||
mockState.resolveChannelInfo.mockResolvedValue({
|
||||
id: "chan-1",
|
||||
name: "town-square",
|
||||
display_name: "Town Square",
|
||||
team_id: "team-1",
|
||||
type: "O",
|
||||
});
|
||||
mockState.resolveMattermostMedia.mockResolvedValue([]);
|
||||
mockState.resolveUserInfo.mockResolvedValue({ id: "user-1", username: "alice" });
|
||||
mockState.dispatchReplyFromConfig.mockImplementation(async () => {
|
||||
mockState.abortController?.abort();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enqueue regular user posts as system events", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const abortController = new AbortController();
|
||||
mockState.abortController = abortController;
|
||||
const { monitorMattermostProvider } = await import("./monitor.js");
|
||||
|
||||
const monitor = monitorMattermostProvider({
|
||||
config: testConfig,
|
||||
runtime: testRuntime(),
|
||||
abortSignal: abortController.signal,
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.openListenerCount).toBeGreaterThan(0);
|
||||
});
|
||||
socket.emitOpen();
|
||||
|
||||
await socket.emitMessage({
|
||||
event: "posted",
|
||||
data: {
|
||||
channel_id: "chan-1",
|
||||
channel_name: "town-square",
|
||||
channel_display_name: "Town Square",
|
||||
sender_name: "alice",
|
||||
post: JSON.stringify({
|
||||
id: "post-1",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
message: "hello from mattermost",
|
||||
create_at: 1_714_000_000_000,
|
||||
}),
|
||||
},
|
||||
broadcast: {
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
},
|
||||
});
|
||||
socket.emitClose(1000);
|
||||
await monitor;
|
||||
|
||||
expect(mockState.enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(mockState.dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.dispatchReplyFromConfig.mock.calls[0]?.[0].ctx).toMatchObject({
|
||||
BodyForAgent: "hello from mattermost",
|
||||
ConversationLabel: "Town Square id:chan-1",
|
||||
MessageSid: "post-1",
|
||||
OriginatingChannel: "mattermost",
|
||||
Provider: "mattermost",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1488,16 +1488,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
directId: senderId,
|
||||
});
|
||||
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel =
|
||||
kind === "direct"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
sessionKey,
|
||||
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
|
||||
const body = core.channel.reply.formatInboundEnvelope({
|
||||
channel: "Mattermost",
|
||||
|
||||
@@ -306,6 +306,5 @@ export function registerSlashCommandRoute(api: OpenClawPluginApi) {
|
||||
auth: "plugin",
|
||||
handler: routeHandler,
|
||||
});
|
||||
api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MemoryIndexMeta = {
|
||||
export function resolveConfiguredSourcesForMeta(sources: Iterable<MemorySource>): MemorySource[] {
|
||||
const normalized = Array.from(sources)
|
||||
.filter((source): source is MemorySource => source === "memory" || source === "sessions")
|
||||
.toSorted();
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
return normalized.length > 0 ? normalized : ["memory"];
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function normalizeMetaSources(meta: MemoryIndexMeta): MemorySource[] {
|
||||
(source): source is MemorySource => source === "memory" || source === "sessions",
|
||||
),
|
||||
),
|
||||
).toSorted();
|
||||
).toSorted((left, right) => left.localeCompare(right));
|
||||
return normalized.length > 0 ? normalized : ["memory"];
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +202,43 @@ describe("memory plugin e2e", () => {
|
||||
expect(config?.autoRecall).toBe(true);
|
||||
});
|
||||
|
||||
test("registers as disabled instead of throwing when inspected without config", async () => {
|
||||
const registerService = vi.fn();
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const mockApi = {
|
||||
id: "memory-lancedb",
|
||||
name: "Memory (LanceDB)",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {},
|
||||
runtime: {},
|
||||
logger,
|
||||
registerTool: vi.fn(),
|
||||
registerCli: vi.fn(),
|
||||
registerService,
|
||||
on: vi.fn(),
|
||||
resolvePath: (filePath: string) => filePath,
|
||||
};
|
||||
|
||||
expect(() => memoryPlugin.register(mockApi as any)).not.toThrow();
|
||||
expect(registerService).toHaveBeenCalledWith({
|
||||
id: "memory-lancedb",
|
||||
start: expect.any(Function),
|
||||
});
|
||||
expect(mockApi.registerTool).not.toHaveBeenCalled();
|
||||
expect(mockApi.on).not.toHaveBeenCalled();
|
||||
|
||||
registerService.mock.calls[0]?.[0].start({});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
"memory-lancedb: disabled until configured (embedding config required)",
|
||||
);
|
||||
});
|
||||
|
||||
test("registers auto-recall on before_prompt_build instead of the legacy hook", async () => {
|
||||
const on = vi.fn();
|
||||
const mockApi = {
|
||||
|
||||
@@ -503,7 +503,19 @@ export default definePluginEntry({
|
||||
configSchema: memoryConfigSchema,
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
||||
let cfg: MemoryConfig;
|
||||
try {
|
||||
cfg = memoryConfigSchema.parse(api.pluginConfig);
|
||||
} catch (error) {
|
||||
api.registerService({
|
||||
id: "memory-lancedb",
|
||||
start: () => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
api.logger.warn(`memory-lancedb: disabled until configured (${message})`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dbPath = cfg.dbPath!;
|
||||
const resolvedDbPath = dbPath.includes("://") ? dbPath : api.resolvePath(dbPath);
|
||||
const { model, dimensions } = cfg.embedding;
|
||||
|
||||
@@ -300,6 +300,97 @@ describe("syncMemoryWikiBridgeSources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to overwrite bridge source pages through vault symlinks", async () => {
|
||||
const workspaceDir = await createBridgeWorkspace("symlink-workspace");
|
||||
const { rootDir: vaultDir, config } = await createVault({
|
||||
rootDir: nextCaseRoot("symlink-vault"),
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: {
|
||||
enabled: true,
|
||||
readMemoryArtifacts: true,
|
||||
indexMemoryRoot: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
|
||||
registerBridgeArtifacts([
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir,
|
||||
relativePath: "MEMORY.md",
|
||||
absolutePath: memoryPath,
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
]);
|
||||
const appConfig: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
};
|
||||
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
|
||||
const pagePath = first.pagePaths[0] ?? "";
|
||||
const pageAbsPath = path.join(vaultDir, pagePath);
|
||||
const externalTarget = path.join(workspaceDir, "outside.md");
|
||||
await fs.writeFile(externalTarget, "external target\n", "utf8");
|
||||
await fs.rm(pageAbsPath);
|
||||
await fs.symlink(externalTarget, pageAbsPath);
|
||||
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
|
||||
|
||||
await expect(syncMemoryWikiBridgeSources({ config, appConfig })).rejects.toThrow(
|
||||
"Refusing to write imported source page through symlink",
|
||||
);
|
||||
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
|
||||
});
|
||||
|
||||
it("replaces bridge source page hardlinks without clobbering their target", async () => {
|
||||
const workspaceDir = await createBridgeWorkspace("hardlink-workspace");
|
||||
const { rootDir: vaultDir, config } = await createVault({
|
||||
rootDir: nextCaseRoot("hardlink-vault"),
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: {
|
||||
enabled: true,
|
||||
readMemoryArtifacts: true,
|
||||
indexMemoryRoot: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
await fs.writeFile(memoryPath, "# Durable Memory\n", "utf8");
|
||||
registerBridgeArtifacts([
|
||||
{
|
||||
kind: "memory-root",
|
||||
workspaceDir,
|
||||
relativePath: "MEMORY.md",
|
||||
absolutePath: memoryPath,
|
||||
agentIds: ["main"],
|
||||
contentType: "markdown",
|
||||
},
|
||||
]);
|
||||
const appConfig: OpenClawConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", default: true, workspace: workspaceDir }],
|
||||
},
|
||||
};
|
||||
const first = await syncMemoryWikiBridgeSources({ config, appConfig });
|
||||
const pagePath = first.pagePaths[0] ?? "";
|
||||
const pageAbsPath = path.join(vaultDir, pagePath);
|
||||
const externalTarget = path.join(workspaceDir, "outside-hardlink.md");
|
||||
await fs.writeFile(externalTarget, "external target\n", "utf8");
|
||||
await fs.rm(pageAbsPath);
|
||||
await fs.link(externalTarget, pageAbsPath);
|
||||
await fs.writeFile(memoryPath, "# Updated Durable Memory\n", "utf8");
|
||||
|
||||
const second = await syncMemoryWikiBridgeSources({ config, appConfig });
|
||||
|
||||
expect(second.updatedCount).toBe(1);
|
||||
await expect(fs.readFile(externalTarget, "utf8")).resolves.toBe("external target\n");
|
||||
await expect(fs.readFile(pageAbsPath, "utf8")).resolves.toContain("# Updated Durable Memory");
|
||||
});
|
||||
|
||||
it("caps composed bridge source filenames to the filesystem component limit", async () => {
|
||||
const workspaceDir = await createBridgeWorkspace(`${"漢".repeat(50)}-workspace`);
|
||||
const { rootDir: vaultDir, config } = await createVault({
|
||||
|
||||
@@ -3,11 +3,25 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerWikiCli, runWikiChatGptImport, runWikiChatGptRollback } from "./cli.js";
|
||||
import {
|
||||
registerWikiCli,
|
||||
runWikiBridgeImport,
|
||||
runWikiChatGptImport,
|
||||
runWikiChatGptRollback,
|
||||
runWikiDoctor,
|
||||
runWikiStatus,
|
||||
} from "./cli.js";
|
||||
import type { MemoryWikiPluginConfig } from "./config.js";
|
||||
import { parseWikiMarkdown, renderWikiMarkdown } from "./markdown.js";
|
||||
import type { MemoryWikiDoctorReport, MemoryWikiStatus } from "./status.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
|
||||
const callGatewayFromCliMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/gateway-runtime", () => ({
|
||||
callGatewayFromCli: callGatewayFromCliMock,
|
||||
}));
|
||||
|
||||
const { createVault } = createMemoryWikiTestHarness();
|
||||
let suiteRoot = "";
|
||||
let caseIndex = 0;
|
||||
@@ -24,6 +38,7 @@ describe("memory-wiki cli", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
callGatewayFromCliMock.mockReset();
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(
|
||||
(() => true) as typeof process.stdout.write,
|
||||
);
|
||||
@@ -88,6 +103,45 @@ describe("memory-wiki cli", () => {
|
||||
return exportDir;
|
||||
}
|
||||
|
||||
function createGatewayStatus(config: {
|
||||
vault: { path: string };
|
||||
bridge: MemoryWikiStatus["bridge"];
|
||||
}): MemoryWikiStatus {
|
||||
return {
|
||||
vaultMode: "bridge",
|
||||
renderMode: "native",
|
||||
vaultPath: config.vault.path,
|
||||
vaultExists: true,
|
||||
bridge: config.bridge,
|
||||
bridgePublicArtifactCount: 2,
|
||||
obsidianCli: {
|
||||
enabled: false,
|
||||
requested: false,
|
||||
available: false,
|
||||
command: null,
|
||||
},
|
||||
unsafeLocal: {
|
||||
allowPrivateMemoryCoreAccess: false,
|
||||
pathCount: 0,
|
||||
},
|
||||
pageCounts: {
|
||||
source: 0,
|
||||
entity: 0,
|
||||
concept: 0,
|
||||
synthesis: 0,
|
||||
report: 0,
|
||||
},
|
||||
sourceCounts: {
|
||||
native: 0,
|
||||
bridge: 0,
|
||||
bridgeEvents: 0,
|
||||
unsafeLocal: 0,
|
||||
other: 0,
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
it("registers apply synthesis and writes a synthesis page", async () => {
|
||||
const { rootDir, config } = await createCliVault();
|
||||
const program = new Command();
|
||||
@@ -193,6 +247,226 @@ cli note
|
||||
await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes active bridge status and doctor through the gateway", async () => {
|
||||
const { config } = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
const status = createGatewayStatus(config);
|
||||
const report: MemoryWikiDoctorReport = {
|
||||
healthy: false,
|
||||
warningCount: 1,
|
||||
status: {
|
||||
...status,
|
||||
warnings: [
|
||||
{
|
||||
code: "bridge-artifacts-missing",
|
||||
message: "No exported artifacts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
fixes: [
|
||||
{
|
||||
code: "bridge-artifacts-missing",
|
||||
message: "Create memory artifacts.",
|
||||
},
|
||||
],
|
||||
};
|
||||
callGatewayFromCliMock.mockResolvedValueOnce(status).mockResolvedValueOnce(report);
|
||||
|
||||
await expect(runWikiStatus({ config, json: true })).resolves.toBe(status);
|
||||
await expect(runWikiDoctor({ config, json: true })).resolves.toBe(report);
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"wiki.status",
|
||||
{ timeout: "30000" },
|
||||
undefined,
|
||||
{ progress: false },
|
||||
);
|
||||
expect(callGatewayFromCliMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"wiki.doctor",
|
||||
{ timeout: "30000" },
|
||||
undefined,
|
||||
{ progress: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes gateway status text output without changing JSON output", async () => {
|
||||
const { config } = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
const unsafeStatus = createGatewayStatus({
|
||||
...config,
|
||||
vault: { path: "\u001B[2J/tmp/wiki\nforged prompt\u202E" },
|
||||
});
|
||||
unsafeStatus.warnings = [
|
||||
{
|
||||
code: "bridge-artifacts-missing",
|
||||
message: "missing artifacts\r\nfake success\u001B[31m\u202E",
|
||||
},
|
||||
];
|
||||
const textOutput: string[] = [];
|
||||
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
|
||||
|
||||
await runWikiStatus({
|
||||
config,
|
||||
stdout: {
|
||||
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
});
|
||||
|
||||
const renderedText = textOutput.join("");
|
||||
expect(renderedText).not.toContain("\u001B");
|
||||
expect(renderedText).not.toContain("\u202E");
|
||||
expect(renderedText).toContain("(/tmp/wiki forged prompt)");
|
||||
expect(renderedText).toContain("- missing artifacts fake success");
|
||||
|
||||
const jsonOutput: string[] = [];
|
||||
callGatewayFromCliMock.mockResolvedValueOnce(unsafeStatus);
|
||||
|
||||
await runWikiStatus({
|
||||
config,
|
||||
json: true,
|
||||
stdout: {
|
||||
write: ((chunk: string) => jsonOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
});
|
||||
|
||||
const renderedJson = jsonOutput.join("");
|
||||
expect(renderedJson).not.toContain("\u001B");
|
||||
expect(renderedJson).not.toContain("\u202E");
|
||||
expect(renderedJson).not.toContain("\r");
|
||||
expect(renderedJson).toContain("\\u001b[2J/tmp/wiki\\nforged prompt\\u202e");
|
||||
expect(renderedJson).toContain("missing artifacts\\r\\nfake success\\u001b[31m\\u202e");
|
||||
|
||||
const parsed = JSON.parse(renderedJson) as MemoryWikiStatus;
|
||||
expect(parsed.vaultPath).toBe("\u001B[2J/tmp/wiki\nforged prompt\u202E");
|
||||
expect(parsed.warnings[0]?.message).toBe("missing artifacts\r\nfake success\u001B[31m\u202E");
|
||||
});
|
||||
|
||||
it("rejects malformed gateway responses before rendering", async () => {
|
||||
const { config } = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
callGatewayFromCliMock.mockResolvedValueOnce({ vaultMode: "bridge" });
|
||||
|
||||
await expect(runWikiStatus({ config })).rejects.toThrow(
|
||||
"Invalid Gateway response for wiki.status.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized gateway strings before rendering", async () => {
|
||||
const { config } = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
const status = createGatewayStatus(config);
|
||||
status.warnings = [
|
||||
{
|
||||
code: "bridge-artifacts-missing",
|
||||
message: "x".repeat(10_001),
|
||||
},
|
||||
];
|
||||
callGatewayFromCliMock.mockResolvedValueOnce(status);
|
||||
|
||||
await expect(runWikiStatus({ config })).rejects.toThrow(
|
||||
"Invalid Gateway response for wiki.status.",
|
||||
);
|
||||
});
|
||||
|
||||
it("truncates gateway status text output after rendering", async () => {
|
||||
const { config } = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
const status = createGatewayStatus(config);
|
||||
status.warnings = [
|
||||
{
|
||||
code: "bridge-artifacts-missing",
|
||||
message: `${"warning ".repeat(500)}tail`,
|
||||
},
|
||||
];
|
||||
const textOutput: string[] = [];
|
||||
callGatewayFromCliMock.mockResolvedValueOnce(status);
|
||||
|
||||
await runWikiStatus({
|
||||
config,
|
||||
stdout: {
|
||||
write: ((chunk: string) => textOutput.push(chunk) > 0) as NodeJS.WriteStream["write"],
|
||||
},
|
||||
});
|
||||
|
||||
const renderedText = textOutput.join("");
|
||||
expect(renderedText).toContain("... [truncated]");
|
||||
expect(renderedText).not.toContain("tail");
|
||||
});
|
||||
|
||||
it("routes active bridge imports through the gateway and keeps disabled bridge imports local", async () => {
|
||||
const active = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: true, readMemoryArtifacts: true },
|
||||
},
|
||||
initialize: true,
|
||||
});
|
||||
callGatewayFromCliMock.mockResolvedValueOnce({
|
||||
importedCount: 1,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
removedCount: 0,
|
||||
artifactCount: 1,
|
||||
workspaces: 1,
|
||||
pagePaths: ["sources/bridge-alpha.md"],
|
||||
indexesRefreshed: true,
|
||||
indexUpdatedFiles: ["index.md"],
|
||||
indexRefreshReason: "import-changed",
|
||||
});
|
||||
|
||||
const activeResult = await runWikiBridgeImport({ config: active.config, json: true });
|
||||
|
||||
expect(activeResult.importedCount).toBe(1);
|
||||
expect(callGatewayFromCliMock).toHaveBeenCalledWith(
|
||||
"wiki.bridge.import",
|
||||
{ timeout: "30000" },
|
||||
undefined,
|
||||
{ progress: false },
|
||||
);
|
||||
|
||||
callGatewayFromCliMock.mockClear();
|
||||
const disabled = await createCliVault({
|
||||
config: {
|
||||
vaultMode: "bridge",
|
||||
bridge: { enabled: false },
|
||||
},
|
||||
});
|
||||
|
||||
const disabledResult = await runWikiBridgeImport({ config: disabled.config, json: true });
|
||||
|
||||
expect(disabledResult.artifactCount).toBe(0);
|
||||
expect(callGatewayFromCliMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports ChatGPT exports with dry-run, apply, and rollback", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Command } from "commander";
|
||||
import { callGatewayFromCli } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { applyMemoryWikiMutation } from "./apply.js";
|
||||
import {
|
||||
@@ -27,14 +28,29 @@ import {
|
||||
} from "./obsidian.js";
|
||||
import { getMemoryWikiPage, searchMemoryWiki } from "./query.js";
|
||||
import { syncMemoryWikiImportedSources } from "./source-sync.js";
|
||||
import type { MemoryWikiImportedSourceSyncResult } from "./source-sync.js";
|
||||
import {
|
||||
buildMemoryWikiDoctorReport,
|
||||
renderMemoryWikiDoctor,
|
||||
renderMemoryWikiStatus,
|
||||
type MemoryWikiDoctorReport,
|
||||
type MemoryWikiStatus,
|
||||
resolveMemoryWikiStatus,
|
||||
} from "./status.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const WIKI_GATEWAY_TIMEOUT_MS = "30000";
|
||||
const GATEWAY_TERMINAL_STRING_MAX_CHARS = 2_000;
|
||||
const GATEWAY_RESPONSE_MAX_ARRAY_ITEMS = 10_000;
|
||||
const GATEWAY_RESPONSE_MAX_STRING_CHARS = 10_000;
|
||||
const GATEWAY_RESPONSE_MAX_CODE_CHARS = 256;
|
||||
const ANSI_ESCAPE_SEQUENCE_PATTERN = new RegExp(
|
||||
String.raw`(?:\x1B\[[0-?]*[ -/]*[@-~]|\x1B[@-Z\\-_]|\x9B[0-?]*[ -/]*[@-~])`,
|
||||
"g",
|
||||
);
|
||||
const TERMINAL_CONTROL_CHARACTER_PATTERN = new RegExp(String.raw`[\x00-\x1F\x7F-\x9F]+`, "g");
|
||||
const UNICODE_FORMAT_CONTROL_PATTERN = /[\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g;
|
||||
|
||||
type WikiStatusCommandOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
@@ -143,10 +159,173 @@ function isResolvedMemoryWikiConfig(
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeGatewayStringForTerminal(value: string): string {
|
||||
const truncated =
|
||||
value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
|
||||
? value.slice(0, GATEWAY_TERMINAL_STRING_MAX_CHARS)
|
||||
: value;
|
||||
const sanitized = truncated
|
||||
.replace(ANSI_ESCAPE_SEQUENCE_PATTERN, "")
|
||||
.replace(TERMINAL_CONTROL_CHARACTER_PATTERN, " ")
|
||||
.replace(UNICODE_FORMAT_CONTROL_PATTERN, "");
|
||||
return value.length > GATEWAY_TERMINAL_STRING_MAX_CHARS
|
||||
? `${sanitized}... [truncated]`
|
||||
: sanitized;
|
||||
}
|
||||
|
||||
function escapeGatewayJsonForTerminal(json: string): string {
|
||||
return json.replace(UNICODE_FORMAT_CONTROL_PATTERN, (char) => {
|
||||
const codePoint = char.codePointAt(0);
|
||||
return typeof codePoint === "number" ? `\\u${codePoint.toString(16).padStart(4, "0")}` : "";
|
||||
});
|
||||
}
|
||||
|
||||
function writeOutput(output: string, writer: Pick<NodeJS.WriteStream, "write"> = process.stdout) {
|
||||
writer.write(output.endsWith("\n") ? output : `${output}\n`);
|
||||
}
|
||||
|
||||
function shouldRouteBridgeRuntimeThroughGateway(config: ResolvedMemoryWikiConfig): boolean {
|
||||
return (
|
||||
config.vaultMode === "bridge" && config.bridge.enabled && config.bridge.readMemoryArtifacts
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isBoundedGatewayString(
|
||||
value: unknown,
|
||||
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
|
||||
): value is string {
|
||||
return typeof value === "string" && value.length <= maxChars;
|
||||
}
|
||||
|
||||
function isStringArray(
|
||||
value: unknown,
|
||||
maxChars = GATEWAY_RESPONSE_MAX_STRING_CHARS,
|
||||
): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
|
||||
value.every((item) => isBoundedGatewayString(item, maxChars))
|
||||
);
|
||||
}
|
||||
|
||||
function hasNumberFields(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
return keys.every((key) => typeof value[key] === "number");
|
||||
}
|
||||
|
||||
function isWarningList(value: unknown): boolean {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
|
||||
value.every(
|
||||
(item) =>
|
||||
isRecord(item) &&
|
||||
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
|
||||
isBoundedGatewayString(item.message),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
const bridge = value.bridge;
|
||||
const obsidianCli = value.obsidianCli;
|
||||
const unsafeLocal = value.unsafeLocal;
|
||||
const pageCounts = value.pageCounts;
|
||||
const sourceCounts = value.sourceCounts;
|
||||
return (
|
||||
isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
|
||||
isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
|
||||
isBoundedGatewayString(value.vaultPath) &&
|
||||
typeof value.vaultExists === "boolean" &&
|
||||
(typeof value.bridgePublicArtifactCount === "number" ||
|
||||
value.bridgePublicArtifactCount === null) &&
|
||||
isRecord(bridge) &&
|
||||
typeof bridge.enabled === "boolean" &&
|
||||
isRecord(obsidianCli) &&
|
||||
typeof obsidianCli.enabled === "boolean" &&
|
||||
typeof obsidianCli.requested === "boolean" &&
|
||||
typeof obsidianCli.available === "boolean" &&
|
||||
(isBoundedGatewayString(obsidianCli.command) || obsidianCli.command === null) &&
|
||||
isRecord(unsafeLocal) &&
|
||||
typeof unsafeLocal.allowPrivateMemoryCoreAccess === "boolean" &&
|
||||
typeof unsafeLocal.pathCount === "number" &&
|
||||
isRecord(pageCounts) &&
|
||||
hasNumberFields(pageCounts, ["source", "entity", "concept", "synthesis", "report"]) &&
|
||||
isRecord(sourceCounts) &&
|
||||
hasNumberFields(sourceCounts, ["native", "bridge", "bridgeEvents", "unsafeLocal", "other"]) &&
|
||||
isWarningList(value.warnings)
|
||||
);
|
||||
}
|
||||
|
||||
function isMemoryWikiDoctorReport(value: unknown): value is MemoryWikiDoctorReport {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.healthy === "boolean" &&
|
||||
typeof value.warningCount === "number" &&
|
||||
isMemoryWikiStatus(value.status) &&
|
||||
Array.isArray(value.fixes) &&
|
||||
value.fixes.length <= GATEWAY_RESPONSE_MAX_ARRAY_ITEMS &&
|
||||
value.fixes.every(
|
||||
(item) =>
|
||||
isRecord(item) &&
|
||||
isBoundedGatewayString(item.code, GATEWAY_RESPONSE_MAX_CODE_CHARS) &&
|
||||
isBoundedGatewayString(item.message),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isMemoryWikiImportResult(value: unknown): value is MemoryWikiImportedSourceSyncResult {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
hasNumberFields(value, [
|
||||
"importedCount",
|
||||
"updatedCount",
|
||||
"skippedCount",
|
||||
"removedCount",
|
||||
"artifactCount",
|
||||
"workspaces",
|
||||
]) &&
|
||||
isStringArray(value.pagePaths) &&
|
||||
typeof value.indexesRefreshed === "boolean" &&
|
||||
isStringArray(value.indexUpdatedFiles) &&
|
||||
isBoundedGatewayString(value.indexRefreshReason, GATEWAY_RESPONSE_MAX_CODE_CHARS)
|
||||
);
|
||||
}
|
||||
|
||||
function validateWikiGatewayResult(
|
||||
method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import",
|
||||
value: unknown,
|
||||
): MemoryWikiStatus | MemoryWikiDoctorReport | MemoryWikiImportedSourceSyncResult {
|
||||
if (method === "wiki.status" && isMemoryWikiStatus(value)) {
|
||||
return value;
|
||||
}
|
||||
if (method === "wiki.doctor" && isMemoryWikiDoctorReport(value)) {
|
||||
return value;
|
||||
}
|
||||
if (method === "wiki.bridge.import" && isMemoryWikiImportResult(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid Gateway response for ${method}.`);
|
||||
}
|
||||
|
||||
async function callWikiGateway(method: "wiki.status"): Promise<MemoryWikiStatus>;
|
||||
async function callWikiGateway(method: "wiki.doctor"): Promise<MemoryWikiDoctorReport>;
|
||||
async function callWikiGateway(
|
||||
method: "wiki.bridge.import",
|
||||
): Promise<MemoryWikiImportedSourceSyncResult>;
|
||||
async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") {
|
||||
const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, {
|
||||
progress: false,
|
||||
});
|
||||
return validateWikiGatewayResult(method, result);
|
||||
}
|
||||
|
||||
function normalizeCliStringList(values?: string[]): string[] | undefined {
|
||||
if (!values) {
|
||||
return undefined;
|
||||
@@ -201,6 +380,16 @@ function formatJsonOrText<T>(
|
||||
return json ? JSON.stringify(result, null, 2) : render(result);
|
||||
}
|
||||
|
||||
function formatGatewayJsonOrText<T>(
|
||||
result: T,
|
||||
json: boolean | undefined,
|
||||
render: (result: T) => string,
|
||||
): string {
|
||||
return json
|
||||
? escapeGatewayJsonForTerminal(JSON.stringify(result, null, 2))
|
||||
: sanitizeGatewayStringForTerminal(render(result));
|
||||
}
|
||||
|
||||
async function runWikiCommandWithSummary<T>(params: {
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
@@ -255,12 +444,19 @@ export async function runWikiStatus(params: {
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
const status = await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
});
|
||||
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
|
||||
const status = routeThroughGateway
|
||||
? await callWikiGateway("wiki.status")
|
||||
: await (async () => {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
return await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
});
|
||||
})();
|
||||
writeOutput(
|
||||
params.json ? JSON.stringify(status, null, 2) : renderMemoryWikiStatus(status),
|
||||
routeThroughGateway
|
||||
? formatGatewayJsonOrText(status, params.json, renderMemoryWikiStatus)
|
||||
: formatJsonOrText(status, params.json, renderMemoryWikiStatus),
|
||||
params.stdout,
|
||||
);
|
||||
return status;
|
||||
@@ -272,17 +468,24 @@ export async function runWikiDoctor(params: {
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
const report = buildMemoryWikiDoctorReport(
|
||||
await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
}),
|
||||
);
|
||||
const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config);
|
||||
const report = routeThroughGateway
|
||||
? await callWikiGateway("wiki.doctor")
|
||||
: await (async () => {
|
||||
await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig });
|
||||
return buildMemoryWikiDoctorReport(
|
||||
await resolveMemoryWikiStatus(params.config, {
|
||||
appConfig: params.appConfig,
|
||||
}),
|
||||
);
|
||||
})();
|
||||
if (!report.healthy) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
writeOutput(
|
||||
params.json ? JSON.stringify(report, null, 2) : renderMemoryWikiDoctor(report),
|
||||
routeThroughGateway
|
||||
? formatGatewayJsonOrText(report, params.json, renderMemoryWikiDoctor)
|
||||
: formatJsonOrText(report, params.json, renderMemoryWikiDoctor),
|
||||
params.stdout,
|
||||
);
|
||||
return report;
|
||||
@@ -505,6 +708,13 @@ export async function runWikiBridgeImport(params: {
|
||||
json?: boolean;
|
||||
stdout?: Pick<NodeJS.WriteStream, "write">;
|
||||
}) {
|
||||
const render = (value: MemoryWikiImportedSourceSyncResult) =>
|
||||
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`;
|
||||
if (shouldRouteBridgeRuntimeThroughGateway(params.config)) {
|
||||
const result = await callWikiGateway("wiki.bridge.import");
|
||||
writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout);
|
||||
return result;
|
||||
}
|
||||
return runWikiCommandWithSummary({
|
||||
json: params.json,
|
||||
stdout: params.stdout,
|
||||
@@ -513,8 +723,7 @@ export async function runWikiBridgeImport(params: {
|
||||
config: params.config,
|
||||
appConfig: params.appConfig,
|
||||
}),
|
||||
render: (value) =>
|
||||
`Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`,
|
||||
render,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pathExists } from "./source-path-shared.js";
|
||||
import {
|
||||
setImportedSourceEntry,
|
||||
shouldSkipImportedSourceWrite,
|
||||
@@ -8,6 +9,123 @@ import {
|
||||
} from "./source-sync-state.js";
|
||||
|
||||
type ImportedSourceState = Parameters<typeof shouldSkipImportedSourceWrite>[0]["state"];
|
||||
type FileStats = Awaited<ReturnType<typeof fs.lstat>>;
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
async function resolveWritableVaultPagePath(params: {
|
||||
vaultRoot: string;
|
||||
pagePath: string;
|
||||
}): Promise<{
|
||||
pageAbsPath: string;
|
||||
pageDir: string;
|
||||
pageDirRealPath: string;
|
||||
vaultRealPath: string;
|
||||
existing: FileStats | null;
|
||||
}> {
|
||||
const vaultAbsPath = path.resolve(params.vaultRoot);
|
||||
const pageAbsPath = path.resolve(vaultAbsPath, params.pagePath);
|
||||
if (!isPathInside(vaultAbsPath, pageAbsPath)) {
|
||||
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
|
||||
}
|
||||
|
||||
const vaultRealPath = await fs.realpath(vaultAbsPath);
|
||||
const pageDir = path.dirname(pageAbsPath);
|
||||
await fs.mkdir(pageDir, { recursive: true });
|
||||
const pageDirRealPath = await fs.realpath(pageDir);
|
||||
if (!isPathInside(vaultRealPath, pageDirRealPath)) {
|
||||
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
|
||||
}
|
||||
|
||||
const existing = await fs.lstat(pageAbsPath).catch((err: unknown) => {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to write imported source page through symlink: ${params.pagePath}`);
|
||||
}
|
||||
if (existing && !existing.isFile()) {
|
||||
throw new Error(`Refusing to write imported source page over non-file: ${params.pagePath}`);
|
||||
}
|
||||
return { pageAbsPath, pageDir, pageDirRealPath, vaultRealPath, existing };
|
||||
}
|
||||
|
||||
async function assertWritablePageDir(params: {
|
||||
pageDir: string;
|
||||
pageDirRealPath: string;
|
||||
vaultRealPath: string;
|
||||
pagePath: string;
|
||||
}): Promise<void> {
|
||||
const currentPageDirRealPath = await fs.realpath(params.pageDir);
|
||||
if (
|
||||
currentPageDirRealPath !== params.pageDirRealPath ||
|
||||
!isPathInside(params.vaultRealPath, currentPageDirRealPath)
|
||||
) {
|
||||
throw new Error(`Refusing to write imported source page outside vault: ${params.pagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDestinationForReplace(filePath: string, pagePath: string): Promise<void> {
|
||||
const existing = await fs.lstat(filePath).catch((err: unknown) => {
|
||||
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (existing?.isSymbolicLink()) {
|
||||
throw new Error(`Refusing to write imported source page through symlink: ${pagePath}`);
|
||||
}
|
||||
if (existing && !existing.isFile()) {
|
||||
throw new Error(`Refusing to write imported source page over non-file: ${pagePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeFileAtomicInVault(params: {
|
||||
filePath: string;
|
||||
pageDir: string;
|
||||
pageDirRealPath: string;
|
||||
vaultRealPath: string;
|
||||
pagePath: string;
|
||||
content: string;
|
||||
}): Promise<void> {
|
||||
const noFollow = fsConstants.O_NOFOLLOW ?? 0;
|
||||
await assertWritablePageDir(params);
|
||||
|
||||
const tempPath = path.join(params.pageDir, `.openclaw-wiki-${process.pid}-${randomUUID()}.tmp`);
|
||||
let shouldRemoveTemp = true;
|
||||
try {
|
||||
const handle = await fs.open(
|
||||
tempPath,
|
||||
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow,
|
||||
0o600,
|
||||
);
|
||||
try {
|
||||
const tempStat = await handle.stat();
|
||||
if (!tempStat.isFile() || tempStat.nlink !== 1) {
|
||||
throw new Error(
|
||||
`Refusing to write imported source page through unsafe temp file: ${params.pagePath}`,
|
||||
);
|
||||
}
|
||||
await handle.writeFile(params.content, "utf8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await assertWritablePageDir(params);
|
||||
await validateDestinationForReplace(params.filePath, params.pagePath);
|
||||
await fs.rename(tempPath, params.filePath);
|
||||
shouldRemoveTemp = false;
|
||||
await assertWritablePageDir(params);
|
||||
} finally {
|
||||
if (shouldRemoveTemp) {
|
||||
await fs.rm(tempPath, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeImportedSourcePage(params: {
|
||||
vaultRoot: string;
|
||||
@@ -21,8 +139,17 @@ export async function writeImportedSourcePage(params: {
|
||||
state: ImportedSourceState;
|
||||
buildRendered: (raw: string, updatedAt: string) => string;
|
||||
}): Promise<{ pagePath: string; changed: boolean; created: boolean }> {
|
||||
const pageAbsPath = path.join(params.vaultRoot, params.pagePath);
|
||||
const created = !(await pathExists(pageAbsPath));
|
||||
const {
|
||||
pageAbsPath,
|
||||
pageDir,
|
||||
pageDirRealPath,
|
||||
vaultRealPath,
|
||||
existing: pageStat,
|
||||
} = await resolveWritableVaultPagePath({
|
||||
vaultRoot: params.vaultRoot,
|
||||
pagePath: params.pagePath,
|
||||
});
|
||||
const created = !pageStat;
|
||||
const updatedAt = new Date(params.sourceUpdatedAtMs).toISOString();
|
||||
const shouldSkip = await shouldSkipImportedSourceWrite({
|
||||
vaultRoot: params.vaultRoot,
|
||||
@@ -40,9 +167,16 @@ export async function writeImportedSourcePage(params: {
|
||||
|
||||
const raw = await fs.readFile(params.sourcePath, "utf8");
|
||||
const rendered = params.buildRendered(raw, updatedAt);
|
||||
const existing = await fs.readFile(pageAbsPath, "utf8").catch(() => "");
|
||||
const existing = pageStat ? await fs.readFile(pageAbsPath, "utf8").catch(() => "") : "";
|
||||
if (existing !== rendered) {
|
||||
await fs.writeFile(pageAbsPath, rendered, "utf8");
|
||||
await writeFileAtomicInVault({
|
||||
filePath: pageAbsPath,
|
||||
pageDir,
|
||||
pageDirRealPath,
|
||||
vaultRealPath,
|
||||
pagePath: params.pagePath,
|
||||
content: rendered,
|
||||
});
|
||||
}
|
||||
|
||||
setImportedSourceEntry({
|
||||
|
||||
1
extensions/nextcloud-talk/doctor-contract-api.ts
Normal file
1
extensions/nextcloud-talk/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
@@ -212,6 +212,55 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not overwrite configured native Ollama params.thinking with implicit off", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
|
||||
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
|
||||
],
|
||||
async (fetchMock) => {
|
||||
const baseStreamFn = createOllamaStreamFn("http://ollama-host:11434");
|
||||
const model = {
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3:32b",
|
||||
contextWindow: 131072,
|
||||
params: { thinking: "medium" },
|
||||
};
|
||||
|
||||
const wrapped = createConfiguredOllamaCompatStreamWrapper({
|
||||
provider: "ollama",
|
||||
modelId: "qwen3:32b",
|
||||
model,
|
||||
streamFn: baseStreamFn,
|
||||
thinkingLevel: "off",
|
||||
} as never);
|
||||
if (!wrapped) {
|
||||
throw new Error("Expected wrapped Ollama stream function");
|
||||
}
|
||||
|
||||
const stream = await Promise.resolve(
|
||||
wrapped(
|
||||
model as never,
|
||||
{
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
} as never,
|
||||
{} as never,
|
||||
),
|
||||
);
|
||||
|
||||
await collectStreamEvents(stream);
|
||||
|
||||
const requestInit = getGuardedFetchCall(fetchMock).init ?? {};
|
||||
if (typeof requestInit.body !== "string") {
|
||||
throw new Error("Expected string request body");
|
||||
}
|
||||
const requestBody = JSON.parse(requestInit.body) as { think?: string };
|
||||
expect(requestBody.think).toBe("medium");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the native think effort on native Ollama chat requests when thinking is enabled", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
@@ -1248,6 +1297,69 @@ describe("createOllamaStreamFn streaming events", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("emits an error instead of accepting garbled Kimi visible text", async () => {
|
||||
const garbled =
|
||||
'$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' +
|
||||
'#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""';
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
JSON.stringify({
|
||||
model: "kimi-k2.5:cloud",
|
||||
created_at: "t",
|
||||
message: { role: "assistant", content: garbled },
|
||||
done: false,
|
||||
}),
|
||||
'{"model":"kimi-k2.5:cloud","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}',
|
||||
],
|
||||
async () => {
|
||||
const stream = await createOllamaTestStream({
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
model: { id: "kimi-k2.5:cloud", provider: "ollama" },
|
||||
});
|
||||
const events = await collectStreamEvents(stream);
|
||||
|
||||
const types = events.map((e) => e.type);
|
||||
expect(types).toEqual(["start", "text_start", "text_delta", "error"]);
|
||||
const errorEvent = events.at(-1);
|
||||
expect(errorEvent).toMatchObject({
|
||||
type: "error",
|
||||
error: expect.objectContaining({
|
||||
errorMessage: expect.stringContaining("garbled visible text"),
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reject punctuation-heavy text from unrelated Ollama models", async () => {
|
||||
const punctuationHeavy =
|
||||
'$$"##"%#"##"####""$""""##""$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$$' +
|
||||
'#"$"$"""$""""#$"""$"""%"%###"""#%""""&"#"""$"""#"#""""%#""""&"#"""$"""$"""#%"""';
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
JSON.stringify({
|
||||
model: "qwen3:32b",
|
||||
created_at: "t",
|
||||
message: { role: "assistant", content: punctuationHeavy },
|
||||
done: false,
|
||||
}),
|
||||
'{"model":"qwen3:32b","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":20,"eval_count":40}',
|
||||
],
|
||||
async () => {
|
||||
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
|
||||
const events = await collectStreamEvents(stream);
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual([
|
||||
"start",
|
||||
"text_start",
|
||||
"text_delta",
|
||||
"text_end",
|
||||
"done",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a single text_delta for single-chunk responses", async () => {
|
||||
await withMockNdjsonFetch(
|
||||
[
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user