mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 02:11:53 +08:00
Compare commits
158 Commits
fix-codex-
...
codex/gate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f82f46085 | ||
|
|
82fd88dbf4 | ||
|
|
d25bece9f6 | ||
|
|
724868cec8 | ||
|
|
6b6538bd13 | ||
|
|
dbabfc550f | ||
|
|
c822824503 | ||
|
|
fe97f1fa4f | ||
|
|
b9dc6d86b8 | ||
|
|
13c2e245aa | ||
|
|
f3f6a866ca | ||
|
|
6ae9c8bead | ||
|
|
8ba7927f6e | ||
|
|
84ec355af8 | ||
|
|
23ed804657 | ||
|
|
23cfc81bcd | ||
|
|
92524fcf98 | ||
|
|
a47132734b | ||
|
|
b405c6e640 | ||
|
|
c70adb8528 | ||
|
|
6b3998aa40 | ||
|
|
365c986a5b | ||
|
|
ac5674b32c | ||
|
|
731c8843ff | ||
|
|
554dfbd017 | ||
|
|
4feb4e6623 | ||
|
|
266722500c | ||
|
|
4e76d6e427 | ||
|
|
ee9d471865 | ||
|
|
d5abbd29cc | ||
|
|
a5c1956ca1 | ||
|
|
2268ce3a14 | ||
|
|
e28d66d531 | ||
|
|
41aee75cd1 | ||
|
|
04605f1670 | ||
|
|
a0f35574d0 | ||
|
|
66b98b7294 | ||
|
|
a582fc2d5c | ||
|
|
beea866a53 | ||
|
|
1d121c1f08 | ||
|
|
12b8db34ee | ||
|
|
fd244fd76d | ||
|
|
c35634c729 | ||
|
|
0668f1e003 | ||
|
|
94f3ecae9a | ||
|
|
641ad418c9 | ||
|
|
336ba2a2b3 | ||
|
|
8717525fbc | ||
|
|
2f2563314a | ||
|
|
b79c41d252 | ||
|
|
31a28eb5ba | ||
|
|
f71df80522 | ||
|
|
f6d0cc6cc3 | ||
|
|
db743e4918 | ||
|
|
462a056210 | ||
|
|
fe25ed214e | ||
|
|
3fa9658b39 | ||
|
|
c20224688c | ||
|
|
a012411d5e | ||
|
|
bcb9fa42be | ||
|
|
3bd47a95a8 | ||
|
|
e17f628a75 | ||
|
|
7edcfabf51 | ||
|
|
e7ae306aa1 | ||
|
|
04afd114bb | ||
|
|
c499ef1a6b | ||
|
|
f84d031d38 | ||
|
|
3048ad4731 | ||
|
|
ceb3092493 | ||
|
|
babd25c6b7 | ||
|
|
bfc798bd0b | ||
|
|
32f89760e3 | ||
|
|
11017c93cf | ||
|
|
643dea2455 | ||
|
|
6789fe248b | ||
|
|
10d2f41c83 | ||
|
|
b1fc55fded | ||
|
|
8962d35264 | ||
|
|
d41907a5cb | ||
|
|
b8dccbf310 | ||
|
|
949012797b | ||
|
|
1aef36b60d | ||
|
|
1e5641ba82 | ||
|
|
0916a19cb5 | ||
|
|
1d5f01500d | ||
|
|
625713091e | ||
|
|
d9e999cf4f | ||
|
|
81b239dc98 | ||
|
|
7f05ea60fa | ||
|
|
8ec9bfb31e | ||
|
|
4e1f59010e | ||
|
|
25eef1203a | ||
|
|
d656cda46d | ||
|
|
5479b6b32c | ||
|
|
36755e4057 | ||
|
|
6bd19bffd6 | ||
|
|
31de033590 | ||
|
|
e064cc98f0 | ||
|
|
0a42afae3a | ||
|
|
da1ccd3077 | ||
|
|
2ab08c8a19 | ||
|
|
c635f0087e | ||
|
|
3b7d01b63f | ||
|
|
8f612787a8 | ||
|
|
eefa6ecea0 | ||
|
|
9518d12e13 | ||
|
|
65ea6fdb49 | ||
|
|
af3d9333aa | ||
|
|
cd42df45d6 | ||
|
|
3485a907d1 | ||
|
|
6cac228a0c | ||
|
|
b73d01f13b | ||
|
|
bb8aa0cfe2 | ||
|
|
1fc92ddfb1 | ||
|
|
284dcc51b8 | ||
|
|
6cfb62d5aa | ||
|
|
bc0def52af | ||
|
|
ce63b9ca46 | ||
|
|
8da06d46f8 | ||
|
|
e44b915dbf | ||
|
|
df4aac8f96 | ||
|
|
c04bbd3cbb | ||
|
|
fe89243c3b | ||
|
|
6db2ee6583 | ||
|
|
ca7349b585 | ||
|
|
dd4c68b525 | ||
|
|
89987df3d5 | ||
|
|
b97b5d6f22 | ||
|
|
aa39107261 | ||
|
|
117d2c9f2e | ||
|
|
f5ebe63ecd | ||
|
|
2d231cef27 | ||
|
|
e4cee2eb69 | ||
|
|
5b418c3c4f | ||
|
|
c722ae6a65 | ||
|
|
5d4a8b0072 | ||
|
|
5496c0d5b7 | ||
|
|
1ee0d51e92 | ||
|
|
4935e24c7a | ||
|
|
aac216d699 | ||
|
|
3b8ac38ae9 | ||
|
|
78644bc6de | ||
|
|
3da9027770 | ||
|
|
256377c029 | ||
|
|
7c5222a195 | ||
|
|
78eb92e622 | ||
|
|
faa443a452 | ||
|
|
61ae9b7193 | ||
|
|
3fd64a281f | ||
|
|
c95ccf43c1 | ||
|
|
6a41a54212 | ||
|
|
983064f5f8 | ||
|
|
bce56bacc7 | ||
|
|
e774b25b2f | ||
|
|
3ce922437f | ||
|
|
97ed9b2d82 | ||
|
|
5923d9e807 | ||
|
|
a504cd0190 |
115
.agents/skills/codex-review/SKILL.md
Normal file
115
.agents/skills/codex-review/SKILL.md
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: codex-review
|
||||
description: "Codex code review closeout: local dirty changes, PR branch vs main, parallel tests."
|
||||
---
|
||||
|
||||
# Codex Review
|
||||
|
||||
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
|
||||
|
||||
Use when:
|
||||
- user asks for Codex review / autoreview / second-model review
|
||||
- after non-trivial code edits, before final/commit/ship
|
||||
- reviewing a local branch or PR branch after fixes
|
||||
|
||||
## Contract
|
||||
|
||||
- Treat review output as advisory. Never blindly apply it.
|
||||
- Verify every finding by reading the real code path and adjacent files.
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- Keep going until Codex review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun Codex review.
|
||||
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
|
||||
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
|
||||
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
|
||||
- Do not push just to review. Push only when the user requested push/ship/PR update.
|
||||
|
||||
## Pick Target
|
||||
|
||||
Dirty local work:
|
||||
|
||||
```bash
|
||||
codex review --uncommitted
|
||||
```
|
||||
|
||||
Use this only when the patch is actually unstaged/staged/untracked in the
|
||||
current checkout. For committed, pushed, or PR work, review the branch against
|
||||
its base instead; do not force `--mode local` / `--uncommitted` just because the
|
||||
helper docs mention dirty work first. A clean `--uncommitted` review only proves
|
||||
there is no local patch.
|
||||
|
||||
Branch/PR work:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
codex review --base origin/main
|
||||
```
|
||||
|
||||
Do not pass an inline prompt with `--base`; current CLI rejects `--base` + `[PROMPT]` even though help text is ambiguous. If custom instructions are needed, run the plain base review first, then do a local/manual follow-up pass.
|
||||
|
||||
If an open PR exists, use its actual base:
|
||||
|
||||
```bash
|
||||
base=$(gh pr view --json baseRefName --jq .baseRefName)
|
||||
codex review --base "origin/$base"
|
||||
```
|
||||
|
||||
Committed single change:
|
||||
|
||||
```bash
|
||||
codex review --commit HEAD
|
||||
```
|
||||
|
||||
## Parallel Closeout
|
||||
|
||||
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
|
||||
|
||||
```bash
|
||||
scripts/codex-review --parallel-tests "<focused test command>"
|
||||
```
|
||||
|
||||
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
|
||||
|
||||
## Context Efficiency
|
||||
|
||||
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
|
||||
- actionable findings it accepts
|
||||
- findings it rejects, with one-line reason
|
||||
- exact files/tests to rerun
|
||||
|
||||
Run inline only for tiny changes or when subagents are unavailable.
|
||||
|
||||
## Helper
|
||||
|
||||
Bundled helper:
|
||||
|
||||
```bash
|
||||
~/.codex/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
If installed from `agent-scripts`, path is:
|
||||
|
||||
```bash
|
||||
/Users/steipete/Projects/agent-scripts/skills/codex-review/scripts/codex-review --help
|
||||
```
|
||||
|
||||
The helper:
|
||||
- chooses dirty `--uncommitted` first
|
||||
- otherwise uses current PR base if `gh pr view` works
|
||||
- otherwise uses `origin/main` for non-main branches
|
||||
- should be left in `--mode auto` or forced to `--mode branch` for committed/PR work; do not force `--mode local` after committing
|
||||
- writes only to stdout unless `--output` or `CODEX_REVIEW_OUTPUT` is set
|
||||
- supports `--dry-run` and `--parallel-tests`
|
||||
- prints `codex-review clean: no accepted/actionable findings reported` when the selected review command exits 0
|
||||
|
||||
## Final Report
|
||||
|
||||
Include:
|
||||
- review command used
|
||||
- tests/proof run
|
||||
- findings accepted/rejected, briefly why
|
||||
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
|
||||
|
||||
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
|
||||
194
.agents/skills/codex-review/scripts/codex-review
Executable file
194
.agents/skills/codex-review/scripts/codex-review
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: codex-review [options]
|
||||
|
||||
Options:
|
||||
--mode auto|local|branch Target selection. Default: auto.
|
||||
--base REF Base ref for branch review. Default: PR base or origin/main.
|
||||
--codex-bin PATH Codex binary. Default: codex.
|
||||
--output FILE Also save output to file.
|
||||
--parallel-tests CMD Run review and test command concurrently.
|
||||
--dry-run Print selected commands, do not run.
|
||||
-h, --help Show help.
|
||||
|
||||
Modes:
|
||||
local codex review --uncommitted
|
||||
branch codex review --base <base>
|
||||
auto dirty tree -> local, else PR/current branch -> branch
|
||||
EOF
|
||||
}
|
||||
|
||||
mode=auto
|
||||
base_ref=
|
||||
codex_bin=${CODEX_BIN:-codex}
|
||||
output=${CODEX_REVIEW_OUTPUT:-}
|
||||
parallel_tests=
|
||||
dry_run=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
mode=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--base)
|
||||
base_ref=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--codex-bin)
|
||||
codex_bin=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
output=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--parallel-tests)
|
||||
parallel_tests=${2:-}
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
dry_run=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$mode" in
|
||||
auto|local|branch) ;;
|
||||
*)
|
||||
echo "invalid --mode: $mode" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
git rev-parse --show-toplevel >/dev/null
|
||||
|
||||
current_branch=$(git branch --show-current 2>/dev/null || true)
|
||||
dirty=false
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
dirty=true
|
||||
fi
|
||||
|
||||
pr_url=
|
||||
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
|
||||
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
|
||||
base_name=${pr_lines%%$'\t'*}
|
||||
pr_url=${pr_lines#*$'\t'}
|
||||
if [[ -n "$base_name" ]]; then
|
||||
base_ref="origin/$base_name"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$base_ref" ]]; then
|
||||
base_ref=origin/main
|
||||
fi
|
||||
|
||||
review_kind=
|
||||
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
|
||||
review_kind=local
|
||||
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
|
||||
review_kind=branch
|
||||
else
|
||||
echo "no review target: clean main checkout and no forced mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == local ]]; then
|
||||
review_cmd=("$codex_bin" review --uncommitted)
|
||||
else
|
||||
review_cmd=("$codex_bin" review --base "$base_ref")
|
||||
fi
|
||||
|
||||
printf 'codex-review target: %s\n' "$review_kind"
|
||||
printf 'branch: %s\n' "${current_branch:-detached}"
|
||||
if [[ -n "$pr_url" ]]; then
|
||||
printf 'pr: %s\n' "$pr_url"
|
||||
fi
|
||||
printf 'review:'
|
||||
printf ' %q' "${review_cmd[@]}"
|
||||
printf '\n'
|
||||
if [[ -n "$parallel_tests" ]]; then
|
||||
printf 'tests: %s\n' "$parallel_tests"
|
||||
fi
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
printf 'fetch: git fetch origin --quiet\n'
|
||||
fi
|
||||
if [[ -n "$output" ]]; then
|
||||
printf 'output: %s\n' "$output"
|
||||
fi
|
||||
|
||||
if [[ "$dry_run" == true ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$review_kind" == branch ]]; then
|
||||
git fetch origin --quiet || {
|
||||
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
|
||||
}
|
||||
fi
|
||||
|
||||
run_review() {
|
||||
if [[ -n "$output" ]]; then
|
||||
mkdir -p "$(dirname "$output")"
|
||||
"${review_cmd[@]}" 2>&1 | tee "$output"
|
||||
else
|
||||
"${review_cmd[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ -z "$parallel_tests" ]]; then
|
||||
run_review
|
||||
review_status=$?
|
||||
if [[ "$review_status" == 0 ]]; then
|
||||
printf 'codex-review clean: no accepted/actionable findings reported\n'
|
||||
fi
|
||||
exit "$review_status"
|
||||
fi
|
||||
|
||||
review_status_file=$(mktemp)
|
||||
tests_status_file=$(mktemp)
|
||||
|
||||
(
|
||||
set +e
|
||||
run_review
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$review_status_file"
|
||||
) &
|
||||
review_pid=$!
|
||||
|
||||
(
|
||||
set +e
|
||||
bash -lc "$parallel_tests"
|
||||
status=$?
|
||||
printf '%s\n' "$status" > "$tests_status_file"
|
||||
) &
|
||||
tests_pid=$!
|
||||
|
||||
wait "$review_pid" || true
|
||||
wait "$tests_pid" || true
|
||||
|
||||
review_status=$(cat "$review_status_file")
|
||||
tests_status=$(cat "$tests_status_file")
|
||||
rm -f "$review_status_file" "$tests_status_file"
|
||||
|
||||
printf 'codex-review exit: %s\n' "$review_status"
|
||||
printf 'tests exit: %s\n' "$tests_status"
|
||||
|
||||
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'codex-review clean: no accepted/actionable findings reported\n'
|
||||
@@ -19,9 +19,11 @@ or validating a change without wasting hours.
|
||||
Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
|
||||
1. Inspect the diff and classify the touched surface:
|
||||
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- tests only: `pnpm test:changed`
|
||||
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- normal source checkout, tests only: `pnpm test:changed`
|
||||
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
|
||||
- Codex worktree or linked/sparse checkout, changed gates or anything broad: `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"`
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
2. Reproduce narrowly before fixing.
|
||||
@@ -36,6 +38,12 @@ Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
|
||||
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
|
||||
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
|
||||
- In a Codex worktree or linked/sparse checkout, do not run direct local
|
||||
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
|
||||
you have verified pnpm will not reconcile or reinstall dependencies. Use
|
||||
`node scripts/run-vitest.mjs` for tiny local proof, `node
|
||||
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
|
||||
after the relevant remote or node-wrapper proof is already clean.
|
||||
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
|
||||
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
|
||||
reports, and cleans up one-shot boxes. Reuse only an id/slug created in this
|
||||
@@ -55,6 +63,14 @@ OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
|
||||
|
||||
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
|
||||
`pnpm test` wrapper so project routing, workers, and setup stay correct.
|
||||
When the checkout is a Codex worktree, prefer the direct node harness instead:
|
||||
|
||||
```bash
|
||||
node scripts/run-vitest.mjs <path-or-filter>
|
||||
```
|
||||
|
||||
That keeps the test scoped without giving pnpm a chance to run dependency
|
||||
status checks or install reconciliation in a linked worktree.
|
||||
|
||||
## Command Semantics
|
||||
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -1398,6 +1398,7 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:patches:check
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
|
||||
@@ -137,8 +137,10 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-6' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
|
||||
|
||||
|
||||
@@ -2154,27 +2154,11 @@ jobs:
|
||||
fi
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
|
||||
# Keep the release-blocking CI lane on Codex API-key auth. The
|
||||
# staged auth-file path remains supported for local maintainer
|
||||
# reruns, but it can hang on stale subscription/session state in
|
||||
# an otherwise healthy release run.
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
# Replace the staged config.toml with a minimal CI-safe config so
|
||||
# the repo stays trusted for MCP/tool use without inheriting
|
||||
# maintainer-local provider/profile overrides that do not exist
|
||||
# inside CI.
|
||||
# Codex's workspace-write sandbox relies on user namespaces that
|
||||
# this Docker lane does not provide, so run Codex unsandboxed
|
||||
# inside the already-isolated container to keep MCP cron/tool
|
||||
# execution representative instead of failing on nested sandbox
|
||||
# setup.
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-codex-harness-docker)
|
||||
# Keep CI on the API-key path for now. The staged Codex auth secret
|
||||
@@ -2395,14 +2379,11 @@ jobs:
|
||||
fi
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
|
||||
;;
|
||||
live-codex-harness-docker)
|
||||
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
|
||||
7
.github/workflows/openclaw-performance.yml
vendored
7
.github/workflows/openclaw-performance.yml
vendored
@@ -489,9 +489,7 @@ jobs:
|
||||
reports_root=".artifacts/clawgrit-reports"
|
||||
mkdir -p "$reports_root"
|
||||
git -C "$reports_root" init -b main
|
||||
git -C "$reports_root" remote add origin https://github.com/openclaw/clawgrit-reports.git
|
||||
auth_header="$(printf 'x-access-token:%s' "$CLAWGRIT_REPORTS_TOKEN" | base64 -w0)"
|
||||
git -C "$reports_root" config http.https://github.com/.extraheader "AUTHORIZATION: basic ${auth_header}"
|
||||
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
|
||||
if git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
|
||||
git -C "$reports_root" fetch --depth=1 origin main
|
||||
git -C "$reports_root" checkout -B main FETCH_HEAD
|
||||
@@ -501,10 +499,13 @@ jobs:
|
||||
|
||||
- name: Publish to clawgrit reports
|
||||
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
|
||||
env:
|
||||
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
reports_root=".artifacts/clawgrit-reports"
|
||||
git -C "$reports_root" remote set-url origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
|
||||
ref_slug="$(printf '%s' "${TESTED_REF}" | tr -c 'A-Za-z0-9._-' '-')"
|
||||
run_slug="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
dest="${reports_root}/openclaw-performance/${ref_slug}/${run_slug}/${LANE_ID}"
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -31,6 +31,9 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use public barrels, SDK facade, generic contracts.
|
||||
- Owner boundary: owner-specific repair/detection/onboarding/auth/defaults/provider behavior lives in owner plugin. Shared/core gets generic seams only.
|
||||
- Dependency ownership follows runtime ownership: plugin-only deps stay plugin-local; root deps only for core imports or intentionally internalized bundled plugin runtime.
|
||||
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
|
||||
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
|
||||
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
|
||||
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
|
||||
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
|
||||
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
|
||||
@@ -47,8 +50,10 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Package manager/runtime: repo defaults only. No swaps without approval.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Tests: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Checks: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
|
||||
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
|
||||
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
|
||||
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
|
||||
@@ -57,7 +62,8 @@ Skills own workflows; root owns hard policy and routing.
|
||||
## Validation
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
|
||||
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
|
||||
@@ -79,13 +85,14 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
|
||||
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
|
||||
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
|
||||
- After landing/ship final: include 2-5 sentence recap of what landed: behavior change, key files/surface, proof run, issue/PR state. Do not answer with only status/links.
|
||||
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
|
||||
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
|
||||
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
|
||||
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- Maintainers: ignore `Real behavior proof` failures that only say PR body lacks real after-fix evidence.
|
||||
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
163
CHANGELOG.md
163
CHANGELOG.md
@@ -6,15 +6,111 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway auth: add `gateway.auth.tokenScopes` for trusted headless shared-token WebSocket clients to receive static operator scopes such as `operator.read`, with privileged scopes bounded to loopback binds unless `gateway.auth.allowPrivilegedTokenScopes` is explicitly enabled. Fixes #80836 and #81775. Thanks @jchoi-atn and @vivekchand.
|
||||
- Control UI/i18n: add a `pnpm ui:i18n:report` baseline report for hardcoded-copy focus areas and locale fallback metadata. (#81320) Thanks @samzong.
|
||||
- Maintainer tooling: add a repo-local `codex-review` skill for Codex closeout reviews, including local dirty-work and PR-branch review helpers that rerun until no accepted/actionable findings remain and avoid unsupported inline prompts with `--base`.
|
||||
- Maintainer tooling: fail CI when pull requests add package patch files or pnpm patched dependencies, preserving the upstream-and-bump dependency workflow.
|
||||
- Amazon Bedrock: externalize the Bedrock and Bedrock Mantle provider packages so core installs no longer pull AWS SDK dependencies unless those providers are installed.
|
||||
- Plugins: externalize Slack, OpenShell sandbox, and Anthropic Vertex so their runtime dependency cones install only when those plugins are installed.
|
||||
- Codex migration: remove the bundled `codex-cli` backend and repair legacy `codex-cli/*` model refs to the Codex app-server route on `openai/*`.
|
||||
- Control UI/WebChat: add a persisted auto-scroll mode selector so users can keep the current near-bottom behavior, always follow streaming output, or turn automatic streaming scroll off and use the New messages button manually. Fixes #7648 and #81287. Thanks @BunsDev.
|
||||
- ACP: add `acp.fallbacks` so ACP turns can try configured backup runtime backends when the primary backend is unavailable before any output is emitted. (#69542) Thanks @kaseonedge.
|
||||
- Gateway/startup: add owner-level startup trace attribution for auth, plugin loading, lookup counts, and plugin sidecar services. (#81738) Thanks @samzong.
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/migrate: handle delayed Codex plugin marketplace responses so warnings, next-steps, and conflict states render with ⚠️ glyphs and post-install migration retries the marketplace fetch instead of silently skipping plugin items. (#81625) Thanks @sjf.
|
||||
- Channels/Weixin: bump the bundled `@tencent-weixin/openclaw-weixin` external entry to `2.4.3` (from `2.4.1`) so onboarding and `openclaw channels add` install the current Tencent Weixin (personal WeChat) plugin release. (#81730) Thanks @scotthuang.
|
||||
- CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths.
|
||||
- CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work.
|
||||
- Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.
|
||||
- Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-<slug>.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe.
|
||||
- macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev.
|
||||
- Telegram: ship the isolated polling worker at the root dist path used by the bundled worker loader, avoiding startup failures looking for `dist/telegram-ingress-worker.runtime.js`.
|
||||
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
|
||||
- Gateway/OpenAI-compatible HTTP: parse shared JSON endpoint paths without trusting malformed Host headers, avoiding 500s before `/v1/chat/completions`, `/v1/responses`, and `/v1/embeddings` request handling.
|
||||
- Telegram: keep Bot API polling alive during main event-loop stalls by moving ingress to an isolated worker with a durable local spool. Fixes #81132. (#81746) Thanks @joshavant.
|
||||
- Telegram: resolve plugin native commands with the active runtime config so commands like `/codex ...` stay on the native command path.
|
||||
- Telegram: preserve rendered HTML formatting through lazy cron announce delivery so Markdown links stay clickable instead of falling back to literal anchor tags. Fixes #81742. (#81758)
|
||||
- Voice-call webhooks: parse webhook and realtime upgrade paths without trusting malformed Host headers, avoiding 500s before provider signature checks or path rejection.
|
||||
- Media store: reject malformed redirect `Location` headers as media-download failures instead of letting URL parsing escape the async response callback.
|
||||
- ClickClack: skip malformed realtime websocket frames instead of stopping the channel monitor on a single bad JSON event.
|
||||
- Browser tool: treat malformed node proxy `payloadJSON` responses as browser proxy failures instead of leaking raw JSON parser errors.
|
||||
- Gateway HTTP: match models, session kill, and session history route paths without trusting malformed Host headers, avoiding pre-auth 500s on those endpoints.
|
||||
- Google Meet/Codex: report malformed node proxy `payloadJSON` responses with plugin-owned errors instead of leaking raw JSON parser failures.
|
||||
- Debug proxy: reject malformed relative-form proxy targets with a controlled 400 response instead of letting URL parsing escape the request handler.
|
||||
- File transfer: reject malformed inline `file_write` base64 before computing hashes or invoking paired nodes, avoiding Node's lenient base64 decoder.
|
||||
- QA channel: skip malformed inline inbound attachment base64 instead of staging silently corrupted media for agent turns.
|
||||
- Microsoft Teams: reject malformed inline HTML image base64 padding instead of decoding corrupted `data:` image attachments.
|
||||
- Voice-call realtime: ignore malformed provider media-frame base64 before forwarding audio into bridge and transcription paths.
|
||||
- QQBot: reject malformed stored cron payload base64 before JSON decoding structured reminder data.
|
||||
- Telnyx voice-call: use the raw `client_state` fallback when webhook state is malformed base64 instead of using silently corrupted decoded text.
|
||||
- Models config/auth: stop inferring provider env-var markers from broad `^[A-Z_][A-Z0-9_]*$` strings, and resolve config-backed provider `apiKey` values only through structured env SecretRefs (`secrets.providers[id]` / `secrets.defaults`), so unrelated env vars cannot accidentally become provider credentials. Thanks @sallyom.
|
||||
- Media fetch: skip allocating and buffering the response body for bodyless media responses (HEAD probes and 204-style empty bodies), avoiding wasted heap on streams that carry no payload. Thanks @shakkernerd.
|
||||
- CLI/onboarding: forward provider-specific auth flags (e.g. `--openai-api-key`) through the onboarding wizard so they reach provider auth methods via `ctx.opts`, letting `--openai-api-key "$OPENAI_API_KEY"` skip the redundant "use existing env var?" prompt in non-interactive harnesses. (#81669) Thanks @sjf.
|
||||
- CLI/migrate: drop trailing periods from Codex migrate item messages and `REASON_CODE_MESSAGES` strings so plan/result rows read as labels instead of sentence fragments. (#81705) Thanks @sjf.
|
||||
- Slack: treat malformed private-file redirect `Location` headers as unfollowable redirects instead of failing Slack media downloads.
|
||||
- Matrix: ignore malformed percent-encoding in optional location URI parameters instead of letting a bad `geo:` event abort inbound message handling.
|
||||
- Web search: auto-detect Brave through its legacy `tools.web.search.apiKey` compatibility fallback while keeping doctor migration to `plugins.entries.brave.config.webSearch.apiKey` as the canonical repair, so allowlisted isolated cron runs do not report `web_search` unavailable before migration. Fixes #81538. Thanks @atomicmonk.
|
||||
- Plugins: memoize repeated in-process plugin metadata snapshots and keep vanished managed-install residue from forcing full derived discovery, reducing gateway/status startup scans under large plugin sets. Fixes #81143 and #79806. (#81570) Thanks @Kaspre, @holgergruenhagen, @JanPlessow, and @mjamiv.
|
||||
- Plugins: discover provider plugins from `setup.providers[].envVars` credentials during provider discovery while keeping the deprecated `providerAuthEnvVars` fallback. (#81542) Thanks @JARVIS-Glasses.
|
||||
- Docs/Codex harness: clarify that per-agent `CODEX_HOME` isolates `~/.codex` while inherited `HOME` intentionally keeps `.agents` discovery and subprocess user-home state available.
|
||||
- CLI/plugins: keep bare plugin and parent-command help on the lightweight path, avoiding plugin registry discovery before rendering help.
|
||||
- Auth: reclaim dead-owner stale file locks before retrying locked writes, so crashed OAuth refreshes no longer wedge `auth-profiles.json` until manual cleanup.
|
||||
- CLI tables: preserve muted/color styling on wrapped continuation lines after multiline cells, keeping `openclaw plugins list` descriptions readable.
|
||||
- Process execution: collapse case-insensitive duplicate child environment keys on Windows so caller-provided overrides such as `PATH` cannot be shadowed by host `Path`.
|
||||
- Browser CLI: request the existing `operator.admin` gateway scope explicitly for browser control commands, avoiding unnecessary scope-upgrade approval loops. Fixes #81555. (#81716) Thanks @joshavant.
|
||||
- Web: honor explicitly configured global `web_search` providers during provider ownership resolution while keeping sandboxed `web_fetch` limited to bundled providers.
|
||||
- Agents: skip bootstrap file and hook preload work on completed `continuation-skip` turns when no workspace bootstrap is pending, reducing isolated-agent prep latency without changing first-turn bootstrap behavior. Fixes #81548. Thanks @delizaran-unpa.
|
||||
- Config: validate JSON dry-runs against plugin-owned channel schemas, so external channel fields are not rejected by stale bundled schemas. Fixes #77887. (#81504) Thanks @giodl73-repo.
|
||||
- iOS: restore first-use Contacts, Calendar, and Reminders permission prompts and add Privacy & Access status/actions in Settings. Thanks @BunsDev.
|
||||
- Canvas: return not found for malformed percent-encoded Canvas/A2UI/document asset paths and keep decoded parent traversal blocked before path normalization.
|
||||
- Telegram: allow trusted local Bot API media files whose filenames start with dots instead of falling back to remote download.
|
||||
- Agents/Codex app-server: remap injected context files under dot-dot-prefixed workspace directories when a run switches to an effective sandbox workspace.
|
||||
- Control UI/i18n: use the installed workspace pi runtime for locale refreshes, update the fallback package pin, and skip scheduled refreshes with invalid provider credentials instead of failing main.
|
||||
- CI/performance: authenticate the clawgrit report repository remote during both checkout and publish so performance report pushes do not fail after benchmarks complete.
|
||||
- Hooks: load workspace-relative legacy hook modules from dot-dot-prefixed directories without treating the filename prefix as parent traversal.
|
||||
- Plugins: preserve installed package metadata and persisted registry freshness checks for plugin package paths under dot-dot-prefixed directories.
|
||||
- Agents: allow dot-dot-prefixed filenames such as `..note.txt` through sandbox FS bridge, remote sandbox reads, and apply_patch summaries without mistaking the name for parent traversal.
|
||||
- Gateway/diagnostics: suppress cold-start liveness warnings during the startup grace window while still sampling liveness metrics. Fixes #79915. (#81699) Thanks @joshavant.
|
||||
- CLI/migrate: hide per-item source/plugin hints on non-conflicting Codex skill and plugin selection prompts, keeping the hint text reserved for rows that actually need attention. Thanks @sjf.
|
||||
- Codex harness: treat high-confidence app-server OAuth refresh invalidation as a terminal auth-profile failure, stopping repeated raw token-refresh errors without turning entitlement or usage-limit payloads into re-auth prompts.
|
||||
- CLI/migrate: humanize Codex conflict-status messaging across the migrate UI so selection prompts and plan/result rows say "Codex skill already installed in workspace" instead of surfacing internal `MIGRATION_REASON_*` codes. Thanks @sjf.
|
||||
- CLI/migrate: render migrate result rows with distinct glyphs for manual-review (🔍) and archive (📖) items instead of the misleading "skipped" and "migrated" checkmarks, so users can see which entries still need attention versus which were filed away. Thanks @sjf.
|
||||
- CLI/migrate: split Codex migrate output into separate preview and result phases so the Before plan and After result render through clack with independently tunable copy. Thanks @sjf.
|
||||
- Codex app-server: project bundle and user MCP servers into Codex threads, rotate threads when an MCP server is disabled, scope bundle MCP injection to bundled servers, and resend user MCP config on resume so MCP changes take effect mid-session without restarting the agent. (#81551) Thanks @jalehman.
|
||||
- Codex migration: invoke the managed Codex binary instead of a stale system `codex` for source-config migration plans, so users running the bundled Codex runtime get plan output that matches the binary the gateway will actually use. (#81582) Thanks @fuller-stack-dev.
|
||||
- Subagents/maintenance: preserve pending subagent registry sessions during session-store cleanup, pruning, and disk-budget enforcement so in-flight subagent runs are not deleted by background maintenance before they complete. (#81498) Thanks @ai-hpc.
|
||||
- Plugin SDK: restore the deprecated `openclaw/plugin-sdk/memory-core` package subpath as an alias of `memory-host-core`, so published memory companion plugins that still import it resolve on current hosts.
|
||||
- Control UI/chat: reconcile terminal and reconnect run cleanup with cached session activity, stale compaction/fallback indicators, and a compact composer run-status chip so completed or interrupted turns do not leave Stop active. Fixes #76874 and #64220; refs #71630. Thanks @BunsDev.
|
||||
- Maintainer tooling: clarify which pnpm test/check commands are safe locally versus inside Codex worktrees, routing linked-worktree gates through node wrappers and Crabbox/Testbox.
|
||||
- Auto-reply: preserve same-key ordering when debounced inbound work falls back to immediate flushes, so follow-up turns cannot overtake an active buffered flush.
|
||||
- Telegram/WhatsApp: keep Telegram same-chat replies ordered behind active no-delay turns without blocking WhatsApp follow-up message dispatch.
|
||||
- Codex migration: avoid duplicate cached plugin bundle warnings when app-server plugin inventory is available.
|
||||
- Agents: suppress aborted embedded assistant partials, reasoning text, reply directives, and stale prior replies before user-facing delivery while preserving clean timeout/error payloads. Fixes #48241. Thanks @BunsDev, @andyliu, and @yassinebkr.
|
||||
- Agents: allow dot-dot-prefixed filenames such as `..file.txt` inside workspace and sandbox path policy while still rejecting real parent traversal.
|
||||
- Native image input: detect Windows drive image paths in plain prompts so `C:\...\screenshot.png` references are not missed.
|
||||
- Media: normalize Windows-style filename hints before staging attachments, remote media, audio transcodes, and saved-media display names, so POSIX hosts do not preserve drive or directory text in generated filenames.
|
||||
- Media references: resolve first-level inbound media files whose IDs start with dots instead of treating names like `..photo.png` as parent traversal.
|
||||
- iOS/chat: resize PhotosPicker image attachments to capped JPEGs before staging and sending, stripping source metadata and keeping oversized camera photos under the chat upload budget. Fixes #68524. Thanks @BunsDev.
|
||||
- Control UI: keep shared form, config, and usage text-entry controls at 16px on touch-primary devices while preserving chat composer input sizing, so iOS Safari no longer auto-zooms focused fields. Fixes #64651; carries forward #64673. Thanks @NianJiuZst and @BunsDev.
|
||||
- Codex harness: classify native app-server token-refresh logout and relogin failures as authentication refresh errors, so users get re-authentication guidance instead of a raw runtime failure.
|
||||
- Agents/trajectory: make the trajectory flush cleanup timeout configurable with `OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS`, preserving the 10s default while slower stores drain. Refs #75839. Thanks @BunsDev.
|
||||
- Codex startup: treat selectable configured OpenAI agent models as Codex runtime requirements during plugin auto-enable, startup planning, and doctor install repair, so Anthropic-primary configs can still switch to OpenAI/Codex cleanly.
|
||||
- Agents: preserve source-reply delivery metadata when merging tool-returned media into the final reply, keeping message-tool-only replies deliverable and mirrored. Thanks @pashpashpash and @vincentkoc.
|
||||
- Replies: treat rich presentation, interactive controls, and channel-native payload data as outbound content across follow-up, heartbeat, cron, ACP, and block-streaming delivery paths, preventing card/button-only replies from being dropped as empty.
|
||||
- Replies: deliver rich-only block replies even when block-streaming coalescing is enabled, keeping card and button payloads from being dropped by the text coalescer. Thanks @pashpashpash.
|
||||
- macOS/companion: require system TLS trust before pinning a first-use direct `wss://` gateway certificate and honor `gateway.remote.tlsFingerprint` as the explicit pin for remote node-mode sessions, so fresh endpoints fail closed when macOS cannot trust the certificate unless configured out of band. Fixes #50642. Thanks @BunsDev.
|
||||
- Update: snapshot config before update-time repair and restart writes, preserve plugin install records through doctor cleanup, and keep update-time config size drops from blocking the update while pointing users to the pre-update backup. Fixes #80077. (#80257) Thanks @Jerry-Xin and @vincentkoc.
|
||||
- WebChat/TUI: route Codex `tools.message` source replies to the active internal UI turn and mirror them to session history, so message-tool-only harness replies, including rich presentation and button-only replies, no longer disappear while WebChat and TUI remain non-targetable outbound channels. (#81586) Thanks @pashpashpash.
|
||||
- Codex auth: accept OAuth profiles backed by `oauthRef` during runtime auth selection, so official Codex OAuth logins are used by app-server agent runs. (#81633) Thanks @obviyus.
|
||||
- Sessions/status: classify ACP spawn-child sessions as `kind: "spawn-child"` instead of `"direct"` in `openclaw sessions` and status output; extract the duplicated session-kind classifier into a shared helper (`src/sessions/classify-session-kind.ts`) so both surfaces stay in sync. Fixes catalog #19. (#79544)
|
||||
- Sessions/Gateway: report `agentRuntime.id: "acpx"` (or stored backend id) with `source: "session-key"` for ACP control-plane session rows in `openclaw sessions --json`, `openclaw status`, and Gateway session RPC responses instead of the incorrect `"auto"` / `"pi"` implicit fallback. Fixes catalog #18. (#79550)
|
||||
- Telegram: delete tool-progress-only draft bubbles before rotating to the real answer, preventing orphaned progress messages in streamed replies.
|
||||
- Codex app-server: keep per-agent `CODEX_HOME` isolation without rewriting `HOME` by default, so Codex-run subprocesses can still find normal user-home config, tokens, and CLI state unless the launch explicitly overrides `HOME`. Thanks @pashpashpash.
|
||||
- ACP: preserve redacted numeric JSON-RPC `RequestError` details in runtime failure text, so backend diagnostics are visible instead of only `Internal error`. Fixes #81126. (#81188) Thanks @vyctorbrzezowski.
|
||||
- Agents: cache unchanged PI model discovery stores and model lookups, reducing repeated model-resolution startup latency under large model configs. Fixes #78851.
|
||||
- Onboarding: carry returned Codex plugin migration config through the OpenAI model wizard so accepted plugin migrations are saved with the final config write.
|
||||
- Security/Windows ACL audit: classify Anonymous Logon, Guests, Interactive, Local, and Network SIDs as world-equivalent principals so broadly writable paths stay critical instead of being downgraded to group-writable. Fixes #74350. (#74383) Thanks @dwc1997.
|
||||
- Media-understanding: retry transient remote attachment fetch failures before audio or vision processing, so Discord voice notes are not lost after one network/CDN blip. Fixes #74316. Thanks @vyctorbrzezowski and @gabrielexito-stack.
|
||||
- Control UI: order timestamped live stream and tool items before untimestamped history fallbacks, keeping chat history in visible time order. Fixes #80759. (#81016) Thanks @akrimm702.
|
||||
@@ -31,6 +127,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Config: serialize and retry semantic config mutations centrally, so concurrent commands can rebase safe changes instead of clobbering or hand-rolling command-local retry loops. (#76601)
|
||||
- Require approval for setup-code device pairing [AI]. (#81292) Thanks @pgondhi987.
|
||||
- Plugins/install: preserve third-party peer dependencies in the managed npm root when later plugin installs or updates recalculate the shared dependency tree. Thanks @shakkernerd.
|
||||
- Plugins/memory: prefer the npm-installed memory-lancedb plugin over the bundled fallback during duplicate resolution, keeping Active Memory's `memory_recall` tool visible after managed installs. Fixes #81193. Thanks @julio-arcila.
|
||||
- Plugins/uninstall: prune managed third-party peer dependencies after their owning npm plugin is removed, without blocking plugin cleanup on peer-prune failures.
|
||||
- Docker: pin setup-time container paths so stale host `.env` OpenClaw paths cannot leak into Linux containers. Fixes #80381. (#81105) Thanks @brokemac79.
|
||||
- Channels/WeCom: refresh the official onboarding install to `@wecom/wecom-openclaw-plugin@2026.5.7` and update existing managed npm installs instead of failing on the package directory. Fixes #79884. (#80390) Thanks @brokemac79.
|
||||
@@ -57,6 +154,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: keep active reply runs visible to stuck-session diagnostics and clear no-active-work recovery state, preventing stale queued lanes after compaction or tool failures. Fixes #80677. (#81302)
|
||||
- Codex app-server: rotate incompatible context-engine-managed native threads so Lossless-managed sessions do not resume stale hidden Codex history. (#81223) Thanks @jalehman.
|
||||
- Codex cron: execute scheduled command-style automation payloads before workspace bootstrap or memory review, preserving existing isolated cron jobs after Codex harness migration. (#81510) Thanks @jalehman.
|
||||
- Plugin LLM completions: honor Codex agent-runtime policy for canonical OpenAI model refs, so context-engine summarizers can use Codex OAuth instead of requiring direct `OPENAI_API_KEY` auth. (#81511) Thanks @jalehman.
|
||||
- Gateway/OpenAI HTTP: return OpenAI-compatible 400 errors for invalid sampling params and provider validation failures instead of collapsing them to 500s. (#81275) Thanks @Lellansin.
|
||||
- Telegram: publish plugin and skill command description localizations to native command menus while filtering unsupported locale codes and preserving Telegram command limits. (#81351) Thanks @jzakirov.
|
||||
- Limit hook CLI tool authority [AI]. (#81065) Thanks @pgondhi987.
|
||||
@@ -75,6 +173,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Reject truncated exec approval commands [AI]. (#81001) Thanks @pgondhi987.
|
||||
- Enforce inline shell wrapper payload matching [AI]. (#80978) Thanks @pgondhi987.
|
||||
- fix(node-pairing): replace changed pending requests [AI]. (#80894) Thanks @pgondhi987.
|
||||
- Codex/status: align `/codex status` rate-limit wording with `/status` by showing remaining quota and compact reset durations instead of used quota and raw ISO timestamps. Thanks @MatthewSchleder.
|
||||
- Rate limit Google Chat webhook requests [AI]. (#80974) Thanks @pgondhi987.
|
||||
- Docker: mount the auth-profile secret key directory so OAuth-backed auth profiles survive container rebuilds. (#80991)
|
||||
- Onboarding: accept Codex auth profiles for canonical OpenAI model checks, avoiding false missing-auth warnings. (#80913) Thanks @rubencu.
|
||||
@@ -88,6 +187,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/groups: in single-account setups, treat an explicit empty `accounts.<id>.groups: {}` map the same as undefined so the root `channels.telegram.groups` allowlist still applies, instead of silently dropping every group update under the default `groupPolicy: "allowlist"`. Multi-account semantics are unchanged so per-account explicit-empty groups still scope-disable a single account without affecting siblings; the explicit way to block all groups for any account remains `groupPolicy: "disabled"`. Fixes #79427. (#81030) Thanks @kinjitakabe.
|
||||
- Codex (app-server): project user-configured `mcp.servers` into new Codex thread configs, matching the codex-cli runtime's existing `-c mcp_servers=...` behavior so app-server-runtime agents see the same user MCP servers the CLI runtime already exposes. Plugin-curated apps remain attached via the separate `apps` config patch. Fixes #80814. Thanks @kinjitakabe.
|
||||
- Enforce Slack plugin approval button authorization [AI]. (#80899) Thanks @pgondhi987.
|
||||
- Mattermost: log a structured `mattermost no-visible-reply` diagnostic when a substantive (non-reasoning) final reply payload reaches `deliverMattermostReplyPayload` but the underlying `deliverTextOrMediaReply` returns `"empty"` — previously the run completed with a misleading `delivered reply to <channel>` log even though no Mattermost API send happened, masking silent completions in channel/thread contexts. No behavior change; the diagnostic surfaces the failure so operators can detect it instead of seeing the agent appear to go silent. Fixes #80501. Thanks @robbyproc87.
|
||||
- Recognize PowerShell -ec inline commands [AI]. (#80893) Thanks @pgondhi987.
|
||||
- fix(qqbot): authorize approval button callbacks [AI]. (#80892) Thanks @pgondhi987.
|
||||
- Telegram: render supported HTML tags in streamed and durable replies instead of showing literal markup. (#80977)
|
||||
@@ -114,6 +214,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins doctor: report stale plugin config warnings and avoid claiming full plugin health when config warnings remain. (#81515) Thanks @BKF-Gitty.
|
||||
- Sessions: display `model: "<agentId>-acp"` / `modelProvider: "acpx"` (ACP-runtime sentinel) for ACP control-plane sessions in `openclaw sessions` output, instead of the agent's configured model which was misleading. Catalog finding 20. (#79543)
|
||||
- Slack: normalize message read `before` and `after` timestamp bounds before calling Slack history or thread reply APIs. Fixes #80835. (#81338) Thanks @honor2030.
|
||||
- Gateway: throttle assistant/thinking agent event fanout during streaming bursts without dropping buffered deltas. (#80335) Thanks @samzong.
|
||||
- Codex app-server: keep the short post-tool completion watchdog armed across dynamic tool completion bookkeeping so embedded Codex runs fail fast and release their session lane when Codex goes quiet after a tool result. (#81697) Thanks @mbelinky.
|
||||
- Models: restore authenticated CLI runtime providers in the `/models` picker while keeping legacy runtime aliases hidden from setup/default model choices. Closes #81212. (#81239) Thanks @anagnorisis2peripeteia.
|
||||
- Agents/cron: honor a cron payload's explicit `timeoutSeconds` for the LLM idle watchdog even when it numerically equals `agents.defaults.timeoutSeconds`, preserving explicit per-run timeout intent and preventing stalled streaming replies from being cut to the implicit 120s cap. (#79426) Thanks @legolaz8451.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -203,6 +307,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Codex app-server: mirror native Codex subagent spawn lifecycle events into Task Registry so app-server child agents appear in task/status surfaces without relying on transcript text. (#79512) Thanks @mbelinky.
|
||||
- Gateway: expose optional `isHeartbeat` metadata on agent event payloads so clients can distinguish scheduled heartbeat runs from ordinary chat runs. (#80610) Thanks @medns.
|
||||
- Agents: add `agents.defaults.runRetries` and `agents.list[].runRetries` config for embedded Pi runner retry loop limits. (#80661) Thanks @medns.
|
||||
- Codex: add node-backed Codex CLI session listing and binding so an OpenClaw conversation can continue an existing Codex CLI session running on a paired node.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -573,6 +678,7 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/migrate: show native Codex plugin names before truncated plan items and prompt for plugin activation explicitly during interactive Codex migration instead of silently keeping every planned plugin. Thanks @kevinslin.
|
||||
- CLI/migrate: leave already configured target Codex plugins unchecked in the interactive plugin selector and show a `plugin exists` conflict hint while keeping new plugin activations selected by default. Thanks @kevinslin.
|
||||
- CLI/migrate: return cleanly without apply confirmation when interactive Codex migration leaves both skill copies and native plugin activations unselected. Thanks @kevinslin.
|
||||
- Gateway/sessions: extend the per-call sessions-list `rowContext` cache with memoization for `resolveSessionDisplayModelIdentityRef`, thinking metadata, and `resolveModelCostConfig` so deterministic per-row resolvers run once per unique `(provider, model[, agentId])` tuple instead of once per session. Cuts CPU on `sessions.list` for stores with many sessions sharing a small set of model tuples; behavior is unchanged for callers that pass no `rowContext`. Thanks @rolandrscheel.
|
||||
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.
|
||||
- Slack/performance: reduce message preparation, stream recipient lookup, and thread-context allocation overhead on Slack reply hot paths. Thanks @vincentkoc.
|
||||
- Control UI/chat: strip untrusted sender metadata from live streams and transcript display, preserve canvas preview anchors, and stop operator UI clients from injecting their internal client id as sender identity. Fixes #78739. Thanks @tmimmanuel, @guguangxin-eng, @hclsys, and @BunsDev.
|
||||
@@ -637,62 +743,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/auth: keep `agents.defaults.model` when `openclaw models auth login` runs without `--set-default`, so provider onboarding patches add models without silently switching the primary. Fixes #78162. (#78241) Thanks @neeravmakwana.
|
||||
- Control UI/chat: localize the remaining chat welcome, composer, run-control, session/model/thinking selector, and zh-CN Skills labels through the Control UI i18n pipeline so non-English browser locales no longer see those chat controls in English. Fixes #79937. Thanks @BunsDev.
|
||||
- Control UI: surface browser-blocked WebSocket security failures with wss:// and loopback dashboard guidance instead of leaving the connection on a dead security error. Thanks @BunsDev.
|
||||
- Gateway/diagnostics: keep active-only transient event-loop max-delay samples as info-level stability telemetry instead of warning-level liveness diagnostics. Thanks @BunsDev.
|
||||
- Google/Gemini: default new API-key onboarding to stable `google/gemini-2.5-flash` instead of the preview Pro route, reducing surprise daily quota exhaustion. Fixes #79670. Thanks @HugeBunny.
|
||||
- Amazon Bedrock: expose Claude thinking profiles through the lightweight provider policy surface so `/think:adaptive` validates before the Bedrock runtime plugin is loaded. Fixes #79754. Thanks @phoenixyy and @hclsys.
|
||||
- Codex/transcripts: mirror dynamic tool calls and outputs into Codex app-server transcripts so tool activity is visible alongside assistant text instead of being elided, with per-item output capped at 12,000 characters. (#79952) Thanks @scoootscooob.
|
||||
- Memory: close temp SQLite handles before failed atomic reindex cleanup and retry Windows EBUSY/EPERM/EACCES temp file removals, so `memory index --force` does not abort or leave temp sidecars on locked filesystems. Fixes #79708. Thanks @LobsterFarmerAmp and @hclsys.
|
||||
- Agents/CLI: add an explicit `reseedFromRawTranscriptWhenUncompacted` backend opt-in so safe invalidated CLI sessions can reseed from a bounded raw OpenClaw transcript tail before compaction while auth-boundary resets remain no-raw. Fixes #79713. (#79764) Thanks @hclsys.
|
||||
- Agents/CLI: handle resumed CLI JSONL output and bound supervisor output buffering so resumed runs stay readable without letting noisy child output grow unbounded.
|
||||
- Codex app-server: honor per-call `timeoutMs`, configured `image_generate` timeouts, and media image-understanding timeouts for dynamic tool calls, capped at 600000 ms, so slow image generation and image analysis no longer fail at the 30s bridge default. Fixes #79810. Thanks @omarshahine.
|
||||
- Agents/sandbox: include the container workspace path hint in sandbox-root escape errors while preserving shortened host workspace roots. Fixes #79712. Thanks @haumanto and @hclsys.
|
||||
- macOS/device pairing: let the native app read CLI PEM device identities and let the TypeScript loader migrate legacy Swift raw-key identities without generating a new device id, preventing repeated pairing prompts when `OPENCLAW_STATE_DIR` is shared. Fixes #76815. Thanks @BunsDev.
|
||||
- Image generation: honor configured web-fetch SSRF policy across OpenAI, Google, MiniMax, OpenRouter, and Vydra provider requests so RFC2544 fake-IP proxy opt-ins reach generation calls. Fixes #79716. (#79765) Thanks @hclsys.
|
||||
- Telegram: persist reply-chain message cache records as a compact append log instead of rewriting the full cache on every inbound message, reducing large-group turn latency.
|
||||
- Telegram/CLI-backend: mirror outbound replies to the session transcript so CLI-backend agent responses create `.jsonl` session files, preventing `sessionId=unknown` on subsequent runs. Fixes #75991.
|
||||
- Gateway/nodes: allow approved chat-channel macOS node exec replays to cross transient agent WebSocket reconnects only when node, agent session, and channel target metadata still match, restoring Telegram/WeCom host=node approvals without opening a general backend replay bypass. Fixes #77656. Thanks @BunsDev.
|
||||
- QQBot: route gateway WebSocket connections through the ambient proxy agent so deployments with `https_proxy`, `HTTPS_PROXY`, or `HTTP_PROXY` can reach the QQ gateway. (#72961) Thanks @xialonglee.
|
||||
- Agents/subagents: treat `sessions_spawn` `model: "default"` as the default-model fallback and ignore ACP-only stream targets for native sub-agent spawns. Fixes #72078. (#72101) Thanks @xialonglee.
|
||||
- Agents/failover: stop retrying assistant-prefill format rejections across auth profiles or model fallbacks, surfacing the deterministic provider error instead of requeueing the lane. Fixes #79688. (#79728) Thanks @hclsys.
|
||||
- Google/Gemini: resolve missing Gemini 3 Flash catalog rows through the Google provider template path so image-capable media-understanding models keep `input: ["text", "image"]` instead of falling back to text-only metadata. Fixes #79750. (#79759) Thanks @fenglanhua and @hclsys.
|
||||
- Memory/QMD: warn with a manual stale collection removal hint when QMD reports a path/pattern conflict but `collection list` lacks verifiable metadata, avoiding unsafe stderr-only rebinds. Refs #71783. (#72297) Thanks @MonkeyLeeT.
|
||||
- Models/auth: make `openclaw models status --check` and dashboard auth health honor effective auth profile order while keeping stale profiles visible. (#79685) Thanks @nimbleenigma.
|
||||
- Agents/failover: classify bare `stream_read_error` streaming failures as transient timeouts so configured model fallback runs instead of surfacing the raw transport error. Fixes #79689. (#79692) Thanks @hekunwang.
|
||||
- Agents/failover: persist overloaded auth-profile cooldown marks before exhausted fallback summaries surface, so immediate fallback retries honor the recorded cooldown state.
|
||||
- Docs/Subagents: correct the listed sub-agent bootstrap context files to include `SOUL.md`, `IDENTITY.md`, and `USER.md`. (#79470) Thanks @lastguru-net.
|
||||
- Backup: keep live backup archives from copying current agent session transcripts, cron run logs, and delivery queues while preserving workspace lock/temp files and keeping `--json` output parseable when volatile files are skipped. Fixes #72249. (#72251) Thanks @abnershang.
|
||||
- Backup: place the temp manifest outside every backed-up asset so `backup create --verify` still passes when `TMPDIR` resolves inside a source path (for example `~/.openclaw/tmp`), avoiding the duplicate root manifest that otherwise tripped `Expected exactly one backup manifest entry, found 2`. Fixes #75007. Thanks @YaanFPV.
|
||||
- OpenAI/Codex: install the Codex runtime plugin from npm during OpenAI onboarding and load it automatically for implicit OpenAI model routes, while preserving manual PI runtime overrides. Fixes #79358.
|
||||
- OpenAI/realtime voice: defer `response.create` while a realtime response is still active, retry after `response.done`/`response.cancelled`, and align GA input transcription/noise-reduction defaults with the Codex realtime reference so Discord/Voice Call consult results can resume speaking instead of tripping the active-response race.
|
||||
- OpenAI/realtime voice: avoid duplicate barge-in cancellation requests, log realtime model interruption/cutoff events in Discord voice logs, and treat OpenAI's no-active-response cancellation reply as a completed cancel so Discord voice sessions do not wedge pending speech after fast interruptions.
|
||||
- Agents/runtime: strip trailing assistant prefill for Claude-family OpenAI Responses routes, persist prompt/assistant profile cooldown marks before fallback, and show the configured container root in sandbox escape diagnostics. Fixes #79688 and #79712. Thanks @stainlu and @mushuiyu886.
|
||||
- Gateway: avoid false degraded event-loop health during rapid health/readiness/status probes unless sustained load has delay co-evidence, while keeping hard delay detection immediate. (#77028) Thanks @rubencu.
|
||||
- Markdown: keep blockquote spans off trailing paragraph separators. Fixes #79646.
|
||||
- Plugin SDK/LM Studio: recover Harmony plain-text tool calls from LM Studio streams. Fixes #78326.
|
||||
- Control UI: refresh the model cache after `session_status(model=...)` changes a session model. Fixes #79613.
|
||||
- Agents/context-engine: share loop-hook checkpoints with the after-turn finalizer so messages are not replayed. Fixes #79630.
|
||||
- Codex app-server: keep native hook relays alive for long-running turns so shell and file approvals stay reachable until the configured run window finishes. (#77533) Thanks @rubencu.
|
||||
- Gateway/macOS: clear ignored SIGUSR1 restart state, skip redundant package-update restarts when the refreshed LaunchAgent already serves the expected version, and give launchd a 10s throttle plus 20s shutdown window so update restarts do not leave old gateways alive or fight supervisor recovery. Fixes #79577; refs #78699 and #60885. Thanks @BunsDev.
|
||||
- Status/Codex: route Codex-harness `openai/*` usage through the OpenAI Codex quota provider and scope CLI status usage to the default agent auth store so `/status` and `openclaw status --usage` show Codex quota windows again. Fixes #79312. Thanks @keshavbotagent.
|
||||
- Matrix: keep joined strict DM rooms discoverable when stale `m.direct` mappings already point at an older strict room, and let `dm.sessionScope: "per-room"` promote safe unmapped strict rooms through the existing unnamed/unaliased room gate. Fixes #79514. Thanks @stainlu.
|
||||
- Gateway/agent: pass the session-key agent id into inline image attachment validation so the first image in a fresh per-agent session uses the agent's vision-capable model override instead of the text-only system default. Fixes #79407. Thanks @pandadev66.
|
||||
- Gateway/maintenance: prune dedupe overflow against a stable excess count and keep active agent retries from starting duplicate runs after cache eviction. (#73841) Thanks @thesomewhatyou.
|
||||
- Control UI/subagents: suppress internal `subagent_announce` handoff prompts from requester transcripts and hide legacy inter-session wrapper rows so completed subagent results no longer surface runtime context in WebChat history. (#79618) Thanks @joshavant.
|
||||
- Discord: preserve username target resolution for Discord outbound sends. (#79076) Thanks @vincentkoc.
|
||||
- Gateway/sessions: rotate generated transcript paths when gateway sessions reset, complementing the daily-rollover transcript persistence. (#79076) Thanks @vincentkoc.
|
||||
- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd.
|
||||
- Plugins/install: fail managed npm plugin installs when OpenClaw cannot repair a required plugin-local `node_modules/openclaw` peer link, preventing that peer-link failure mode from producing unusable `@openclaw/codex` installs. Refs #79462. Thanks @ai-hpc.
|
||||
- xAI/tools: register and execute `x_search` and `code_execution` when the xAI API key comes from an auth profile, keeping the plugin tool gate aligned with `openclaw onboard --auth-choice xai-api-key`. Fixes #79353. Thanks @dbernaltbn.
|
||||
- Cron/agents: recognize same-target `edit`↔`write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD.
|
||||
- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD.
|
||||
- CLI/router: when `openclaw <name>` does not match a CLI subcommand, check plugin tool manifests first so names like `lcm_recent` get an agent-tool diagnostic instead of the misleading suggestion to add the tool name to `plugins.allow`. Fixes #77214. Thanks @100yenadmin.
|
||||
- QA-lab/parity: bump the live mock-openai parity baseline from `claude-opus-4-6`/`claude-sonnet-4-6` to `claude-opus-4-7`/`claude-sonnet-4-7` and the candidate alt from `gpt-5.4-alt` to `gpt-5.5-alt` in `openclaw-release-checks.yml` and `qa-live-transports-convex.yml`, matching the active Opus 4.7 / GPT-5.5 defaults already used elsewhere on main. Carries forward the surface-bump portion of #74290. Thanks @100yenadmin.
|
||||
- QA-lab/scenarios: raise the `approval-turn-tool-followthrough` per-turn fallback timeouts from 20s/30s to 60s so cold mock-gateway parity runs do not flake on the approval-turn chain. Carries forward the timeout-bump portion of #74290. Thanks @100yenadmin.
|
||||
- Gateway/restart continuation: treat routed post-reboot agent turns as trusted internal continuations while preserving the original Telegram topic route, and retry briefly when the previous run is still shutting down, so owner-only tools remain available for chained restart workflows after reboot.
|
||||
- MS Teams: normalize pre-thread-qualified route session keys before deriving channel-thread lanes so cached route reuse cannot create malformed mixed `:thread:OLD:thread:NEW` sessions. Fixes #66771. (#78850) Thanks @harrisali0101.
|
||||
- Agents/compaction: keep the recent tail after manual `/compact` when Pi returns an empty or no-op compaction summary, preventing blank checkpoints from replacing the live context.
|
||||
- Native commands: handle slash commands before workspace and agent-reply bootstrap so Telegram `/status` and other command-only native replies do not wait behind full agent turn setup.
|
||||
- Telegram/groups: include the recent local chat window and nearby reply-target window as generic inbound context so stale reply ancestry does not overshadow the live group conversation.
|
||||
@@ -710,6 +760,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/macOS: `repairLaunchAgentBootstrap` no longer kickstarts an already-running LaunchAgent, preventing unnecessary service restarts and session disconnects when repair runs against a healthy gateway. Fixes #77428. Thanks @ramitrkar-hash.
|
||||
- Gateway/macOS: `openclaw gateway stop --disable` now persists the LaunchAgent disable bit even after a previous bootout left the service not loaded, keeping the explicit stay-down path reliable. (#78412) Thanks @wdeveloper16.
|
||||
- CLI/status: keep lean `openclaw status --json` off manifest-backed channel discovery so configured-channel checks do not repeatedly rescan plugin metadata. Fixes #79129.
|
||||
- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD.
|
||||
- Control UI/chat: hide retired and non-public Google Gemini model IDs from chat model catalogs and route the bare `gemini-3-pro` alias to Gemini 3.1 Pro Preview instead of the shut-down Gemini 3 Pro Preview. Thanks @BunsDev.
|
||||
- CLI/infer: canonicalize case-only catalog model refs in `infer model run --model` so mixed-case provider/model strings resolve to the canonical catalog entry instead of failing with `Unknown model`. (#78940) Thanks @ai-hpc.
|
||||
- CLI/infer: allow explicit local `infer model run --model <provider/model>` probes to use exact bundled static catalog rows before the provider is written to config, surfacing missing credentials as auth errors instead of `Unknown model`.
|
||||
|
||||
@@ -4,15 +4,19 @@ import OpenClawKit
|
||||
|
||||
final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
let authorized: Bool = if status == .notDetermined || status == .writeOnly {
|
||||
await Self.requestFullEventAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsRead(status: status)
|
||||
}
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let (start, end) = Self.resolveRange(
|
||||
startISO: params.startISO,
|
||||
endISO: params.endISO)
|
||||
@@ -37,15 +41,19 @@ final class CalendarService: CalendarServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
let authorized: Bool = if status == .notDetermined {
|
||||
await Self.requestWriteOnlyEventAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsWrite(status: status)
|
||||
}
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Calendar", code: 3, userInfo: [
|
||||
@@ -95,6 +103,24 @@ final class CalendarService: CalendarServicing {
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func requestFullEventAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestWriteOnlyEventAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestWriteOnlyAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
|
||||
@@ -97,14 +97,17 @@ final class ContactsService: ContactsServicing {
|
||||
return OpenClawContactsAddPayload(contact: Self.payload(from: persisted))
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: CNContactStore, status: CNAuthorizationStatus) async -> Bool {
|
||||
private static func ensureAuthorization(status: CNAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; the caller should instruct the user to grant permission.
|
||||
// Prompts block the invoke and lead to timeouts in headless flows.
|
||||
return false
|
||||
return await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
@@ -113,15 +116,14 @@ final class ContactsService: ContactsServicing {
|
||||
}
|
||||
|
||||
private static func authorizedStore() async throws -> CNContactStore {
|
||||
let store = CNContactStore()
|
||||
let status = CNContactStore.authorizationStatus(for: .contacts)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
let authorized = await Self.ensureAuthorization(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Contacts", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CONTACTS_PERMISSION_REQUIRED: grant Contacts permission",
|
||||
])
|
||||
}
|
||||
return store
|
||||
return CNContactStore()
|
||||
}
|
||||
|
||||
private static func normalizeStrings(_ values: [String]?, lowercased: Bool = false) -> [String] {
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.</string>
|
||||
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>OpenClaw discovers and connects to your OpenClaw gateway on the local network.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
@@ -64,6 +72,8 @@
|
||||
<string>OpenClaw may use motion data to support device-aware interactions and automations.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>OpenClaw needs photo library access when you choose existing photos to share with your assistant.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayOnboardingView: View {
|
||||
|
||||
64
apps/ios/Sources/Permissions/PermissionRequestBridge.swift
Normal file
64
apps/ios/Sources/Permissions/PermissionRequestBridge.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
enum PermissionRequestBridge {
|
||||
final class Box: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuation: CheckedContinuation<Bool, Never>?
|
||||
private var hasResumed = false
|
||||
|
||||
func install(_ continuation: CheckedContinuation<Bool, Never>) -> Bool {
|
||||
self.lock.lock()
|
||||
if self.hasResumed {
|
||||
self.lock.unlock()
|
||||
continuation.resume(returning: false)
|
||||
return false
|
||||
}
|
||||
self.continuation = continuation
|
||||
self.lock.unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
func resume(_ value: Bool) {
|
||||
self.lock.lock()
|
||||
guard !self.hasResumed else {
|
||||
self.lock.unlock()
|
||||
return
|
||||
}
|
||||
self.hasResumed = true
|
||||
let continuation = self.continuation
|
||||
self.continuation = nil
|
||||
self.lock.unlock()
|
||||
continuation?.resume(returning: value)
|
||||
}
|
||||
|
||||
func canStartRequest() -> Bool {
|
||||
self.lock.lock()
|
||||
let canStart = !self.hasResumed
|
||||
self.lock.unlock()
|
||||
return canStart
|
||||
}
|
||||
}
|
||||
|
||||
static func awaitRequest(
|
||||
_ start: @escaping @Sendable (@escaping @Sendable (Bool) -> Void) -> Void) async -> Bool
|
||||
{
|
||||
let box = Box()
|
||||
return await withTaskCancellationHandler {
|
||||
await withCheckedContinuation(isolation: nil) { continuation in
|
||||
guard !Task.isCancelled else {
|
||||
continuation.resume(returning: false)
|
||||
return
|
||||
}
|
||||
guard box.install(continuation) else { return }
|
||||
Task { @MainActor in
|
||||
guard box.canStartRequest() else { return }
|
||||
start { granted in
|
||||
box.resume(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
box.resume(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,19 @@ import OpenClawKit
|
||||
|
||||
final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
let authorized: Bool = if status == .notDetermined || status == .writeOnly {
|
||||
await Self.requestFullReminderAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsRead(status: status)
|
||||
}
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let limit = max(1, min(params.limit ?? 50, 500))
|
||||
let statusFilter = params.status ?? .incomplete
|
||||
|
||||
@@ -48,15 +52,19 @@ final class RemindersService: RemindersServicing {
|
||||
}
|
||||
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
let authorized: Bool = if status == .notDetermined {
|
||||
await Self.requestFullReminderAccess()
|
||||
} else {
|
||||
EventKitAuthorization.allowsWrite(status: status)
|
||||
}
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
])
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !title.isEmpty else {
|
||||
throw NSError(domain: "Reminders", code: 3, userInfo: [
|
||||
@@ -100,6 +108,15 @@ final class RemindersService: RemindersServicing {
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func requestFullReminderAccess() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
|
||||
298
apps/ios/Sources/Settings/PrivacyAccessSectionView.swift
Normal file
298
apps/ios/Sources/Settings/PrivacyAccessSectionView.swift
Normal file
@@ -0,0 +1,298 @@
|
||||
import Contacts
|
||||
import EventKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct PrivacyAccessSectionView: View {
|
||||
@State private var contactsStatus: CNAuthorizationStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
@State private var calendarStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
@State private var remindersStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup("Privacy & Access") {
|
||||
self.permissionRow(
|
||||
title: "Contacts",
|
||||
icon: "person.crop.circle",
|
||||
status: self.statusText(for: self.contactsStatus),
|
||||
detail: "Search and add contacts from the assistant.",
|
||||
actionTitle: self.actionTitle(for: self.contactsStatus),
|
||||
action: self.handleContactsAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Calendar (Add Events)",
|
||||
icon: "calendar.badge.plus",
|
||||
status: self.calendarWriteStatusText,
|
||||
detail: "Add events with least privilege.",
|
||||
actionTitle: self.calendarWriteActionTitle,
|
||||
action: self.handleCalendarWriteAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Calendar (View Events)",
|
||||
icon: "calendar",
|
||||
status: self.calendarReadStatusText,
|
||||
detail: "List and read calendar events.",
|
||||
actionTitle: self.calendarReadActionTitle,
|
||||
action: self.handleCalendarReadAction)
|
||||
|
||||
self.permissionRow(
|
||||
title: "Reminders",
|
||||
icon: "checklist",
|
||||
status: self.remindersStatusText,
|
||||
detail: "List, add, and complete reminders.",
|
||||
actionTitle: self.remindersActionTitle,
|
||||
action: self.handleRemindersAction)
|
||||
}
|
||||
.onAppear { self.refreshAll() }
|
||||
.onChange(of: self.scenePhase) { _, phase in
|
||||
if phase == .active {
|
||||
self.refreshAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func permissionRow(
|
||||
title: String,
|
||||
icon: String,
|
||||
status: String,
|
||||
detail: String,
|
||||
actionTitle: String?,
|
||||
action: (() -> Void)?) -> some View
|
||||
{
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Label(title, systemImage: icon)
|
||||
Spacer()
|
||||
Text(status)
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(self.statusColor(for: status))
|
||||
}
|
||||
Text(detail)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let actionTitle, let action {
|
||||
Button(actionTitle, action: action)
|
||||
.font(.footnote)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func statusColor(for status: String) -> Color {
|
||||
switch status {
|
||||
case "Allowed":
|
||||
.green
|
||||
case "Not Set":
|
||||
.orange
|
||||
case "Add-Only":
|
||||
.yellow
|
||||
default:
|
||||
.red
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(for cnStatus: CNAuthorizationStatus) -> String {
|
||||
switch cnStatus {
|
||||
case .authorized, .limited:
|
||||
"Allowed"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private func actionTitle(for cnStatus: CNAuthorizationStatus) -> String? {
|
||||
switch cnStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleContactsAction() {
|
||||
switch self.contactsStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = CNContactStore()
|
||||
store.requestAccess(for: .contacts) { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteStatusText: String {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
"Allowed"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarWriteActionTitle: String? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCalendarWriteAction() {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
Task {
|
||||
_ = await self.requestCalendarWriteOnly()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadStatusText: String {
|
||||
switch self.calendarStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarReadActionTitle: String? {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined:
|
||||
"Request Full Access"
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCalendarReadAction() {
|
||||
switch self.calendarStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestCalendarFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersStatusText: String {
|
||||
switch self.remindersStatus {
|
||||
case .authorized, .fullAccess:
|
||||
"Allowed"
|
||||
case .writeOnly:
|
||||
"Add-Only"
|
||||
case .notDetermined:
|
||||
"Not Set"
|
||||
case .denied, .restricted:
|
||||
"Not Allowed"
|
||||
@unknown default:
|
||||
"Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private var remindersActionTitle: String? {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined:
|
||||
"Request Access"
|
||||
case .writeOnly:
|
||||
"Upgrade to Full Access"
|
||||
case .denied, .restricted:
|
||||
"Open Settings"
|
||||
default:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRemindersAction() {
|
||||
switch self.remindersStatus {
|
||||
case .notDetermined, .writeOnly:
|
||||
Task {
|
||||
_ = await self.requestRemindersFull()
|
||||
await MainActor.run { self.refreshAll() }
|
||||
}
|
||||
case .denied, .restricted:
|
||||
self.openSettings()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
self.contactsStatus = CNContactStore.authorizationStatus(for: .contacts)
|
||||
self.calendarStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
self.remindersStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||
}
|
||||
|
||||
private func requestCalendarWriteOnly() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestWriteOnlyAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCalendarFull() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToEvents { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestRemindersFull() async -> Bool {
|
||||
await PermissionRequestBridge.awaitRequest { completion in
|
||||
let store = EKEventStore()
|
||||
store.requestFullAccessToReminders { granted, _ in
|
||||
completion(granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
@@ -405,6 +405,8 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
AnyView(PrivacyAccessSectionView())
|
||||
|
||||
DisclosureGroup("Device Info") {
|
||||
TextField("Name", text: self.$displayName)
|
||||
Text(self.instanceId)
|
||||
@@ -419,16 +421,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.modifier(SettingsCloseToolbar())
|
||||
.sheet(isPresented: self.$showGatewayProblemDetails) {
|
||||
if let gatewayProblem = self.appModel.lastGatewayProblem {
|
||||
GatewayProblemDetailsSheet(
|
||||
@@ -488,90 +481,91 @@ struct SettingsTab: View {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.onAppear {
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore
|
||||
.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.appModel.refreshLastShareEventFromRelay()
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
if self.isGatewayConnected {
|
||||
self.appModel.reloadTalkConfig()
|
||||
}
|
||||
}
|
||||
self.defaultShareInstruction = ShareToAgentSettings.loadDefaultInstruction()
|
||||
self.appModel.refreshLastShareEventFromRelay()
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
if self.isGatewayConnected {
|
||||
self.appModel.reloadTalkConfig()
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedAgentPickerId) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.appModel.setSelectedAgentId(trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
.onChange(of: self.appModel.selectedAgentId ?? "") { _, newValue in
|
||||
if newValue != self.selectedAgentPickerId {
|
||||
self.selectedAgentPickerId = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
return
|
||||
.onChange(of: self.preferredGatewayStableID) { _, newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
if self.manualGatewayEnabled {
|
||||
self.setupStatusText = self.appModel.gatewayStatusText
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.setupStatusText = trimmed
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.defaultShareInstruction) { _, newValue in
|
||||
ShareToAgentSettings.saveDefaultInstruction(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.setupCode = ""
|
||||
self.setupStatusText = nil
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
if self.manualGatewayEnabled {
|
||||
self.setupStatusText = self.appModel.gatewayStatusText
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
guard self.manualGatewayEnabled || self.connectingGatewayID == "manual" else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.setupStatusText = trimmed
|
||||
}
|
||||
.onChange(of: self.locationEnabledModeRaw) { _, newValue in
|
||||
let previous = self.lastLocationModeRaw
|
||||
self.lastLocationModeRaw = newValue
|
||||
guard let mode = OpenClawLocationMode(rawValue: newValue) else { return }
|
||||
Task {
|
||||
let granted = await self.appModel.requestLocationPermissions(mode: mode)
|
||||
if !granted {
|
||||
await MainActor.run {
|
||||
self.locationEnabledModeRaw = previous
|
||||
self.lastLocationModeRaw = previous
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
@@ -1138,4 +1132,21 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsCloseToolbar: ViewModifier {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swiftlint:enable type_body_length
|
||||
|
||||
@@ -40,6 +40,7 @@ Sources/Onboarding/OnboardingStateStore.swift
|
||||
Sources/Onboarding/OnboardingWizardView.swift
|
||||
Sources/Onboarding/QRScannerView.swift
|
||||
Sources/OpenClawApp.swift
|
||||
Sources/Permissions/PermissionRequestBridge.swift
|
||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||
Sources/Push/BackgroundAliveBeacon.swift
|
||||
Sources/Push/PushBuildConfig.swift
|
||||
@@ -60,6 +61,7 @@ Sources/Services/WatchConnectivityTransport.swift
|
||||
Sources/Services/WatchMessagingPayloadCodec.swift
|
||||
Sources/Services/WatchMessagingService.swift
|
||||
Sources/SessionKey.swift
|
||||
Sources/Settings/PrivacyAccessSectionView.swift
|
||||
Sources/Settings/SettingsNetworkingHelpers.swift
|
||||
Sources/Settings/SettingsTab.swift
|
||||
Sources/Settings/VoiceWakeWordsSettingsView.swift
|
||||
|
||||
26
apps/ios/Tests/PermissionRequestBridgeTests.swift
Normal file
26
apps/ios/Tests/PermissionRequestBridgeTests.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct PermissionRequestBridgeTests {
|
||||
@Test func `box resumes immediately when cancelled before install`() async {
|
||||
let box = PermissionRequestBridge.Box()
|
||||
box.resume(false)
|
||||
let granted: Bool = await withCheckedContinuation { continuation in
|
||||
_ = box.install(continuation)
|
||||
}
|
||||
#expect(granted == false)
|
||||
#expect(box.canStartRequest() == false)
|
||||
}
|
||||
|
||||
@Test func `box resumes installed continuation once`() async {
|
||||
let box = PermissionRequestBridge.Box()
|
||||
|
||||
let granted: Bool = await withCheckedContinuation { continuation in
|
||||
_ = box.install(continuation)
|
||||
box.resume(true)
|
||||
box.resume(false)
|
||||
}
|
||||
|
||||
#expect(granted == true)
|
||||
}
|
||||
}
|
||||
@@ -136,11 +136,16 @@ targets:
|
||||
NSBonjourServices:
|
||||
- _openclaw-gw._tcp
|
||||
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
|
||||
NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
|
||||
NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.
|
||||
NSContactsUsageDescription: OpenClaw uses your contacts so you can search and reference people while using the assistant.
|
||||
NSLocationWhenInUseUsageDescription: OpenClaw uses your location when you allow location sharing.
|
||||
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
|
||||
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
|
||||
NSMotionUsageDescription: OpenClaw may use motion data to support device-aware interactions and automations.
|
||||
NSPhotoLibraryUsageDescription: OpenClaw needs photo library access when you choose existing photos to share with your assistant.
|
||||
NSRemindersFullAccessUsageDescription: OpenClaw uses your reminders to list, add, and complete tasks when you enable reminders access.
|
||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||
NSSupportsLiveActivities: true
|
||||
ITSAppUsesNonExemptEncryption: false
|
||||
|
||||
@@ -69,6 +69,17 @@ enum GatewayRemoteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
static func resolveTLSFingerprint(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["tlsFingerprint"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
|
||||
@@ -83,7 +83,9 @@ final class MacNodeModeCoordinator {
|
||||
clientId: "openclaw-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
let sessionBox = self.buildSessionBox(url: config.url)
|
||||
let sessionBox = self.buildSessionBox(
|
||||
url: config.url,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
|
||||
try await self.session.connect(
|
||||
url: config.url,
|
||||
@@ -243,15 +245,35 @@ final class MacNodeModeCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
nonisolated static func tlsParams(
|
||||
for url: URL,
|
||||
connectionMode: AppState.ConnectionMode,
|
||||
root: [String: Any],
|
||||
storedFingerprint: String?) -> GatewayTLSParams?
|
||||
{
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let configuredFingerprint = connectionMode == .remote
|
||||
? GatewayRemoteConfig.resolveTLSFingerprint(root: root)
|
||||
: nil
|
||||
let expectedFingerprint = configuredFingerprint ?? storedFingerprint
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: expectedFingerprint,
|
||||
allowTOFU: expectedFingerprint == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL, connectionMode: AppState.ConnectionMode) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let stableID = Self.tlsPinStoreKey(for: url)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
guard let params = Self.tlsParams(
|
||||
for: url,
|
||||
connectionMode: connectionMode,
|
||||
root: OpenClawConfigFile.loadDict(),
|
||||
storedFingerprint: stored)
|
||||
else { return nil }
|
||||
let session = GatewayTLSPinningSession(params: params)
|
||||
return WebSocketSessionBox(session: session)
|
||||
}
|
||||
|
||||
@@ -287,4 +287,36 @@ struct GatewayEndpointStoreTests {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://127.attacker.example")
|
||||
#expect(url == nil)
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint trims remote config value`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " sha256:ABC123 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: root) == "sha256:ABC123")
|
||||
}
|
||||
|
||||
@Test func `resolve tls fingerprint ignores blank or non string values`() {
|
||||
let blank: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": " ",
|
||||
],
|
||||
],
|
||||
]
|
||||
let nonString: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": 123,
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: blank) == nil)
|
||||
#expect(GatewayRemoteConfig.resolveTLSFingerprint(root: nonString) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,60 @@ struct MacNodeModeCoordinatorTests {
|
||||
#expect(MacNodeModeCoordinator.tlsPinStoreKey(for: url) == "gateway.example.ts.net:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params prefer configured fingerprint over stored pin`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:configured",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: root,
|
||||
storedFingerprint: "stored"))
|
||||
|
||||
#expect(params.expectedFingerprint == "sha256:configured")
|
||||
#expect(params.allowTOFU == false)
|
||||
#expect(params.storeKey == "gateway.example.com:443")
|
||||
}
|
||||
|
||||
@Test func `remote tls params allow first use only when no configured or stored pin exists`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.com"))
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .remote,
|
||||
root: [:],
|
||||
storedFingerprint: nil))
|
||||
|
||||
#expect(params.expectedFingerprint == nil)
|
||||
#expect(params.allowTOFU == true)
|
||||
}
|
||||
|
||||
@Test func `local tls params ignore remote configured fingerprint`() throws {
|
||||
let url = try #require(URL(string: "wss://127.0.0.1:18789"))
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"tlsFingerprint": "sha256:remote",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let params = try #require(MacNodeModeCoordinator.tlsParams(
|
||||
for: url,
|
||||
connectionMode: .local,
|
||||
root: root,
|
||||
storedFingerprint: "stored-local"))
|
||||
|
||||
#expect(params.expectedFingerprint == "stored-local")
|
||||
#expect(params.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test func `auto repairs trusted tailscale serve pin mismatch`() throws {
|
||||
let url = try #require(URL(string: "wss://gateway.example.ts.net"))
|
||||
let failure = GatewayTLSValidationFailure(
|
||||
|
||||
@@ -17,6 +17,7 @@ private let chatUILogger = Logger(subsystem: "ai.openclaw", category: "OpenClawC
|
||||
// swiftlint:disable:next type_body_length
|
||||
public final class OpenClawChatViewModel {
|
||||
public static let defaultModelSelectionID = "__default__"
|
||||
private static let maxAttachmentBytes = 5_000_000
|
||||
|
||||
public private(set) var messages: [OpenClawChatMessage] = []
|
||||
public var input: String = ""
|
||||
@@ -1298,11 +1299,6 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async {
|
||||
if data.count > 5_000_000 {
|
||||
self.errorText = "Attachment \(fileName) exceeds 5 MB limit"
|
||||
return
|
||||
}
|
||||
|
||||
let uti: UTType = {
|
||||
if let url {
|
||||
return UTType(filenameExtension: url.pathExtension) ?? .data
|
||||
@@ -1314,13 +1310,33 @@ public final class OpenClawChatViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let preview = Self.previewImage(data: data)
|
||||
let processed: Data
|
||||
do {
|
||||
processed = try await Task.detached(priority: .userInitiated) {
|
||||
try ChatImageProcessor.processForUpload(data: data)
|
||||
}.value
|
||||
} catch {
|
||||
self.errorText = "Could not process \(fileName): \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
if processed.count > Self.maxAttachmentBytes {
|
||||
self.errorText = "Attachment \(fileName) exceeds 5 MB limit after resizing"
|
||||
return
|
||||
}
|
||||
|
||||
let outputFileName: String = {
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
return baseName.isEmpty ? "image.jpg" : "\(baseName).jpg"
|
||||
}()
|
||||
|
||||
let preview = Self.previewImage(data: processed)
|
||||
self.attachments.append(
|
||||
OpenClawPendingAttachment(
|
||||
url: url,
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
data: processed,
|
||||
fileName: outputFileName,
|
||||
mimeType: "image/jpeg",
|
||||
preview: preview))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import Foundation
|
||||
|
||||
/// Chat-specific image upload policy built on the shared JPEG transcoder.
|
||||
public enum ChatImageProcessor {
|
||||
public static let maxLongEdgePx = 1600
|
||||
public static let jpegQuality = 0.8
|
||||
public static let maxPayloadBytes = 3_500_000
|
||||
|
||||
public enum ProcessError: Error, LocalizedError, Sendable {
|
||||
case notAnImage
|
||||
case decodeFailed
|
||||
case encodeFailed
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAnImage:
|
||||
"The data is not a recognizable image."
|
||||
case .decodeFailed:
|
||||
"The image could not be decoded."
|
||||
case .encodeFailed:
|
||||
"The image could not be resized to fit the chat upload limit."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func processForUpload(data: Data) throws -> Data {
|
||||
do {
|
||||
let result = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: data,
|
||||
maxLongEdgePx: self.maxLongEdgePx,
|
||||
quality: self.jpegQuality,
|
||||
maxBytes: self.maxPayloadBytes)
|
||||
return result.data
|
||||
} catch JPEGTranscodeError.decodeFailed {
|
||||
throw ProcessError.notAnImage
|
||||
} catch JPEGTranscodeError.propertiesMissing {
|
||||
throw ProcessError.decodeFailed
|
||||
} catch JPEGTranscodeError.sizeLimitExceeded {
|
||||
throw ProcessError.encodeFailed
|
||||
} catch {
|
||||
throw ProcessError.encodeFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
{
|
||||
return link
|
||||
}
|
||||
return fromGatewayURLString(
|
||||
return self.fromGatewayURLString(
|
||||
trimmed,
|
||||
bootstrapToken: nil,
|
||||
token: nil,
|
||||
@@ -89,7 +89,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
{
|
||||
return link
|
||||
}
|
||||
for candidate in setupCodeCandidates(in: trimmed) where candidate != trimmed {
|
||||
for candidate in self.setupCodeCandidates(in: trimmed) where candidate != trimmed {
|
||||
if let data = decodeBase64Url(candidate),
|
||||
let link = decodeSetupPayload(from: data)
|
||||
{
|
||||
@@ -104,7 +104,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
if let urlString = payload.url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!urlString.isEmpty
|
||||
{
|
||||
return fromGatewayURLString(
|
||||
return self.fromGatewayURLString(
|
||||
urlString,
|
||||
bootstrapToken: payload.bootstrapToken,
|
||||
token: payload.token,
|
||||
|
||||
@@ -79,6 +79,12 @@ public protocol GatewayDeviceTokenRetryTrustProviding: AnyObject {
|
||||
var allowsDeviceTokenRetryAuth: Bool { get }
|
||||
}
|
||||
|
||||
enum GatewayTLSFirstUsePolicy {
|
||||
static func allowsFirstUsePin(systemTrustOk: Bool) -> Bool {
|
||||
systemTrustOk
|
||||
}
|
||||
}
|
||||
|
||||
public enum GatewayTLSStore {
|
||||
private static let keychainService = "ai.openclaw.tls-pinning"
|
||||
|
||||
@@ -159,7 +165,8 @@ public enum GatewayTLSStore {
|
||||
}
|
||||
}
|
||||
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate, GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
|
||||
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate,
|
||||
GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
|
||||
private let params: GatewayTLSParams
|
||||
private let failureLock = NSLock()
|
||||
private var lastTLSFailure: GatewayTLSValidationFailure?
|
||||
@@ -238,12 +245,14 @@ public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLS
|
||||
return
|
||||
}
|
||||
if self.params.allowTOFU {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
if GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
self.clearTLSFailure()
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,26 @@ public struct JPEGTranscoder: Sendable {
|
||||
maxWidthPx: Int?,
|
||||
quality: Double,
|
||||
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
try self.transcodeToJPEG(
|
||||
imageData: imageData,
|
||||
maxWidthPx: maxWidthPx,
|
||||
maxLongEdgePx: nil,
|
||||
quality: quality,
|
||||
maxBytes: maxBytes)
|
||||
}
|
||||
|
||||
/// Re-encodes image data to JPEG, optionally downscaling so the *oriented* longest edge is <= `maxLongEdgePx`.
|
||||
///
|
||||
/// When `maxLongEdgePx` is provided it takes precedence over `maxWidthPx`.
|
||||
/// - Important: This normalizes EXIF orientation (the output pixels are rotated if needed; orientation tag is not
|
||||
/// relied on).
|
||||
public static func transcodeToJPEG(
|
||||
imageData: Data,
|
||||
maxWidthPx: Int? = nil,
|
||||
maxLongEdgePx: Int?,
|
||||
quality: Double,
|
||||
maxBytes: Int? = nil) throws -> (data: Data, widthPx: Int, heightPx: Int)
|
||||
{
|
||||
guard let src = CGImageSourceCreateWithData(imageData as CFData, nil) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
@@ -63,6 +83,10 @@ public struct JPEGTranscoder: Sendable {
|
||||
|
||||
let maxDim = max(orientedWidth, orientedHeight)
|
||||
var targetMaxPixelSize: Int = {
|
||||
if let maxLongEdgePx, maxLongEdgePx > 0 {
|
||||
guard maxDim > maxLongEdgePx else { return maxDim } // never upscale
|
||||
return maxLongEdgePx
|
||||
}
|
||||
guard let maxWidthPx, maxWidthPx > 0 else { return maxDim }
|
||||
guard orientedWidth > maxWidthPx else { return maxDim } // never upscale
|
||||
|
||||
@@ -81,6 +105,7 @@ public struct JPEGTranscoder: Sendable {
|
||||
guard let img = CGImageSourceCreateThumbnailAtIndex(src, 0, thumbOpts as CFDictionary) else {
|
||||
throw JPEGTranscodeError.decodeFailed
|
||||
}
|
||||
let opaqueImage = Self.flattenAlphaIfNeeded(img)
|
||||
|
||||
let out = NSMutableData()
|
||||
guard let dest = CGImageDestinationCreateWithData(out, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
@@ -88,12 +113,12 @@ public struct JPEGTranscoder: Sendable {
|
||||
}
|
||||
let q = self.clampQuality(quality)
|
||||
let encodeProps = [kCGImageDestinationLossyCompressionQuality: q] as CFDictionary
|
||||
CGImageDestinationAddImage(dest, img, encodeProps)
|
||||
CGImageDestinationAddImage(dest, opaqueImage, encodeProps)
|
||||
guard CGImageDestinationFinalize(dest) else {
|
||||
throw JPEGTranscodeError.encodeFailed
|
||||
}
|
||||
|
||||
return (out as Data, img.width, img.height)
|
||||
return (out as Data, opaqueImage.width, opaqueImage.height)
|
||||
}
|
||||
|
||||
guard let maxBytes, maxBytes > 0 else {
|
||||
@@ -132,4 +157,34 @@ public struct JPEGTranscoder: Sendable {
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/// JPEG cannot store alpha. Flatten transparent sources over white before encoding so ImageIO does not composite
|
||||
/// transparent pixels onto black by default.
|
||||
private static func flattenAlphaIfNeeded(_ image: CGImage) -> CGImage {
|
||||
switch image.alphaInfo {
|
||||
case .none, .noneSkipFirst, .noneSkipLast:
|
||||
return image
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: image.width,
|
||||
height: image.height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
else {
|
||||
return image
|
||||
}
|
||||
|
||||
let rect = CGRect(x: 0, y: 0, width: image.width, height: image.height)
|
||||
context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
|
||||
context.fill(rect)
|
||||
context.draw(image, in: rect)
|
||||
return context.makeImage() ?? image
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct ChatImageProcessorTests {
|
||||
private func syntheticJPEG(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 1)
|
||||
}
|
||||
|
||||
context.setFillColor(CGColor(red: 0.8, green: 0.2, blue: 0.4, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 0.1, green: 0.7, blue: 0.3, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 2)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 3)
|
||||
}
|
||||
|
||||
let properties: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: 0.95,
|
||||
kCGImagePropertyExifDictionary: [
|
||||
kCGImagePropertyExifDateTimeOriginal: "2026:04:20 16:30:00",
|
||||
kCGImagePropertyExifLensModel: "Leaky Lens 50mm f/1.4",
|
||||
] as CFDictionary,
|
||||
kCGImagePropertyGPSDictionary: [
|
||||
kCGImagePropertyGPSLatitude: 60.02,
|
||||
kCGImagePropertyGPSLatitudeRef: "N",
|
||||
kCGImagePropertyGPSLongitude: 10.95,
|
||||
kCGImagePropertyGPSLongitudeRef: "E",
|
||||
] as CFDictionary,
|
||||
kCGImagePropertyTIFFDictionary: [
|
||||
kCGImagePropertyTIFFMake: "LeakCorp",
|
||||
kCGImagePropertyTIFFModel: "Privacy-Leaker-1",
|
||||
] as CFDictionary,
|
||||
]
|
||||
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 4)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func syntheticPNGWithAlpha(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 5)
|
||||
}
|
||||
|
||||
context.clear(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1))
|
||||
context.fill(CGRect(x: width / 4, y: height / 4, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 6)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)
|
||||
else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 7)
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatImageProcessorTests", code: 8)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func properties(for data: Data) -> [CFString: Any] {
|
||||
guard
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return properties
|
||||
}
|
||||
|
||||
private func dimensions(for data: Data) -> (width: Int, height: Int)? {
|
||||
let properties = self.properties(for: data)
|
||||
guard
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (width.intValue, height.intValue)
|
||||
}
|
||||
|
||||
@Test func `resizes landscape long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 4000, height: 3000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (4000.0 / 3000.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `resizes portrait long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 3000, height: 4000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (3000.0 / 4000.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `resizes narrow tall long edge to upload limit`() throws {
|
||||
let source = try self.syntheticJPEG(width: 1080, height: 2400)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= ChatImageProcessor.maxLongEdgePx)
|
||||
#expect(abs((Double(dimensions.width) / Double(dimensions.height)) - (1080.0 / 2400.0)) <= 0.02)
|
||||
}
|
||||
|
||||
@Test func `small image is not upscaled`() throws {
|
||||
let source = try self.syntheticJPEG(width: 400, height: 300)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let dimensions = try #require(self.dimensions(for: output))
|
||||
|
||||
#expect(max(dimensions.width, dimensions.height) <= 400)
|
||||
}
|
||||
|
||||
@Test func `output fits payload budget`() throws {
|
||||
let source = try self.syntheticJPEG(width: 4000, height: 3000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
|
||||
#expect(output.count <= ChatImageProcessor.maxPayloadBytes)
|
||||
}
|
||||
|
||||
@Test func `rejects non image data`() {
|
||||
let garbage = Data("not an image".utf8)
|
||||
|
||||
#expect(throws: ChatImageProcessor.ProcessError.self) {
|
||||
_ = try ChatImageProcessor.processForUpload(data: garbage)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `strips source metadata from output`() throws {
|
||||
let source = try self.syntheticJPEG(width: 3000, height: 2000)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let properties = self.properties(for: output)
|
||||
let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any] ?? [:]
|
||||
|
||||
#expect(gps.isEmpty)
|
||||
for needle in ["Leaky Lens", "LeakCorp", "Privacy-Leaker", "2026:04:20"] {
|
||||
#expect(output.range(of: Data(needle.utf8)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `flattens transparent sources to opaque JPEG`() throws {
|
||||
let source = try self.syntheticPNGWithAlpha(width: 800, height: 600)
|
||||
let output = try ChatImageProcessor.processForUpload(data: source)
|
||||
let imageSource = try #require(CGImageSourceCreateWithData(output as CFData, nil))
|
||||
let image = try #require(CGImageSourceCreateImageAtIndex(imageSource, 0, nil))
|
||||
|
||||
#expect([.none, .noneSkipFirst, .noneSkipLast].contains(image.alphaInfo))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import OpenClawKit
|
||||
import UniformTypeIdentifiers
|
||||
import XCTest
|
||||
@testable import OpenClawChatUI
|
||||
|
||||
private struct AttachmentProcessingTransport: OpenClawChatTransport {
|
||||
func requestHistory(sessionKey _: String) async throws -> OpenClawChatHistoryPayload {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 1)
|
||||
}
|
||||
|
||||
func sendMessage(
|
||||
sessionKey _: String,
|
||||
message _: String,
|
||||
thinking _: String,
|
||||
idempotencyKey _: String,
|
||||
attachments _: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 2)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs _: Int) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||
AsyncStream { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
private func makeChatAttachmentJPEG(width: Int, height: Int) throws -> Data {
|
||||
guard
|
||||
let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 3)
|
||||
}
|
||||
|
||||
context.setFillColor(CGColor(red: 0.2, green: 0.4, blue: 0.8, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
||||
context.setFillColor(CGColor(red: 0.9, green: 0.5, blue: 0.1, alpha: 1))
|
||||
context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height / 2))
|
||||
|
||||
guard let image = context.makeImage() else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 4)
|
||||
}
|
||||
|
||||
let data = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 5)
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: 0.95] as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 6)
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
|
||||
private func chatAttachmentDimensions(for data: Data) -> (width: Int, height: Int)? {
|
||||
guard
|
||||
let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? NSNumber,
|
||||
let height = properties[kCGImagePropertyPixelHeight] as? NSNumber
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return (width.intValue, height.intValue)
|
||||
}
|
||||
|
||||
final class ChatViewModelAttachmentTests: XCTestCase {
|
||||
func testImageAttachmentsAreProcessedBeforeStaging() async throws {
|
||||
let imageData = try makeChatAttachmentJPEG(width: 3000, height: 4000)
|
||||
let viewModel = await MainActor.run {
|
||||
OpenClawChatViewModel(sessionKey: "main", transport: AttachmentProcessingTransport())
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
viewModel.addImageAttachment(data: imageData, fileName: "camera.heic", mimeType: "image/jpeg")
|
||||
}
|
||||
|
||||
try await waitUntil("attachment processed") {
|
||||
await MainActor.run { !viewModel.attachments.isEmpty || viewModel.errorText != nil }
|
||||
}
|
||||
|
||||
let attachment = try await MainActor.run {
|
||||
guard let attachment = viewModel.attachments.first else {
|
||||
throw NSError(domain: "ChatViewModelAttachmentTests", code: 7)
|
||||
}
|
||||
return (attachment.fileName, attachment.mimeType, attachment.data)
|
||||
}
|
||||
let dimensions = try XCTUnwrap(chatAttachmentDimensions(for: attachment.2))
|
||||
|
||||
XCTAssertEqual(attachment.0, "camera.jpg")
|
||||
XCTAssertEqual(attachment.1, "image/jpeg")
|
||||
XCTAssertLessThanOrEqual(attachment.2.count, ChatImageProcessor.maxPayloadBytes)
|
||||
XCTAssertLessThanOrEqual(max(dimensions.width, dimensions.height), ChatImageProcessor.maxLongEdgePx)
|
||||
let errorText = await MainActor.run { viewModel.errorText }
|
||||
XCTAssertNil(errorText)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Testing
|
||||
@testable import OpenClawKit
|
||||
|
||||
struct GatewayTLSPinningTests {
|
||||
@Test func `first use pinning requires system trust`() {
|
||||
#expect(GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: true))
|
||||
#expect(!GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: false))
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
c311205806d0eaa3631788dc2c489ece999b70430021ff91b365ce7ccfcba23c config-baseline.json
|
||||
2e27b71c9ed109767a227f5163917a4468a1969079fc3457a3df7fe74c1fa2b7 config-baseline.core.json
|
||||
90e4b700ae744fa81dd1986e4db16aadbb7d1dc4dfe695e03a35e75ea39bfde4 config-baseline.json
|
||||
8edde78f6986926b50613931d5588e4278768ca3003e5ece0edb3110ab9118b0 config-baseline.core.json
|
||||
2aa997d48549bd321a478485126a4bd5065ba47333a80e7eb07a0ef6ad75b0a6 config-baseline.channel.json
|
||||
0dac8944a0d51ae96f97e3809907f8a04d08413434a1a1190240f7e13bb11c4d config-baseline.plugin.json
|
||||
1ab5b65a94d84f59bae5e6bbe310057fae0a645f2538ab00f1f37b7f8b371e6f config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
e8c15cff96a0a869cfe3de29679d4296603a16bfa4676940845a484c23db8e56 plugin-sdk-api-baseline.json
|
||||
a6cbb8dc21b3ed16e0abd23c60a817ddedd65f336427c9fa565a43ca5dcc9a85 plugin-sdk-api-baseline.jsonl
|
||||
1cf7ca2ee1db3bf44682c487c780c6b1c47bbce27e74fb6f455cef445544c84f plugin-sdk-api-baseline.json
|
||||
24b8e3e4773579e5a184dd5f91a5ad2f8e92519b6fe314820a94d7a64bd1141e plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -23,17 +23,17 @@ Production-ready for DMs and channels via Slack app integrations. Default mode i
|
||||
|
||||
Both transports are production-ready and reach feature parity for messaging, slash commands, App Home, and interactivity. Pick by deployment shape, not features.
|
||||
|
||||
| Concern | Socket Mode (default) | HTTP Request URLs |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Public Gateway URL | Not required | Required (DNS, TLS, reverse proxy or tunnel) |
|
||||
| Outbound network | Outbound WSS to `wss-primary.slack.com` must be reachable | No outbound WS; inbound HTTPS only |
|
||||
| Tokens needed | Bot token (`xoxb-...`) + App-Level Token (`xapp-...`) with `connections:write` | Bot token (`xoxb-...`) + Signing Secret |
|
||||
| Dev laptop / behind firewall | Works as-is | Needs a public tunnel (ngrok, Cloudflare Tunnel, Tailscale Funnel) or staging Gateway |
|
||||
| Horizontal scaling | One Socket Mode session per app per host; multiple Gateways need separate Slack apps | Stateless POST handler; multiple Gateway replicas can share one app behind a load balancer |
|
||||
| Multi-account on one Gateway | Supported; each account opens its own WS | Supported; each account needs a unique `webhookPath` (default `/slack/events`) so registrations do not collide |
|
||||
| Slash command transport | Delivered over the WS connection; `slash_commands[].url` is ignored | Slack POSTs to `slash_commands[].url`; field is required for the command to dispatch |
|
||||
| Request signing | Not used (auth is the App-Level Token) | Slack signs every request; OpenClaw verifies with `signingSecret` |
|
||||
| Recovery on connection drop | Slack SDK auto-reconnects; the gateway's pong-timeout transport tuning applies | No persistent connection to drop; retries are per-request from Slack |
|
||||
| Concern | Socket Mode (default) | HTTP Request URLs |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| Public Gateway URL | Not required | Required (DNS, TLS, reverse proxy or tunnel) |
|
||||
| Outbound network | Outbound WSS to `wss-primary.slack.com` must be reachable | No outbound WS; inbound HTTPS only |
|
||||
| Tokens needed | Bot token (`xoxb-...`) + App-Level Token (`xapp-...`) with `connections:write` | Bot token (`xoxb-...`) + Signing Secret |
|
||||
| Dev laptop / behind firewall | Works as-is | Needs a public tunnel (ngrok, Cloudflare Tunnel, Tailscale Funnel) or staging Gateway |
|
||||
| Horizontal scaling | One Socket Mode session per app per host; multiple Gateways need separate Slack apps | Stateless POST handler; multiple Gateway replicas can share one app behind a load balancer |
|
||||
| Multi-account on one Gateway | Supported; each account opens its own WS | Supported; each account needs a unique `webhookPath` (default `/slack/events`) so registrations do not collide |
|
||||
| Slash command transport | Delivered over the WS connection; `slash_commands[].url` is ignored | Slack POSTs to `slash_commands[].url`; field is required for the command to dispatch |
|
||||
| Request signing | Not used (auth is the App-Level Token) | Slack signs every request; OpenClaw verifies with `signingSecret` |
|
||||
| Recovery on connection drop | Slack SDK auto-reconnect is enabled; OpenClaw also restarts failed Socket Mode sessions with bounded backoff. Pong-timeout transport tuning applies. | No persistent connection to drop; retries are per-request from Slack |
|
||||
|
||||
<Note>
|
||||
**Pick Socket Mode** for single-Gateway hosts, dev laptops, and on-prem networks that can reach `*.slack.com` outbound but cannot accept inbound HTTPS.
|
||||
@@ -41,6 +41,16 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
**Pick HTTP Request URLs** when running multiple Gateway replicas behind a load balancer, when outbound WSS is blocked but inbound HTTPS is allowed, or when you already terminate Slack webhooks at a reverse proxy.
|
||||
</Note>
|
||||
|
||||
## Install
|
||||
|
||||
Install Slack before configuring the channel:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/slack
|
||||
```
|
||||
|
||||
`plugins install` registers and enables the plugin. The plugin still does nothing until you configure the Slack app and channel settings below. See [Plugins](/tools/plugin) for general plugin behavior and install rules.
|
||||
|
||||
## Quick setup
|
||||
|
||||
<Tabs>
|
||||
@@ -181,7 +191,7 @@ Both transports are production-ready and reach feature parity for messaging, sla
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
**Recommended** matches the bundled Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
|
||||
**Recommended** matches the Slack plugin's full feature set: App Home, slash commands, files, reactions, pins, group DMs, and emoji/usergroup reads. Pick **Minimal** when workspace policy restricts scopes — it covers DMs, channel/group history, mentions, and slash commands but drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read`. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale and additive options like extra slash commands.
|
||||
</Note>
|
||||
|
||||
After Slack creates the app:
|
||||
@@ -383,7 +393,7 @@ openclaw gateway
|
||||
</CodeGroup>
|
||||
|
||||
<Note>
|
||||
**Recommended** matches the bundled Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
|
||||
**Recommended** matches the Slack plugin's full feature set; **Minimal** drops files, reactions, pins, group-DM (`mpim:*`), `emoji:read`, and `usergroups:read` for restrictive workspaces. See [Manifest and scope checklist](#manifest-and-scope-checklist) for per-scope rationale.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
@@ -462,6 +472,13 @@ OpenClaw sets the Slack SDK client pong timeout to 15 seconds by default for Soc
|
||||
|
||||
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.
|
||||
|
||||
Notes:
|
||||
|
||||
- `socketMode` is ignored in HTTP Request URL mode.
|
||||
- Base `channels.slack.socketMode` settings apply to all Slack accounts unless overridden. Per-account overrides use `channels.slack.accounts.<accountId>.socketMode`; because this is an object override, include every socket tuning field you want for that account.
|
||||
- Only `clientPingTimeout` has an OpenClaw default (`15000`). `serverPingTimeout` and `pingPongLoggingEnabled` are passed to the Slack SDK only when configured.
|
||||
- Socket Mode restart backoff starts around 2 seconds and caps around 30 seconds. Consecutive recoverable start/start-wait failures stop after 12 attempts; after a successful connection, later recoverable disconnects start a fresh retry cycle. Non-recoverable Slack auth errors such as `invalid_auth`, revoked tokens, or missing scopes fail fast instead of retrying forever.
|
||||
|
||||
## 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.
|
||||
@@ -931,8 +948,9 @@ Current Slack message actions include `send`, `upload-file`, `download-file`, `r
|
||||
- Slack route bindings accept raw peer IDs plus Slack target forms such as `channel:C12345678`, `user:U12345678`, and `<@U12345678>`.
|
||||
- With default `session.dmScope=main`, Slack DMs collapse to agent main session.
|
||||
- Channel sessions: `agent:<agentId>:slack:channel:<channelId>`.
|
||||
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
|
||||
- In channels where OpenClaw handles top-level messages without requiring an explicit mention, non-`off` `replyToMode` routes each handled root into `agent:<agentId>:slack:channel:<channelId>:thread:<rootTs>` so the visible Slack thread maps to one OpenClaw session from the first turn.
|
||||
- Ordinary top-level channel messages stay on the per-channel session, even when `replyToMode` is non-`off`.
|
||||
- Slack thread replies use the parent Slack `thread_ts` for session suffixes (`:thread:<threadTs>`), even when outbound reply threading is disabled with `replyToMode="off"`.
|
||||
- OpenClaw seeds an eligible top-level channel root into `agent:<agentId>:slack:channel:<channelId>:thread:<rootTs>` when that root is expected to start a visible Slack thread, so the root and later thread replies share one OpenClaw session. This applies to `app_mention` events, explicit bot or configured mention-pattern matches, and `requireMention: false` channels with non-`off` `replyToMode`.
|
||||
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
|
||||
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
|
||||
- `channels.slack.thread.requireExplicitMention` (default `false`): when `true`, suppress implicit thread mentions so the bot only responds to explicit `@bot` mentions inside threads, even when the bot already participated in the thread. Without this, replies in a bot-participated thread bypass `requireMention` gating.
|
||||
@@ -953,7 +971,7 @@ For explicit Slack thread replies from the `message` tool, set `replyBroadcast:
|
||||
When a `message` tool call runs inside a Slack thread and targets the same channel, OpenClaw normally inherits the current Slack thread according to `replyToMode`. Set `topLevel: true` on `action: "send"` or `action: "upload-file"` to force a new parent-channel message instead. `threadId: null` is accepted as the same top-level opt-out.
|
||||
|
||||
<Note>
|
||||
`replyToMode="off"` disables **all** reply threading in Slack, including explicit `[[reply_to_*]]` tags. This differs from Telegram, where explicit tags are still honored in `"off"` mode. Slack threads hide messages from the channel while Telegram replies stay visible inline.
|
||||
`replyToMode="off"` disables outbound Slack reply threading, including explicit `[[reply_to_*]]` tags. It does not flatten inbound Slack thread sessions: messages already posted inside a Slack thread still route to the `:thread:<threadTs>` session. This differs from Telegram, where explicit tags are still honored in `"off"` mode. Slack threads hide messages from the channel while Telegram replies stay visible inline.
|
||||
</Note>
|
||||
|
||||
## Ack reactions
|
||||
@@ -1258,6 +1276,17 @@ Primary reference: [Configuration reference - Slack](/gateway/config-channels#sl
|
||||
- channel allowlist (`channels.slack.channels`) — **keys must be channel IDs** (`C12345678`), not names (`#channel-name`). Name-based keys silently fail under `groupPolicy: "allowlist"` because channel routing is ID-first by default. To find an ID: right-click the channel in Slack → **Copy link** — the `C...` value at the end of the URL is the channel ID.
|
||||
- `requireMention`
|
||||
- per-channel `users` allowlist
|
||||
- `messages.groupChat.visibleReplies`: if it is `"message_tool"` and logs show assistant text with no `message(action=send)` call, the turn was processed but the final answer was kept private. Set it to `"automatic"` if you want normal assistant final replies posted back to Slack channels.
|
||||
|
||||
```json5
|
||||
{
|
||||
messages: {
|
||||
groupChat: {
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
@@ -1274,7 +1303,8 @@ openclaw doctor
|
||||
|
||||
- `channels.slack.dm.enabled`
|
||||
- `channels.slack.dmPolicy` (or legacy `channels.slack.dm.policy`)
|
||||
- pairing approvals / allowlist entries
|
||||
- pairing approvals / allowlist entries (`dmPolicy: "open"` still requires `channels.slack.allowFrom: ["*"]`)
|
||||
- group DMs use MPIM handling; enable `channels.slack.dm.groupEnabled` and, if configured, include the MPIM in `channels.slack.dm.groupChannels`
|
||||
- Slack Assistant DM events: verbose logs mentioning `drop message_changed`
|
||||
usually mean Slack sent an edited Assistant-thread event without a
|
||||
recoverable human sender in message metadata
|
||||
@@ -1287,12 +1317,19 @@ openclaw pairing list slack
|
||||
|
||||
<Accordion title="Socket mode not connecting">
|
||||
Validate bot + app tokens and Socket Mode enablement in Slack app settings.
|
||||
The `xapp-...` App-Level Token needs `connections:write`, and the `xoxb-...`
|
||||
bot token must belong to the same Slack app/workspace as the app token.
|
||||
|
||||
If `openclaw channels status --probe --json` shows `botTokenStatus` or
|
||||
`appTokenStatus: "configured_unavailable"`, the Slack account is
|
||||
configured but the current runtime could not resolve the SecretRef-backed
|
||||
value.
|
||||
|
||||
Logs such as `slack socket mode failed to start; retry ...` are recoverable
|
||||
start failures. Missing scopes, revoked tokens, and invalid auth fail fast
|
||||
instead. A `slack token mismatch ...` log means the bot token and app token
|
||||
appear to belong to different Slack apps; fix the Slack app credentials.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="HTTP mode not receiving events">
|
||||
@@ -1302,11 +1339,16 @@ openclaw pairing list slack
|
||||
- webhook path
|
||||
- Slack Request URLs (Events + Interactivity + Slash Commands)
|
||||
- unique `webhookPath` per HTTP account
|
||||
- the public URL terminates TLS and forwards requests to the Gateway path
|
||||
- the Slack app `request_url` path exactly matches `channels.slack.webhookPath` (default `/slack/events`)
|
||||
|
||||
If `signingSecretStatus: "configured_unavailable"` appears in account
|
||||
snapshots, the HTTP account is configured but the current runtime could not
|
||||
resolve the SecretRef-backed signing secret.
|
||||
|
||||
A repeated `slack: webhook path ... already registered` log means two HTTP
|
||||
accounts are using the same `webhookPath`; give each account a distinct path.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Native/slash commands not firing">
|
||||
@@ -1315,7 +1357,14 @@ openclaw pairing list slack
|
||||
- native command mode (`channels.slack.commands.native: true`) with matching slash commands registered in Slack
|
||||
- or single slash command mode (`channels.slack.slashCommand.enabled: true`)
|
||||
|
||||
Also check `commands.useAccessGroups` and channel/user allowlists.
|
||||
Slack does not create or remove slash commands automatically. `commands.native: "auto"` does not enable Slack native commands; use `true` and create the matching commands in the Slack app. In HTTP mode, every Slack slash command must include the Gateway URL. In Socket Mode, command payloads arrive over the websocket and Slack ignores `slash_commands[].url`.
|
||||
|
||||
Also check `commands.useAccessGroups`, DM authorization, channel allowlists,
|
||||
and per-channel `users` allowlists. Slack returns ephemeral errors for
|
||||
blocked slash-command senders, including:
|
||||
|
||||
- `This channel is not allowed.`
|
||||
- `You are not authorized to use this command here.`
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -517,7 +517,11 @@ Before a first run, check the wrapper from the repo root:
|
||||
pnpm crabbox:run -- --help | sed -n '1,120p'
|
||||
```
|
||||
|
||||
The repo wrapper refuses a stale Crabbox binary that does not advertise `blacksmith-testbox`. Pass the provider explicitly even though `.crabbox.yaml` has owned-cloud defaults.
|
||||
The repo wrapper refuses a stale Crabbox binary that does not advertise `blacksmith-testbox`. Pass the provider explicitly even though `.crabbox.yaml` has owned-cloud defaults. In Codex worktrees or linked/sparse checkouts, avoid the local `pnpm crabbox:run` script because pnpm may reconcile dependencies before Crabbox starts; invoke the node wrapper directly instead:
|
||||
|
||||
```bash
|
||||
node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox --timing-json --shell -- "pnpm test <path-or-filter>"
|
||||
```
|
||||
|
||||
Changed gate:
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ order and tells you what it chose:
|
||||
- `OPENAI_API_KEY` -> `openai/gpt-5.5`
|
||||
- `ANTHROPIC_API_KEY` -> `anthropic/claude-opus-4-7`
|
||||
- Claude Code CLI -> `claude-cli/claude-opus-4-7`
|
||||
- Codex CLI -> `codex-cli/gpt-5.5`
|
||||
- Codex -> `openai/gpt-5.5` through the Codex app-server harness
|
||||
|
||||
If none are available, setup still writes the default workspace and leaves the
|
||||
model unset. Install or log into Codex/Claude Code, or expose
|
||||
@@ -171,7 +171,6 @@ back to local runtimes already present on the machine:
|
||||
|
||||
- Claude Code CLI: `claude-cli/claude-opus-4-7`
|
||||
- Codex app-server harness: `openai/gpt-5.5`
|
||||
- Codex CLI: `codex-cli/gpt-5.5`
|
||||
|
||||
The model-assisted planner cannot mutate config directly. It must translate the
|
||||
request into one of Crestodian's typed commands, then the normal approval and
|
||||
|
||||
@@ -123,9 +123,10 @@ inventory a specific Codex home.
|
||||
|
||||
Use this provider when moving to the OpenClaw Codex harness and you want to
|
||||
promote useful personal Codex CLI assets deliberately. Local Codex app-server
|
||||
launches use a per-agent `CODEX_HOME`, so they do not read your personal Codex
|
||||
CLI state by default, while subprocesses still inherit the normal process
|
||||
`HOME` unless the app-server launch explicitly overrides it.
|
||||
launches use a per-agent `CODEX_HOME`, so they do not read your personal
|
||||
`~/.codex` by default. The normal process `HOME` is still inherited, so Codex
|
||||
can see shared `$HOME/.agents/*` skills/plugin marketplace entries and
|
||||
subprocesses can find user-home config and tokens.
|
||||
|
||||
Running `openclaw migrate codex` in an interactive terminal previews the full
|
||||
plan, then opens checkbox selectors before the final apply confirmation. Skill
|
||||
|
||||
@@ -97,6 +97,8 @@ This is the agent-facing decision tree:
|
||||
`openai/<model>` with `openclaw doctor --fix`; doctor keeps the Codex auth
|
||||
route by adding provider/model-scoped `agentRuntime.id: "codex"` where the
|
||||
old model ref implied it.
|
||||
Legacy **`codex-cli/*` model refs** repair to the same `openai/<model>` Codex
|
||||
app-server route; OpenClaw no longer keeps a bundled Codex CLI backend.
|
||||
5. If the user explicitly says **ACP**, **acpx**, or **Codex ACP adapter**, use
|
||||
ACP with `runtime: "acp"` and `agentId: "codex"`.
|
||||
6. If the request is for **Claude Code, Gemini CLI, OpenCode, Cursor, Droid, or
|
||||
@@ -180,6 +182,10 @@ Legacy refs such as `claude-cli/claude-opus-4-7` remain supported for
|
||||
compatibility, but new config should keep the provider/model canonical and put
|
||||
the execution backend in provider/model runtime policy.
|
||||
|
||||
Legacy `codex-cli/*` refs are different: doctor migrates them to `openai/*` so
|
||||
they run through the Codex app-server harness instead of preserving a Codex CLI
|
||||
backend.
|
||||
|
||||
`auto` mode is intentionally conservative for most providers. OpenAI agent
|
||||
models are the exception: unset runtime and `auto` both resolve to the Codex
|
||||
harness. Explicit PI runtime config remains an opt-in compatibility route for
|
||||
|
||||
@@ -16,8 +16,11 @@ Your agent has three memory-related files:
|
||||
|
||||
- **`MEMORY.md`** — long-term memory. Durable facts, preferences, and
|
||||
decisions. Loaded at the start of every DM session.
|
||||
- **`memory/YYYY-MM-DD.md`** — daily notes. Running context and observations.
|
||||
Today and yesterday's notes are loaded automatically.
|
||||
- **`memory/YYYY-MM-DD.md`** (or **`memory/YYYY-MM-DD-<slug>.md`**) — daily notes.
|
||||
Running context and observations. Today and yesterday's notes are loaded
|
||||
automatically, and slugged variants such as those written by the bundled
|
||||
session-memory hook on `/new` or `/reset` are now picked up alongside the
|
||||
date-only file.
|
||||
- **`DREAMS.md`** (optional) — Dream Diary and dreaming sweep
|
||||
summaries for human review, including grounded historical backfill entries.
|
||||
|
||||
|
||||
@@ -41,9 +41,9 @@ Reference for **LLM/model providers** (not chat channels like WhatsApp/Telegram)
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="CLI runtimes">
|
||||
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*`, `google/gemini-*`, or `openai/gpt-*`, then set provider/model runtime policy to `claude-cli`, `google-gemini-cli`, or `codex-cli` when you want a local CLI backend.
|
||||
CLI runtimes use the same split: choose canonical model refs such as `anthropic/claude-*` or `google/gemini-*`, then set provider/model runtime policy to `claude-cli` or `google-gemini-cli` when you want a local CLI backend.
|
||||
|
||||
Legacy `claude-cli/*`, `google-gemini-cli/*`, and `codex-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately.
|
||||
Legacy `claude-cli/*` and `google-gemini-cli/*` refs migrate back to canonical provider refs with the runtime recorded separately. Legacy `codex-cli/*` refs migrate to `openai/*` and use the Codex app-server route; OpenClaw no longer keeps a bundled Codex CLI backend.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "CLI backends: local AI CLI fallback with optional MCP tool bridge"
|
||||
read_when:
|
||||
- You want a reliable fallback when API providers fail
|
||||
- You are running Codex CLI or other local AI CLIs and want to reuse them
|
||||
- You are running local AI CLIs and want to reuse them
|
||||
- You want to understand the MCP loopback bridge for CLI backend tool access
|
||||
title: "CLI backends"
|
||||
---
|
||||
@@ -31,11 +31,11 @@ thread/conversation binding, and persistent external coding sessions, use
|
||||
|
||||
## Beginner-friendly quick start
|
||||
|
||||
You can use Codex CLI **without any config** (the bundled OpenAI plugin
|
||||
You can use Claude Code CLI **without any config** (the bundled Anthropic plugin
|
||||
registers a default backend):
|
||||
|
||||
```bash
|
||||
openclaw agent --message "hi" --model codex-cli/gpt-5.5
|
||||
openclaw agent --message "hi" --model claude-cli/claude-sonnet-4-6
|
||||
```
|
||||
|
||||
If your gateway runs under launchd/systemd and PATH is minimal, add just the
|
||||
@@ -46,8 +46,8 @@ command path:
|
||||
agents: {
|
||||
defaults: {
|
||||
cliBackends: {
|
||||
"codex-cli": {
|
||||
command: "/opt/homebrew/bin/codex",
|
||||
"claude-cli": {
|
||||
command: "/opt/homebrew/bin/claude",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -72,11 +72,11 @@ Add a CLI backend to your fallback list so it only runs when primary models fail
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["codex-cli/gpt-5.5"],
|
||||
fallbacks: ["claude-cli/claude-sonnet-4-6"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"codex-cli/gpt-5.5": {},
|
||||
"claude-cli/claude-sonnet-4-6": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -97,7 +97,7 @@ All CLI backends live under:
|
||||
agents.defaults.cliBackends
|
||||
```
|
||||
|
||||
Each entry is keyed by a **provider id** (e.g. `codex-cli`, `my-cli`).
|
||||
Each entry is keyed by a **provider id** (e.g. `claude-cli`, `my-cli`).
|
||||
The provider id becomes the left side of your model ref:
|
||||
|
||||
```
|
||||
@@ -111,9 +111,6 @@ The provider id becomes the left side of your model ref:
|
||||
agents: {
|
||||
defaults: {
|
||||
cliBackends: {
|
||||
"codex-cli": {
|
||||
command: "/opt/homebrew/bin/codex",
|
||||
},
|
||||
"my-cli": {
|
||||
command: "my-cli",
|
||||
args: ["--json"],
|
||||
@@ -149,7 +146,7 @@ The provider id becomes the left side of your model ref:
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Selects a backend** based on the provider prefix (`codex-cli/...`).
|
||||
1. **Selects a backend** based on the provider prefix (`claude-cli/...`).
|
||||
2. **Builds a system prompt** using the same OpenClaw prompt + workspace context.
|
||||
3. **Executes the CLI** with a session id (if supported) so history stays consistent.
|
||||
The bundled `claude-cli` backend keeps a Claude stdio process alive per
|
||||
@@ -164,12 +161,6 @@ told us OpenClaw-style Claude CLI usage is allowed again, so OpenClaw treats
|
||||
a new policy.
|
||||
</Note>
|
||||
|
||||
The bundled OpenAI `codex-cli` backend passes OpenClaw's system prompt through
|
||||
Codex's `model_instructions_file` config override (`-c
|
||||
model_instructions_file="..."`). Codex does not expose a Claude-style
|
||||
`--append-system-prompt` flag, so OpenClaw writes the assembled prompt to a
|
||||
temporary file for each fresh Codex CLI session.
|
||||
|
||||
The bundled Anthropic `claude-cli` backend receives the OpenClaw skills snapshot
|
||||
two ways: the compact OpenClaw skills catalog in the appended system prompt, and
|
||||
a temporary Claude Code plugin passed with `--plugin-dir`. The plugin contains
|
||||
@@ -292,7 +283,7 @@ load local files from plain paths.
|
||||
- `output: "json"` (default) tries to parse JSON and extract text + session id.
|
||||
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and
|
||||
usage from `stats` when `usage` is missing or empty.
|
||||
- `output: "jsonl"` parses JSONL streams (for example Codex CLI `--json`) and extracts the final agent message plus session
|
||||
- `output: "jsonl"` parses JSONL streams and extracts the final agent message plus session
|
||||
identifiers when present.
|
||||
- `output: "text"` treats stdout as the final response.
|
||||
|
||||
@@ -304,16 +295,19 @@ Input modes:
|
||||
|
||||
## Defaults (plugin-owned)
|
||||
|
||||
The bundled OpenAI plugin also registers a default for `codex-cli`:
|
||||
Bundled CLI backend defaults live with their owning plugin. For example,
|
||||
Anthropic owns `claude-cli` and Google owns `google-gemini-cli`. OpenAI Codex
|
||||
agent runs use the Codex app-server harness through `openai/*`; OpenClaw no
|
||||
longer registers a bundled `codex-cli` backend.
|
||||
|
||||
- `command: "codex"`
|
||||
- `args: ["exec","--json","--color","never","--sandbox","workspace-write","--skip-git-repo-check"]`
|
||||
- `resumeArgs: ["exec","resume","{sessionId}","-c","sandbox_mode=\"workspace-write\"","--skip-git-repo-check"]`
|
||||
The bundled Anthropic plugin registers a default for `claude-cli`:
|
||||
|
||||
- `command: "claude"`
|
||||
- `args: ["-p","--output-format","stream-json","--include-partial-messages","--verbose", ...]`
|
||||
- `output: "jsonl"`
|
||||
- `resumeOutput: "text"`
|
||||
- `input: "stdin"`
|
||||
- `modelArg: "--model"`
|
||||
- `imageArg: "--image"`
|
||||
- `sessionMode: "existing"`
|
||||
- `sessionMode: "always"`
|
||||
|
||||
The bundled Google plugin also registers a default for `google-gemini-cli`:
|
||||
|
||||
@@ -383,9 +377,6 @@ opt into a generated MCP config overlay with `bundleMcp: true`.
|
||||
Current bundled behavior:
|
||||
|
||||
- `claude-cli`: generated strict MCP config file
|
||||
- `codex-cli`: inline config overrides for `mcp_servers`; the generated
|
||||
OpenClaw loopback server is marked with Codex's per-server tool approval mode
|
||||
so MCP calls cannot stall on local approval prompts
|
||||
- `google-gemini-cli`: generated Gemini system settings file
|
||||
|
||||
When bundle MCP is enabled, OpenClaw:
|
||||
@@ -414,16 +405,13 @@ children and Streamable HTTP/SSE streams do not outlive the run.
|
||||
- **Streaming is backend-specific.** Some backends stream JSONL; others buffer
|
||||
until exit.
|
||||
- **Structured outputs** depend on the CLI's JSON format.
|
||||
- **Codex CLI sessions** resume via text output (no JSONL), which is less
|
||||
structured than the initial `--json` run. OpenClaw sessions still work
|
||||
normally.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **CLI not found**: set `command` to a full path.
|
||||
- **Wrong model name**: use `modelAliases` to map `provider/model` → CLI model.
|
||||
- **No session continuity**: ensure `sessionArg` is set and `sessionMode` is not
|
||||
`none` (Codex CLI currently cannot resume with JSON output).
|
||||
`none`.
|
||||
- **Images ignored**: set `imageArg` (and verify CLI supports file paths).
|
||||
|
||||
## Related
|
||||
|
||||
@@ -461,8 +461,8 @@ Optional CLI backends for text-only fallback runs (no tool calls). Useful as a b
|
||||
agents: {
|
||||
defaults: {
|
||||
cliBackends: {
|
||||
"codex-cli": {
|
||||
command: "/opt/homebrew/bin/codex",
|
||||
"claude-cli": {
|
||||
command: "/opt/homebrew/bin/claude",
|
||||
},
|
||||
"my-cli": {
|
||||
command: "my-cli",
|
||||
|
||||
@@ -476,7 +476,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.
|
||||
- `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. `clientPingTimeout` defaults to `15000`; `serverPingTimeout` and `pingPongLoggingEnabled` are passed only when configured.
|
||||
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
|
||||
strings or SecretRef objects.
|
||||
- Slack account snapshots expose per-credential source/status fields such as
|
||||
|
||||
@@ -444,6 +444,8 @@ See [Inferred commitments](/concepts/commitments).
|
||||
auth: {
|
||||
mode: "token", // none | token | password | trusted-proxy
|
||||
token: "your-token",
|
||||
// tokenScopes: ["operator.read"], // optional static scopes for trusted device-less token clients
|
||||
// allowPrivilegedTokenScopes: false, // safe default; set true only to allow write/admin tokenScopes on non-loopback binds
|
||||
// password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD
|
||||
// trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth
|
||||
allowTailscale: true,
|
||||
@@ -515,6 +517,8 @@ See [Inferred commitments](/concepts/commitments).
|
||||
- **Auth**: required by default. Non-loopback binds require gateway auth. In practice that means a shared token/password or an identity-aware reverse proxy with `gateway.auth.mode: "trusted-proxy"`. Onboarding wizard generates a token by default.
|
||||
- If both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs), set `gateway.auth.mode` explicitly to `token` or `password`. Startup and service install/repair flows fail when both are configured and mode is unset.
|
||||
- `gateway.auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
|
||||
- `gateway.auth.tokenScopes`: optional static operator scopes for device-less WebSocket clients that authenticate with `gateway.auth.token`. Use this for trusted headless observers that cannot complete device pairing, for example `["operator.read"]` to allow `sessions.messages.subscribe`, `sessions.subscribe`, `logs.tail`, and `channels.status`. Missing or empty keeps device-less token auth scope-less; clients cannot self-grant scopes with `params.scopes`.
|
||||
- `gateway.auth.allowPrivilegedTokenScopes`: permits `gateway.auth.tokenScopes` values beyond `operator.read` when the Gateway is bound to a non-loopback address. Keep this unset unless an external trust boundary protects the shared token.
|
||||
- `gateway.auth.mode: "trusted-proxy"`: delegate browser/user auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). This mode expects a **non-loopback** proxy source by default; same-host loopback reverse proxies require explicit `gateway.auth.trustedProxy.allowLoopback = true`. Internal same-host callers can use `gateway.auth.password` as a local direct fallback; `gateway.auth.token` remains mutually exclusive with trusted-proxy mode.
|
||||
- `gateway.auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`). HTTP API endpoints do **not** use that Tailscale header auth; they follow the gateway's normal HTTP auth mode instead. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `gateway.auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
|
||||
@@ -18,6 +18,7 @@ and an optional `mirror` workspace mode.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenShell plugin installed (`openclaw plugins install @openclaw/openshell-sandbox`)
|
||||
- The `openshell` CLI installed and on `PATH` (or set a custom path via
|
||||
`plugins.entries.openshell.config.command`)
|
||||
- An OpenShell account with sandbox access
|
||||
@@ -25,7 +26,11 @@ and an optional `mirror` workspace mode.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Enable the plugin and set the sandbox backend:
|
||||
1. Install and enable the plugin, then set the sandbox backend:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/openshell-sandbox
|
||||
```
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -102,9 +102,20 @@ own `system.run` exec approval policy.
|
||||
## Shared-secret auth
|
||||
|
||||
Shared gateway token/password auth is treated as trusted operator access for
|
||||
that Gateway. OpenAI-compatible HTTP surfaces and `/tools/invoke` restore the
|
||||
normal full operator default scope set for shared-secret bearer auth, even if a
|
||||
caller sends narrower declared scopes.
|
||||
that Gateway. Device-less WebSocket clients cannot self-grant scopes with
|
||||
`params.scopes`; token-auth clients receive `gateway.auth.tokenScopes` when that
|
||||
field is configured, otherwise they connect with no operator scopes until they
|
||||
use device pairing. Configure `["operator.read"]` for trusted headless observer
|
||||
clients that need `sessions.messages.subscribe` without an interactive pairing
|
||||
flow.
|
||||
|
||||
OpenAI-compatible HTTP surfaces and `/tools/invoke` restore the normal full
|
||||
operator default scope set for shared-secret bearer auth, even if a caller sends
|
||||
narrower declared scopes.
|
||||
|
||||
Privileged `gateway.auth.tokenScopes` values such as `operator.write` or
|
||||
`operator.admin` are accepted on loopback binds. Non-loopback binds require the
|
||||
explicit `gateway.auth.allowPrivilegedTokenScopes` opt-in.
|
||||
|
||||
Identity-bearing modes, such as trusted proxy auth or private-ingress `none`,
|
||||
can still honor explicit declared scopes. Use separate Gateways for real trust
|
||||
|
||||
@@ -233,6 +233,10 @@ Common scopes:
|
||||
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets`
|
||||
(or `operator.admin`).
|
||||
|
||||
Device-less shared-token WebSocket clients do not receive scopes from
|
||||
`params.scopes`. Configure `gateway.auth.tokenScopes` on the Gateway when a
|
||||
trusted headless token client needs static access such as `operator.read`.
|
||||
|
||||
Plugin-registered gateway RPC methods may request their own operator scope, but
|
||||
reserved core admin prefixes (`config.*`, `exec.approvals.*`, `wizard.*`,
|
||||
`update.*`) always resolve to `operator.admin`.
|
||||
@@ -414,7 +418,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
|
||||
<Accordion title="Session control">
|
||||
- `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured.
|
||||
- `sessions.subscribe` and `sessions.unsubscribe` toggle session change event subscriptions for the current WS client.
|
||||
- `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session.
|
||||
- `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. Requires `operator.read`; trusted headless token clients can obtain that scope through `gateway.auth.tokenScopes`.
|
||||
- `sessions.preview` returns bounded transcript previews for specific session keys.
|
||||
- `sessions.describe` returns one Gateway session row for an exact session key.
|
||||
- `sessions.resolve` resolves or canonicalizes a session target.
|
||||
|
||||
@@ -148,7 +148,7 @@ Short version: **keep the Gateway loopback-only** unless you're sure you need a
|
||||
- `gateway.remote.token` / `.password` are client credential sources. They do **not** configure server auth by themselves.
|
||||
- Local call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`, including macOS direct mode. Without a configured or previously stored pin, macOS only pins a first-use certificate after normal system trust passes; self-signed or private-CA gateways that macOS does not already trust need an explicit fingerprint or Remote over SSH.
|
||||
- **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
|
||||
headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints do not
|
||||
use that Tailscale header auth and instead follow the gateway's normal HTTP
|
||||
|
||||
@@ -867,6 +867,27 @@ Set a token so **all** WS clients must authenticate:
|
||||
}
|
||||
```
|
||||
|
||||
Device-less token-auth WebSocket clients do not inherit scopes from their
|
||||
handshake request. If a trusted local/headless observer needs read-only
|
||||
subscriptions without device pairing, configure:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "loopback",
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "your-token",
|
||||
tokenScopes: ["operator.read"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Only use privileged token scopes such as `operator.write` or `operator.admin`
|
||||
on loopback binds, or set `gateway.auth.allowPrivilegedTokenScopes: true` after
|
||||
you have put an external trust boundary around the shared token.
|
||||
|
||||
Doctor can generate one for you: `openclaw doctor --generate-gateway-token`.
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -133,7 +133,7 @@ openclaw models list --json
|
||||
|
||||
</Tip>
|
||||
|
||||
## Live: CLI backend smoke (Claude, Codex, Gemini, or other local CLIs)
|
||||
## Live: CLI backend smoke (Claude, Gemini, or other local CLIs)
|
||||
|
||||
- Test: `src/gateway/gateway-cli-backend.live.test.ts`
|
||||
- Goal: validate the Gateway + agent pipeline using a local CLI backend, without touching your default config.
|
||||
@@ -145,9 +145,9 @@ openclaw models list --json
|
||||
- Default provider/model: `claude-cli/claude-sonnet-4-6`
|
||||
- Command/args/image behavior come from the owning CLI backend plugin metadata.
|
||||
- Overrides (optional):
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.5"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/codex"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_ARGS='["exec","--json","--color","never","--sandbox","read-only","--skip-git-repo-check"]'`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-sonnet-4-6"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_COMMAND="/full/path/to/claude"`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_ARGS='["-p","--output-format","json"]'`
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_PROBE=1` to send a real image attachment (paths are injected into the prompt). Docker recipes default this off unless explicitly requested.
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="--image"` to pass image file paths as CLI args instead of prompt injection.
|
||||
- `OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="repeat"` (or `"list"`) to control how image args are passed when `IMAGE_ARG` is set.
|
||||
@@ -158,8 +158,8 @@ openclaw models list --json
|
||||
Example:
|
||||
|
||||
```bash
|
||||
OPENCLAW_LIVE_CLI_BACKEND=1 \
|
||||
OPENCLAW_LIVE_CLI_BACKEND_MODEL="codex-cli/gpt-5.5" \
|
||||
OPENCLAW_LIVE_CLI_BACKEND=1 \
|
||||
OPENCLAW_LIVE_CLI_BACKEND_MODEL="claude-cli/claude-sonnet-4-6" \
|
||||
pnpm test:live src/gateway/gateway-cli-backend.live.test.ts
|
||||
```
|
||||
|
||||
@@ -186,7 +186,6 @@ Single-provider Docker recipes:
|
||||
```bash
|
||||
pnpm test:docker:live-cli-backend:claude
|
||||
pnpm test:docker:live-cli-backend:claude-subscription
|
||||
pnpm test:docker:live-cli-backend:codex
|
||||
pnpm test:docker:live-cli-backend:gemini
|
||||
```
|
||||
|
||||
@@ -194,9 +193,9 @@ Notes:
|
||||
|
||||
- The Docker runner lives at `scripts/test-live-cli-backend-docker.sh`.
|
||||
- It runs the live CLI-backend smoke inside the repo Docker image as the non-root `node` user.
|
||||
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code`, `@openai/codex`, or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- It resolves CLI smoke metadata from the owning extension, then installs the matching Linux CLI package (`@anthropic-ai/claude-code` or `@google/gemini-cli`) into a cached writable prefix at `OPENCLAW_DOCKER_CLI_TOOLS_DIR` (default: `~/.cache/openclaw/docker-cli-tools`).
|
||||
- `pnpm test:docker:live-cli-backend:claude-subscription` requires portable Claude Code subscription OAuth through either `~/.claude/.credentials.json` with `claudeAiOauth.subscriptionType` or `CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`. It first proves direct `claude -p` in Docker, then runs two Gateway CLI-backend turns without preserving Anthropic API-key env vars. This subscription lane disables the Claude MCP/tool and image probes by default because Claude currently routes third-party app usage through extra-usage billing instead of normal subscription plan limits.
|
||||
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude, Codex, and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
|
||||
- The live CLI-backend smoke now exercises the same end-to-end flow for Claude and Gemini: text turn, image classification turn, then MCP `cron` tool call verified through the gateway CLI.
|
||||
- Claude's default smoke also patches the session from Sonnet to Opus and verifies the resumed session still remembers an earlier note.
|
||||
|
||||
## Live: APNs HTTP/2 proxy reachability
|
||||
|
||||
@@ -81,7 +81,7 @@ node.
|
||||
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`openclaw status --json`).
|
||||
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
|
||||
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
|
||||
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, review the certificate or switch to **Remote over SSH**.
|
||||
- **Dashboard works but Mac capabilities are offline**: this means the app's operator/control connection is healthy, but the companion node connection is not connected or is missing its command surface. Open the menu bar device section and check whether the Mac is `paired · disconnected`. For `wss://*.ts.net` Tailscale Serve endpoints, the app detects stale legacy TLS leaf pins after certificate rotation, clears the stale pin when macOS trusts the new certificate, and retries automatically. If the certificate is not system-trusted or the host is not a Tailscale Serve name, set `gateway.remote.tlsFingerprint` to the expected certificate fingerprint, review the certificate, or switch to **Remote over SSH**.
|
||||
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
|
||||
|
||||
## Notification sounds
|
||||
|
||||
@@ -166,18 +166,23 @@ login instead of inherited child-process env. WebSocket app-server connections
|
||||
do not receive Gateway env API-key fallback; use an explicit auth profile or the
|
||||
remote app-server's own account.
|
||||
|
||||
Stdio app-server launches inherit OpenClaw's process environment by default, but
|
||||
OpenClaw owns the Codex app-server account bridge and sets both `CODEX_HOME` and
|
||||
`HOME` to per-agent directories under that agent's OpenClaw state. Codex's own
|
||||
skill loader reads `$CODEX_HOME/skills` and `$HOME/.agents/skills`, so both
|
||||
values are isolated for local app-server launches. That keeps Codex-native
|
||||
skills, plugins, config, accounts, and thread state scoped to the OpenClaw agent
|
||||
instead of leaking in from the operator's personal Codex CLI home.
|
||||
Stdio app-server launches inherit OpenClaw's process environment by default.
|
||||
OpenClaw owns the Codex app-server account bridge and sets `CODEX_HOME` to a
|
||||
per-agent directory under that agent's OpenClaw state. That keeps Codex config,
|
||||
accounts, plugin cache/data, and thread state scoped to the OpenClaw agent
|
||||
instead of leaking in from the operator's personal `~/.codex` home.
|
||||
|
||||
OpenClaw does not rewrite `HOME` for normal local app-server launches. Codex-run
|
||||
subprocesses such as `openclaw`, `gh`, `git`, cloud CLIs, and shell commands see
|
||||
the normal process home and can find user-home config and tokens. Codex may also
|
||||
discover `$HOME/.agents/skills` and `$HOME/.agents/plugins/marketplace.json`;
|
||||
that `.agents` discovery is intentionally shared with the operator home and is
|
||||
separate from isolated `~/.codex` state.
|
||||
|
||||
OpenClaw plugins and OpenClaw skill snapshots still flow through OpenClaw's own
|
||||
plugin registry and skill loader. Personal Codex CLI assets do not. If you have
|
||||
useful Codex CLI skills or plugins that should become part of an OpenClaw agent,
|
||||
inventory them explicitly:
|
||||
plugin registry and skill loader. Personal Codex `~/.codex` assets do not. If
|
||||
you have useful Codex CLI skills or plugins from a Codex home that should become
|
||||
part of an OpenClaw agent, inventory them explicitly:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run
|
||||
@@ -205,8 +210,9 @@ If a deployment needs additional environment isolation, add those variables to
|
||||
```
|
||||
|
||||
`appServer.clearEnv` only affects the spawned Codex app-server child process.
|
||||
`CODEX_HOME` and `HOME` remain reserved for OpenClaw's per-agent Codex
|
||||
isolation on local launches.
|
||||
OpenClaw removes `CODEX_HOME` and `HOME` from this list during local launch
|
||||
normalization: `CODEX_HOME` stays per-agent, and `HOME` stays inherited so
|
||||
subprocesses can use normal user-home state.
|
||||
|
||||
## Dynamic tools
|
||||
|
||||
|
||||
@@ -177,13 +177,14 @@ Keep provider refs and runtime policy separate:
|
||||
|
||||
Common command routing:
|
||||
|
||||
| User intent | Use |
|
||||
| ------------------------------- | --------------------------------------- |
|
||||
| Attach the current chat | `/codex bind [--cwd <path>]` |
|
||||
| Resume an existing Codex thread | `/codex resume <thread-id>` |
|
||||
| List or filter Codex threads | `/codex threads [filter]` |
|
||||
| Send Codex feedback only | `/codex diagnostics [note]` |
|
||||
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
|
||||
| User intent | Use |
|
||||
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| Attach the current chat | `/codex bind [--cwd <path>]` |
|
||||
| Resume an existing Codex thread | `/codex resume <thread-id>` |
|
||||
| List or filter Codex threads | `/codex threads [filter]` |
|
||||
| Attach an existing Codex CLI session on a paired node | `/codex sessions --host <node> [filter]`, then `/codex resume <session-id> --host <node> --bind here` |
|
||||
| Send Codex feedback only | `/codex diagnostics [note]` |
|
||||
| Start an ACP/acpx task | ACP/acpx session commands, not `/codex` |
|
||||
|
||||
| Use case | Configure | Verify | Notes |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------- | ---------------------------------- |
|
||||
@@ -424,6 +425,13 @@ time when Codex reports one and tries the next ordered auth profile for the same
|
||||
Codex run. When the reset time passes, the subscription profile becomes eligible
|
||||
again without changing the selected `openai/gpt-*` model or Codex runtime.
|
||||
|
||||
For local stdio app-server launches, OpenClaw sets `CODEX_HOME` to a per-agent
|
||||
directory so Codex config, auth/account files, plugin cache/data, and native
|
||||
thread state do not read or write the operator's personal `~/.codex` by
|
||||
default. OpenClaw preserves the normal process `HOME`; Codex-run subprocesses
|
||||
can still find user-home config and tokens, and Codex may discover shared
|
||||
`$HOME/.agents/skills` and `$HOME/.agents/plugins/marketplace.json` entries.
|
||||
|
||||
If a deployment needs additional environment isolation, add those variables to
|
||||
`appServer.clearEnv`:
|
||||
|
||||
@@ -445,6 +453,9 @@ If a deployment needs additional environment isolation, add those variables to
|
||||
```
|
||||
|
||||
`appServer.clearEnv` only affects the spawned Codex app-server child process.
|
||||
OpenClaw removes `CODEX_HOME` and `HOME` from this list during local launch
|
||||
normalization: `CODEX_HOME` stays per-agent, and `HOME` stays inherited so
|
||||
subprocesses can use normal user-home state.
|
||||
|
||||
Codex dynamic tools default to `searchable` loading. OpenClaw does not expose
|
||||
dynamic tools that duplicate Codex-native workspace operations: `read`, `write`,
|
||||
@@ -480,7 +491,7 @@ Supported `appServer` fields:
|
||||
| `url` | unset | WebSocket app-server URL. |
|
||||
| `authToken` | unset | Bearer token for WebSocket transport. |
|
||||
| `headers` | `{}` | Extra WebSocket headers. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. `CODEX_HOME` and `HOME` are reserved for OpenClaw's per-agent Codex isolation on local launches. |
|
||||
| `clearEnv` | `[]` | Extra environment variable names removed from the spawned stdio app-server process after OpenClaw builds its inherited environment. OpenClaw keeps per-agent `CODEX_HOME` and inherited `HOME` for local launches. |
|
||||
| `requestTimeoutMs` | `60000` | Timeout for app-server control-plane calls. |
|
||||
| `turnCompletionIdleTimeoutMs` | `60000` | Quiet window after a turn-scoped Codex app-server request while OpenClaw waits for `turn/completed`. Raise this for slow post-tool or status-only synthesis phases. |
|
||||
| `mode` | `"yolo"` unless local Codex requirements disallow YOLO | Preset for YOLO or guardian-reviewed execution. Local stdio requirements that omit `danger-full-access`, `never` approval, or the `user` reviewer make the implicit default guardian. |
|
||||
|
||||
@@ -388,7 +388,7 @@ Prefer the narrowest metadata that already describes ownership. Use
|
||||
when those fields express the relationship. Use `activation` for extra planner
|
||||
hints that cannot be represented by those ownership fields.
|
||||
Use top-level `cliBackends` for CLI runtime aliases such as `claude-cli`,
|
||||
`codex-cli`, or `google-gemini-cli`; `activation.onAgentHarnesses` is only for
|
||||
`my-cli`, or `google-gemini-cli`; `activation.onAgentHarnesses` is only for
|
||||
embedded agent harness ids that do not already have an ownership field.
|
||||
|
||||
This block is metadata only. It does not register runtime behavior, and it does
|
||||
|
||||
@@ -52,10 +52,7 @@ commands.
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
|
||||
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
|
||||
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
|
||||
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
|
||||
@@ -110,7 +107,6 @@ commands.
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [qianfan](/plugins/reference/qianfan) | Adds Qianfan model provider support to OpenClaw. | `@openclaw/qianfan-provider`<br />included in OpenClaw | providers: qianfan |
|
||||
| [qwen](/plugins/reference/qwen) | Adds Qwen, Qwen Cloud, Model Studio, DashScope model provider support to OpenClaw. | `@openclaw/qwen-provider`<br />included in OpenClaw | providers: qwen, qwencloud, modelstudio, dashscope; contracts: mediaUnderstandingProviders, videoGenerationProviders |
|
||||
@@ -120,7 +116,6 @@ commands.
|
||||
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
|
||||
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
|
||||
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
|
||||
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
|
||||
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
|
||||
| [tavily](/plugins/reference/tavily) | Adds agent-callable tools. Adds web search provider support. | `@openclaw/tavily-plugin`<br />included in OpenClaw | contracts: tools, webSearchProviders; skills |
|
||||
@@ -143,33 +138,38 @@ commands.
|
||||
|
||||
## Official external packages
|
||||
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
|
||||
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
|
||||
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
|
||||
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
|
||||
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
|
||||
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
|
||||
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
|
||||
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
|
||||
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
|
||||
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
|
||||
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
|
||||
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
|
||||
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
|
||||
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
|
||||
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
|
||||
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
|
||||
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
|
||||
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
|
||||
| Plugin | Description | Distribution | Surface |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
|
||||
| [brave](/plugins/reference/brave) | Adds web search provider support. | `@openclaw/brave-plugin`<br />npm; ClawHub | contracts: webSearchProviders |
|
||||
| [codex](/plugins/reference/codex) | Codex app-server harness and Codex-managed GPT model catalog. | `@openclaw/codex`<br />npm; ClawHub | providers: codex; contracts: mediaUnderstandingProviders, migrationProviders |
|
||||
| [diagnostics-otel](/plugins/reference/diagnostics-otel) | OpenClaw diagnostics OpenTelemetry exporter. | `@openclaw/diagnostics-otel`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-otel` | plugin |
|
||||
| [diagnostics-prometheus](/plugins/reference/diagnostics-prometheus) | OpenClaw diagnostics Prometheus exporter. | `@openclaw/diagnostics-prometheus`<br />npm; ClawHub: `clawhub:@openclaw/diagnostics-prometheus` | plugin |
|
||||
| [diffs](/plugins/reference/diffs) | Read-only diff viewer and file renderer for agents. | `@openclaw/diffs`<br />npm; ClawHub | contracts: tools; skills |
|
||||
| [discord](/plugins/reference/discord) | Adds the Discord channel surface for sending and receiving OpenClaw messages. | `@openclaw/discord`<br />npm; ClawHub | channels: discord |
|
||||
| [feishu](/plugins/reference/feishu) | Adds the Feishu channel surface for sending and receiving OpenClaw messages. | `@openclaw/feishu`<br />npm; ClawHub | channels: feishu; contracts: tools; skills |
|
||||
| [google-meet](/plugins/reference/google-meet) | Join Google Meet calls through Chrome or Twilio transports. | `@openclaw/google-meet`<br />npm; ClawHub | contracts: tools |
|
||||
| [googlechat](/plugins/reference/googlechat) | Adds the Google Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/googlechat`<br />npm; ClawHub | channels: googlechat |
|
||||
| [line](/plugins/reference/line) | Adds the LINE channel surface for sending and receiving OpenClaw messages. | `@openclaw/line`<br />npm; ClawHub | channels: line |
|
||||
| [lobster](/plugins/reference/lobster) | Typed workflow tool with resumable approvals. | `@openclaw/lobster`<br />npm; ClawHub | contracts: tools |
|
||||
| [matrix](/plugins/reference/matrix) | Adds the Matrix channel surface for sending and receiving OpenClaw messages. | `@openclaw/matrix`<br />ClawHub: `clawhub:@openclaw/matrix`; npm | channels: matrix |
|
||||
| [memory-lancedb](/plugins/reference/memory-lancedb) | Adds agent-callable tools. | `@openclaw/memory-lancedb`<br />npm; ClawHub | contracts: tools |
|
||||
| [msteams](/plugins/reference/msteams) | Adds the Microsoft Teams channel surface for sending and receiving OpenClaw messages. | `@openclaw/msteams`<br />npm; ClawHub | channels: msteams |
|
||||
| [nextcloud-talk](/plugins/reference/nextcloud-talk) | Adds the Nextcloud Talk channel surface for sending and receiving OpenClaw messages. | `@openclaw/nextcloud-talk`<br />npm; ClawHub | channels: nextcloud-talk |
|
||||
| [nostr](/plugins/reference/nostr) | Adds the Nostr channel surface for sending and receiving OpenClaw messages. | `@openclaw/nostr`<br />npm; ClawHub | channels: nostr |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [qqbot](/plugins/reference/qqbot) | Adds the QQ Bot channel surface for sending and receiving OpenClaw messages. | `@openclaw/qqbot`<br />npm; ClawHub | channels: qqbot; contracts: tools; skills |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [tlon](/plugins/reference/tlon) | Adds the Tlon channel surface for sending and receiving OpenClaw messages. | `@openclaw/tlon`<br />npm; ClawHub | channels: tlon; contracts: tools; skills |
|
||||
| [twitch](/plugins/reference/twitch) | Adds the Twitch channel surface for sending and receiving OpenClaw messages. | `@openclaw/twitch`<br />npm; ClawHub | channels: twitch |
|
||||
| [voice-call](/plugins/reference/voice-call) | Adds agent-callable tools. | `@openclaw/voice-call`<br />npm; ClawHub | contracts: tools |
|
||||
| [whatsapp](/plugins/reference/whatsapp) | Adds the WhatsApp channel surface for sending and receiving OpenClaw messages. | `@openclaw/whatsapp`<br />ClawHub: `clawhub:@openclaw/whatsapp`; npm | channels: whatsapp |
|
||||
| [zalo](/plugins/reference/zalo) | Adds the Zalo channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalo`<br />npm; ClawHub | channels: zalo |
|
||||
| [zalouser](/plugins/reference/zalouser) | Adds the Zalo Personal channel surface for sending and receiving OpenClaw messages. | `@openclaw/zalouser`<br />npm; ClawHub | channels: zalouser; contracts: tools |
|
||||
|
||||
## Source checkout only
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ pnpm plugins:inventory:gen
|
||||
| ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [acpx](/plugins/reference/acpx) | Embedded ACP runtime backend with plugin-owned session and transport management. | `@openclaw/acpx`<br />npm; ClawHub | skills |
|
||||
| [alibaba](/plugins/reference/alibaba) | Adds video generation provider support. | `@openclaw/alibaba-provider`<br />included in OpenClaw | contracts: videoGenerationProviders |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />included in OpenClaw | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />included in OpenClaw | providers: amazon-bedrock-mantle |
|
||||
| [amazon-bedrock](/plugins/reference/amazon-bedrock) | Adds Amazon Bedrock model provider support to OpenClaw. | `@openclaw/amazon-bedrock-provider`<br />npm | providers: amazon-bedrock; contracts: memoryEmbeddingProviders |
|
||||
| [amazon-bedrock-mantle](/plugins/reference/amazon-bedrock-mantle) | Adds Amazon Bedrock Mantle model provider support to OpenClaw. | `@openclaw/amazon-bedrock-mantle-provider`<br />npm | providers: amazon-bedrock-mantle |
|
||||
| [anthropic](/plugins/reference/anthropic) | Adds Anthropic model provider support to OpenClaw. | `@openclaw/anthropic-provider`<br />included in OpenClaw | providers: anthropic; contracts: mediaUnderstandingProviders |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />included in OpenClaw | providers: anthropic-vertex |
|
||||
| [anthropic-vertex](/plugins/reference/anthropic-vertex) | Adds Anthropic Vertex model provider support to OpenClaw. | `@openclaw/anthropic-vertex-provider`<br />npm; ClawHub | providers: anthropic-vertex |
|
||||
| [arcee](/plugins/reference/arcee) | Adds Arcee model provider support to OpenClaw. | `@openclaw/arcee-provider`<br />included in OpenClaw | providers: arcee |
|
||||
| [azure-speech](/plugins/reference/azure-speech) | Azure AI Speech text-to-speech (MP3, native Ogg/Opus voice notes, PCM telephony). | `@openclaw/azure-speech`<br />included in OpenClaw | contracts: speechProviders |
|
||||
| [bonjour](/plugins/reference/bonjour) | Advertise the local OpenClaw gateway over Bonjour/mDNS. | `@openclaw/bonjour`<br />included in OpenClaw | plugin |
|
||||
@@ -93,7 +93,7 @@ pnpm plugins:inventory:gen
|
||||
| [opencode](/plugins/reference/opencode) | Adds OpenCode model provider support to OpenClaw. | `@openclaw/opencode-provider`<br />included in OpenClaw | providers: opencode; contracts: mediaUnderstandingProviders |
|
||||
| [opencode-go](/plugins/reference/opencode-go) | Adds OpenCode Go model provider support to OpenClaw. | `@openclaw/opencode-go-provider`<br />included in OpenClaw | providers: opencode-go; contracts: mediaUnderstandingProviders |
|
||||
| [openrouter](/plugins/reference/openrouter) | Adds OpenRouter model provider support to OpenClaw. | `@openclaw/openrouter-provider`<br />included in OpenClaw | providers: openrouter; contracts: imageGenerationProviders, mediaUnderstandingProviders, speechProviders, videoGenerationProviders |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />included in OpenClaw | plugin |
|
||||
| [openshell](/plugins/reference/openshell) | Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-based command execution. | `@openclaw/openshell-sandbox`<br />npm; ClawHub | plugin |
|
||||
| [perplexity](/plugins/reference/perplexity) | Adds web search provider support. | `@openclaw/perplexity-plugin`<br />included in OpenClaw | contracts: webSearchProviders |
|
||||
| [qa-channel](/plugins/reference/qa-channel) | Adds the QA Channel surface for sending and receiving OpenClaw messages. | `@openclaw/qa-channel`<br />source checkout only | channels: qa-channel |
|
||||
| [qa-lab](/plugins/reference/qa-lab) | OpenClaw QA lab plugin with private debugger UI and scenario runner. | `@openclaw/qa-lab`<br />source checkout only | plugin |
|
||||
@@ -107,7 +107,7 @@ pnpm plugins:inventory:gen
|
||||
| [sglang](/plugins/reference/sglang) | Adds SGLang model provider support to OpenClaw. | `@openclaw/sglang-provider`<br />included in OpenClaw | providers: sglang |
|
||||
| [signal](/plugins/reference/signal) | Adds the Signal channel surface for sending and receiving OpenClaw messages. | `@openclaw/signal`<br />included in OpenClaw | channels: signal |
|
||||
| [skill-workshop](/plugins/reference/skill-workshop) | Captures repeatable workflows as workspace skills, with pending review, safe writes, and skill prompt refresh. | `@openclaw/skill-workshop`<br />included in OpenClaw | contracts: tools |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />included in OpenClaw | channels: slack |
|
||||
| [slack](/plugins/reference/slack) | Adds the Slack channel surface for sending and receiving OpenClaw messages. | `@openclaw/slack`<br />npm; ClawHub | channels: slack |
|
||||
| [stepfun](/plugins/reference/stepfun) | Adds StepFun, StepFun Plan model provider support to OpenClaw. | `@openclaw/stepfun-provider`<br />included in OpenClaw | providers: stepfun, stepfun-plan |
|
||||
| [synology-chat](/plugins/reference/synology-chat) | Adds the Synology Chat channel surface for sending and receiving OpenClaw messages. | `@openclaw/synology-chat`<br />npm; ClawHub | channels: synology-chat |
|
||||
| [synthetic](/plugins/reference/synthetic) | Adds Synthetic model provider support to OpenClaw. | `@openclaw/synthetic-provider`<br />included in OpenClaw | providers: synthetic |
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Amazon Bedrock Mantle model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/amazon-bedrock-mantle-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Amazon Bedrock model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/amazon-bedrock-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds Anthropic Vertex model provider support to OpenClaw.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/anthropic-vertex-provider`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Sandbox backend powered by OpenShell with mirrored local workspaces and SSH-base
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/openshell-sandbox`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Adds the Slack channel surface for sending and receiving OpenClaw messages.
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/slack`
|
||||
- Install route: included in OpenClaw
|
||||
- Install route: npm; ClawHub
|
||||
|
||||
## Surface
|
||||
|
||||
|
||||
@@ -320,9 +320,9 @@ descriptor-backed placeholders for parse-time lazy loading.
|
||||
### CLI backend registration
|
||||
|
||||
`api.registerCliBackend(...)` lets a plugin own the default config for a local
|
||||
AI CLI backend such as `codex-cli`.
|
||||
AI CLI backend such as `claude-cli` or `my-cli`.
|
||||
|
||||
- The backend `id` becomes the provider prefix in model refs like `codex-cli/gpt-5`.
|
||||
- The backend `id` becomes the provider prefix in model refs like `my-cli/gpt-5`.
|
||||
- The backend `config` uses the same shape as `agents.defaults.cliBackends.<id>`.
|
||||
- User config still wins. OpenClaw merges `agents.defaults.cliBackends.<id>` over the
|
||||
plugin default before running the CLI.
|
||||
|
||||
@@ -41,6 +41,13 @@ but new code should not add imports from them: `agent-runtime-test-contracts`,
|
||||
`text-runtime`, and `zod`. Import `zod` directly from `zod` in new plugin code.
|
||||
`plugin-test-runtime` is still an active focused test helper subpath.
|
||||
|
||||
### Reserved bundled plugin helper subpaths
|
||||
|
||||
These subpaths are plugin-owned compatibility surfaces reserved for their owning
|
||||
bundled plugin, not general SDK APIs: `plugin-sdk/codex-mcp-projection` and
|
||||
`plugin-sdk/codex-native-task-runtime`. Cross-owner extension imports are blocked
|
||||
by package contract guardrails.
|
||||
|
||||
### Deprecated unused public subpaths
|
||||
|
||||
These public subpaths existed for at least one month and currently have no
|
||||
@@ -215,6 +222,8 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/runtime` | Broad runtime/logging/backup/plugin-install helpers |
|
||||
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
|
||||
| `plugin-sdk/browser-config` | Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
|
||||
| `plugin-sdk/codex-mcp-projection` | Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Reserved bundled Codex helper for native task mirror/runtime wiring; not for third-party plugins |
|
||||
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
|
||||
| `plugin-sdk/matrix` | Deprecated Matrix compatibility facade for older third-party channel packages; new plugins should import `plugin-sdk/run-command` directly |
|
||||
| `plugin-sdk/mattermost` | Deprecated Mattermost compatibility facade for older third-party channel packages; new plugins should import generic SDK subpaths directly |
|
||||
@@ -361,10 +370,18 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Reserved bundled-helper subpaths">
|
||||
There are currently no reserved bundled-helper SDK subpaths. Owner-specific
|
||||
helpers live inside the owning plugin package, while reusable host contracts
|
||||
use generic SDK subpaths such as `plugin-sdk/gateway-runtime`,
|
||||
`plugin-sdk/security-runtime`, and `plugin-sdk/plugin-config-runtime`.
|
||||
Reserved bundled-helper SDK subpaths are narrow owner-specific surfaces for
|
||||
bundled plugin code. They are tracked in the SDK inventory so package
|
||||
builds and aliasing stay deterministic, but they are not general plugin
|
||||
authoring APIs. New reusable host contracts should use generic SDK subpaths
|
||||
such as `plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, and
|
||||
`plugin-sdk/plugin-config-runtime`.
|
||||
|
||||
| Subpath | Owner and purpose |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/codex-mcp-projection` | Bundled Codex plugin helper for projecting user MCP server config into Codex app-server thread config |
|
||||
| `plugin-sdk/codex-native-task-runtime` | Bundled Codex plugin helper for mirroring Codex app-server native subagents into OpenClaw task state |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ model as `provider/model`.
|
||||
- [xAI](/providers/xai)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
## Additional bundled provider variants
|
||||
## Additional provider variants
|
||||
|
||||
- `anthropic-vertex` - implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `anthropic-vertex` - install `@openclaw/anthropic-vertex-provider` for implicit Anthropic on Google Vertex support when Vertex credentials are available; no separate onboarding auth choice
|
||||
- `copilot-proxy` - local VS Code Copilot Proxy bridge; use `openclaw onboard --auth-choice copilot-proxy`
|
||||
- `google-gemini-cli` - unofficial Gemini CLI OAuth flow; requires a local `gemini` install (`brew install gemini-cli` or `npm install -g @google/gemini-cli`); default model `google-gemini-cli/gemini-3-flash-preview`; use `openclaw onboard --auth-choice google-gemini-cli` or `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ PI runtime config remains available as an opt-in compatibility route. When PI is
|
||||
explicitly selected with an `openai-codex` auth profile, OpenClaw keeps the
|
||||
public model ref as `openai/*` and routes PI internally through the legacy
|
||||
Codex-auth transport. Run `openclaw doctor --fix` to repair stale
|
||||
`openai-codex/*` model refs or old PI session pins that do not come from
|
||||
`openai-codex/*`, `codex-cli/*`, or old PI session pins that do not come from
|
||||
explicit runtime config.
|
||||
</Note>
|
||||
|
||||
@@ -85,7 +85,7 @@ explicit runtime config.
|
||||
| ------------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Chat / Responses | `openai/<model>` model provider | Yes |
|
||||
| Codex subscription models | `openai/<model>` with `openai-codex` OAuth | Yes |
|
||||
| Legacy Codex model refs | `openai-codex/<model>` | Repaired by doctor to `openai/<model>` |
|
||||
| Legacy Codex model refs | `openai-codex/<model>` or `codex-cli/<model>` | Repaired by doctor to `openai/<model>` |
|
||||
| Codex app-server harness | `openai/<model>` with omitted runtime or provider/model `agentRuntime.id: codex` | Yes |
|
||||
| Server-side web search | Native OpenAI Responses tool | Yes, when web search is enabled and no provider pinned |
|
||||
| Images | `image_generate` | Yes |
|
||||
@@ -245,6 +245,7 @@ Choose your preferred auth method and follow the setup steps.
|
||||
| `openai/gpt-5.5` | omitted / provider/model `agentRuntime.id: "codex"` | Native Codex app-server harness | Codex sign-in or ordered `openai` auth profile |
|
||||
| `openai/gpt-5.5` | provider/model `agentRuntime.id: "pi"` | PI embedded runtime with internal Codex-auth transport | Selected `openai-codex` profile |
|
||||
| `openai-codex/gpt-5.5` | repaired by doctor | Legacy route rewritten to `openai/gpt-5.5` | Existing `openai-codex` profile |
|
||||
| `codex-cli/gpt-5.5` | repaired by doctor | Legacy CLI route rewritten to `openai/gpt-5.5` | Codex app-server auth |
|
||||
|
||||
<Warning>
|
||||
Do not configure older `openai-codex/gpt-5.1*`, `openai-codex/gpt-5.2*`, or
|
||||
|
||||
@@ -15,6 +15,7 @@ title: "Tests"
|
||||
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed`: explicit broad changed test run. Use it when a test harness/config/package edit should fall back to Vitest's broader changed-test behavior.
|
||||
- `pnpm changed:lanes`: shows the architectural lanes triggered by the diff against `origin/main`.
|
||||
- `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.
|
||||
- Codex worktrees and linked/sparse checkouts: avoid direct local `pnpm test*`, `pnpm check*`, and `pnpm crabbox:run` unless you have verified pnpm will not reconcile dependencies. For tiny explicit-file proof use `node scripts/run-vitest.mjs <path-or-filter>`; for changed gates or broad proof use `node scripts/crabbox-wrapper.mjs run --provider blacksmith-testbox ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox.
|
||||
- `OPENCLAW_HEAVY_CHECK_LOCK_SCOPE=worktree <local-heavy-check command>`: keeps heavy-check serialization inside the current worktree instead of the Git common dir for commands such as `pnpm check:changed` and targeted `pnpm test ...`. Use it only on high-capacity local hosts when you intentionally run independent checks across linked worktrees.
|
||||
- `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.
|
||||
@@ -42,7 +43,7 @@ title: "Tests"
|
||||
- `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.
|
||||
- `pnpm test:docker:skill-install`: Installs the packed OpenClaw tarball in a bare Docker runner, disables `skills.install.allowUploadedArchives`, resolves a current skill slug from live ClawHub search, installs it through `openclaw skills install`, and verifies `SKILL.md`, `.clawhub/origin.json`, `.clawhub/lock.json`, and `skills info --json`.
|
||||
- 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.
|
||||
- CLI backend live Docker probes can be run as focused lanes, for example `pnpm test:docker:live-cli-backend:claude`, `pnpm test:docker:live-cli-backend:claude:resume`, or `pnpm test:docker:live-cli-backend:claude:mcp`. Gemini has 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, pulls an external Open WebUI image, and is not expected to be CI-stable like the normal unit/e2e suites.
|
||||
- `pnpm test:docker:mcp-channels`: Starts a seeded Gateway container and a second client container that spawns `openclaw mcp serve`, then verifies routed conversation discovery, transcript reads, attachment metadata, live event queue behavior, outbound send routing, and Claude-style channel + permission notifications over the real stdio bridge. The Claude notification assertion reads the raw stdio MCP frames directly so the smoke reflects what the bridge actually emits.
|
||||
- `pnpm test:docker:upgrade-survivor`: Installs the packed OpenClaw tarball over a dirty old-user fixture, runs package update plus non-interactive doctor without live provider or channel keys, then starts a loopback Gateway and checks that agents, channel config, plugin allowlists, workspace/session files, stale legacy plugin dependency state, startup, and RPC status survive.
|
||||
|
||||
@@ -77,7 +77,7 @@ Onboarding starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
|
||||
In interactive token mode, choose default plaintext token storage or opt into SecretRef.
|
||||
Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
|
||||
4. **Channels** — built-in and bundled chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
|
||||
4. **Channels** — built-in and official plugin chat channels such as iMessage, Discord, Feishu, Google Chat, Mattermost, Microsoft Teams, QQ Bot, Signal, Slack, Telegram, WhatsApp, and more.
|
||||
5. **Daemon** — Installs a LaunchAgent (macOS), systemd user unit (Linux/WSL2), or native Windows Scheduled Task with per-user Startup-folder fallback.
|
||||
If token auth requires a token and `gateway.auth.token` is SecretRef-managed, daemon install validates it but does not persist the resolved token into supervisor service environment metadata.
|
||||
If token auth requires a token and the configured token SecretRef is unresolved, daemon install is blocked with actionable guidance.
|
||||
|
||||
@@ -31,9 +31,12 @@ If a skill name conflicts, the highest source wins.
|
||||
|
||||
Codex CLI's native `$CODEX_HOME/skills` directory is not one of these OpenClaw
|
||||
skill roots. In Codex harness mode, local app-server launches use isolated
|
||||
per-agent Codex homes, so personal Codex CLI skills are not loaded implicitly.
|
||||
Use `openclaw migrate codex --dry-run` to inventory them and
|
||||
`openclaw migrate codex` to choose skill directories with an interactive
|
||||
per-agent Codex homes, so skills in the operator's personal `~/.codex/skills`
|
||||
are not loaded implicitly. Codex-native `.agents` discovery uses inherited
|
||||
`HOME` separately; OpenClaw's own skill roots above already include
|
||||
`~/.agents/skills`. Use `openclaw migrate codex --dry-run` to inventory skills
|
||||
from the Codex home, then `openclaw migrate codex` to choose skill directories
|
||||
with an interactive
|
||||
checkbox prompt before copying them into the current OpenClaw agent workspace.
|
||||
For non-interactive runs, repeat `--skill <name>` for the exact skills to copy.
|
||||
|
||||
|
||||
@@ -168,6 +168,20 @@ This disables runtime trajectory capture. `/export-trajectory` can still export
|
||||
the transcript branch, but runtime-only files such as compiled context,
|
||||
provider artifacts, and prompt metadata may be missing.
|
||||
|
||||
## Tune flush timeout
|
||||
|
||||
OpenClaw flushes runtime trajectory sidecars during agent cleanup. The default
|
||||
cleanup timeout is 10,000 ms. On slow disks or large stores, set
|
||||
`OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS` before starting OpenClaw:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_TRAJECTORY_FLUSH_TIMEOUT_MS=30000
|
||||
```
|
||||
|
||||
This controls when OpenClaw logs a `pi-trajectory-flush` timeout and continues.
|
||||
It does not change the trajectory size caps. To tune all agent cleanup steps
|
||||
that do not pass an explicit timeout, set `OPENCLAW_AGENT_CLEANUP_TIMEOUT_MS`.
|
||||
|
||||
## Privacy and limits
|
||||
|
||||
Trajectory bundles are designed for support and debugging, not public posting.
|
||||
|
||||
@@ -73,6 +73,7 @@ Notes:
|
||||
## Sending + delivery
|
||||
|
||||
- Messages are sent to the Gateway; delivery to providers is off by default.
|
||||
- The TUI is an internal source surface like WebChat, not a generic outbound channel. Harnesses that require `tools.message` for visible replies can satisfy the active TUI turn with a targetless `message.send`; explicit provider delivery still uses normal configured channels and never falls back to `lastChannel`.
|
||||
- Turn delivery on:
|
||||
- `/deliver on`
|
||||
- or the Settings panel
|
||||
|
||||
@@ -51,6 +51,7 @@ WebChat has two separate data paths:
|
||||
|
||||
- The session JSONL file is the durable model/runtime transcript. For normal agent runs, Pi persists model-visible `user`, `assistant`, and `toolResult` messages through its session manager. WebChat does not write arbitrary delivery, status, or helper text into that transcript.
|
||||
- Gateway `ReplyPayload` events are the live delivery projection. They can be normalized for WebChat/channel display, block streaming, directive tags, media embedding, TTS/audio flags, and UI fallback behavior. They are not themselves the canonical session log.
|
||||
- Harnesses that require visible replies through `tools.message` still use WebChat as a current-run internal source reply sink. A targetless `message.send` from that active WebChat run is projected into the same chat and mirrored to the session transcript; WebChat does not become a reusable outbound channel and never inherits `lastChannel`.
|
||||
- WebChat injects assistant transcript entries only when the Gateway owns a displayed message outside a normal Pi assistant turn: `chat.inject`, non-agent command replies, aborted partial output, and WebChat-managed media transcript supplements.
|
||||
- `chat.history` reads the stored session transcript and applies WebChat display projection. If live assistant text appears during a run but disappears after history reload, first check whether the raw JSONL contains the assistant text, then whether `chat.history` projection stripped it, then whether the Control UI optimistic-tail merge replaced local delivery state with the persisted snapshot.
|
||||
|
||||
|
||||
@@ -116,6 +116,14 @@ describe("active-memory plugin", () => {
|
||||
config: {
|
||||
current: () => configFile,
|
||||
loadConfig: () => configFile,
|
||||
mutateConfigFile: vi.fn(
|
||||
async ({ mutate }: { mutate: (draft: Record<string, unknown>) => void }) => {
|
||||
const draft = structuredClone(configFile);
|
||||
mutate(draft);
|
||||
configFile = draft;
|
||||
return { changed: true, config: configFile };
|
||||
},
|
||||
),
|
||||
replaceConfigFile: vi.fn(
|
||||
async ({ nextConfig }: { nextConfig: Record<string, unknown> }) => {
|
||||
configFile = nextConfig;
|
||||
@@ -476,7 +484,7 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
|
||||
expect(offResult.text).toBe("Active Memory: off globally.");
|
||||
expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
requireRecord(
|
||||
requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[
|
||||
@@ -576,7 +584,7 @@ describe("active-memory plugin", () => {
|
||||
expect(result.text).toContain("global enable/disable changes require operator.admin");
|
||||
}
|
||||
|
||||
expect(api.runtime.config.replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(api.runtime.config.mutateConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows admin-scoped gateway callers to change global active-memory config", async () => {
|
||||
@@ -595,7 +603,7 @@ describe("active-memory plugin", () => {
|
||||
});
|
||||
|
||||
expect(result.text).toBe("Active Memory: off globally.");
|
||||
expect(api.runtime.config.replaceConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(api.runtime.config.mutateConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
requireRecord(
|
||||
requireRecord(requireRecord(configFile.plugins, "plugins").entries, "entries")[
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock Mantle (OpenAI-compatible) provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.95.2",
|
||||
@@ -15,6 +18,21 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-mantle-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/amazon-bedrock-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Amazon Bedrock provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-bedrock": "3.1045.0",
|
||||
@@ -17,6 +20,21 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/amazon-bedrock-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@openclaw/anthropic-vertex-provider",
|
||||
"version": "2026.5.12-beta.1",
|
||||
"private": true,
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/vertex-sdk": "0.16.0",
|
||||
@@ -15,6 +18,22 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/anthropic-vertex-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.5.12-beta.1"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.5.12-beta.1",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } f
|
||||
|
||||
type AnthropicVertexEffort = NonNullable<AnthropicOptions["effort"]>;
|
||||
type AnthropicVertexAdaptiveEffort = AnthropicVertexEffort | "xhigh";
|
||||
type AnthropicVertexClientOptions = ConstructorParameters<typeof AnthropicVertexSdk>[0];
|
||||
type AnthropicVertexClientOptions = {
|
||||
baseURL?: string;
|
||||
projectId?: string;
|
||||
region: string;
|
||||
};
|
||||
|
||||
export type AnthropicVertexStreamDeps = {
|
||||
AnthropicVertex: new (options: AnthropicVertexClientOptions) => unknown;
|
||||
|
||||
42
extensions/anthropic/cli-catalog.ts
Normal file
42
extensions/anthropic/cli-catalog.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ModelCatalogEntry } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS } from "./cli-constants.js";
|
||||
|
||||
// Claude CLI auth is subscription-backed, so catalog rows only need picker metadata.
|
||||
const CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW = 200_000;
|
||||
|
||||
const CLAUDE_CLI_MODEL_LABELS: Record<string, string> = {
|
||||
"claude-opus-4-7": "Claude Opus 4.7 (Claude CLI)",
|
||||
"claude-opus-4-6": "Claude Opus 4.6 (Claude CLI)",
|
||||
"claude-opus-4-5": "Claude Opus 4.5 (Claude CLI)",
|
||||
"claude-sonnet-4-6": "Claude Sonnet 4.6 (Claude CLI)",
|
||||
"claude-sonnet-4-5": "Claude Sonnet 4.5 (Claude CLI)",
|
||||
"claude-haiku-4-5": "Claude Haiku 4.5 (Claude CLI)",
|
||||
};
|
||||
|
||||
function extractClaudeCliModelIds(): string[] {
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const ref of CLAUDE_CLI_DEFAULT_ALLOWLIST_REFS) {
|
||||
if (!ref.startsWith(`${CLAUDE_CLI_BACKEND_ID}/`)) {
|
||||
continue;
|
||||
}
|
||||
const id = ref.slice(CLAUDE_CLI_BACKEND_ID.length + 1);
|
||||
if (id.length === 0 || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function buildClaudeCliCatalogEntries(): ModelCatalogEntry[] {
|
||||
return extractClaudeCliModelIds().map((id) => ({
|
||||
id,
|
||||
name: CLAUDE_CLI_MODEL_LABELS[id] ?? `${id} (Claude CLI)`,
|
||||
provider: CLAUDE_CLI_BACKEND_ID,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: CLAUDE_CLI_DEFAULT_CONTEXT_WINDOW,
|
||||
}));
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import * as claudeCliAuth from "./cli-auth-seam.js";
|
||||
import { buildAnthropicCliBackend } from "./cli-backend.js";
|
||||
import { buildClaudeCliCatalogEntries } from "./cli-catalog.js";
|
||||
import { buildAnthropicCliMigrationResult } from "./cli-migration.js";
|
||||
import {
|
||||
CLAUDE_CLI_BACKEND_ID,
|
||||
@@ -579,6 +580,8 @@ export function buildAnthropicProvider(): ProviderPlugin {
|
||||
normalizeLowercaseStringOrEmpty(provider) === CLAUDE_CLI_BACKEND_ID
|
||||
? resolveClaudeCliSyntheticAuth()
|
||||
: undefined,
|
||||
// Publish Claude CLI rows through the provider catalog hook.
|
||||
augmentModelCatalog: () => buildClaudeCliCatalogEntries(),
|
||||
buildReplayPolicy: buildAnthropicReplayPolicy,
|
||||
isModernModelRef: ({ modelId }) => matchesAnthropicModernModel(modelId),
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
|
||||
@@ -106,6 +106,34 @@ describe("brave web search provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes legacy top-level apiKey as a Brave-owned compatibility fallback", () => {
|
||||
const apiKey = { source: "env", provider: "default", id: "BRAVE_API_KEY" } as const;
|
||||
const config = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(createBraveWebSearchProvider().getConfiguredCredentialValue?.(config)).toEqual(apiKey);
|
||||
expect(createBraveWebSearchContractProvider().getConfiguredCredentialValue?.(config)).toEqual(
|
||||
apiKey,
|
||||
);
|
||||
expect(createBraveWebSearchProvider().getConfiguredCredentialFallback?.(config)).toEqual({
|
||||
path: "tools.web.search.apiKey",
|
||||
value: apiKey,
|
||||
});
|
||||
expect(
|
||||
createBraveWebSearchContractProvider().getConfiguredCredentialFallback?.(config),
|
||||
).toEqual({
|
||||
path: "tools.web.search.apiKey",
|
||||
value: apiKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("points missing-key users to fetch/browser alternatives", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const provider = createBraveWebSearchProvider();
|
||||
|
||||
@@ -79,6 +79,28 @@ function resolveProviderWebSearchPluginConfig(
|
||||
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
||||
}
|
||||
|
||||
function resolveLegacyTopLevelBraveCredential(
|
||||
config: unknown,
|
||||
): { path: string; value: unknown } | undefined {
|
||||
if (!isRecord(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const tools = isRecord(config.tools) ? config.tools : undefined;
|
||||
const web = isRecord(tools?.web) ? tools.web : undefined;
|
||||
const search = isRecord(web?.search) ? web.search : undefined;
|
||||
if (!search || !("apiKey" in search)) {
|
||||
return undefined;
|
||||
}
|
||||
return { path: "tools.web.search.apiKey", value: search.apiKey };
|
||||
}
|
||||
|
||||
function resolveConfiguredBraveCredential(config: unknown): unknown {
|
||||
return (
|
||||
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey ??
|
||||
resolveLegacyTopLevelBraveCredential(config)?.value
|
||||
);
|
||||
}
|
||||
|
||||
function mergeScopedSearchConfig(
|
||||
searchConfig: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
@@ -148,6 +170,8 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "brave" },
|
||||
}),
|
||||
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
|
||||
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
|
||||
createTool: (ctx) =>
|
||||
createBraveToolDefinition(
|
||||
mergeScopedSearchConfig(
|
||||
|
||||
@@ -3,6 +3,46 @@ import {
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-config-contract";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveLegacyTopLevelBraveCredential(
|
||||
config: unknown,
|
||||
): { path: string; value: unknown } | undefined {
|
||||
if (!isRecord(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const tools = isRecord(config.tools) ? config.tools : undefined;
|
||||
const web = isRecord(tools?.web) ? tools.web : undefined;
|
||||
const search = isRecord(web?.search) ? web.search : undefined;
|
||||
if (!search || !("apiKey" in search)) {
|
||||
return undefined;
|
||||
}
|
||||
return { path: "tools.web.search.apiKey", value: search.apiKey };
|
||||
}
|
||||
|
||||
function resolveProviderWebSearchPluginConfig(
|
||||
config: unknown,
|
||||
pluginId: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!isRecord(config)) {
|
||||
return undefined;
|
||||
}
|
||||
const plugins = isRecord(config.plugins) ? config.plugins : undefined;
|
||||
const entries = isRecord(plugins?.entries) ? plugins.entries : undefined;
|
||||
const entry = isRecord(entries?.[pluginId]) ? entries[pluginId] : undefined;
|
||||
const pluginConfig = isRecord(entry?.config) ? entry.config : undefined;
|
||||
return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined;
|
||||
}
|
||||
|
||||
function resolveConfiguredBraveCredential(config: unknown): unknown {
|
||||
return (
|
||||
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey ??
|
||||
resolveLegacyTopLevelBraveCredential(config)?.value
|
||||
);
|
||||
}
|
||||
|
||||
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const credentialPath = "plugins.entries.brave.config.webSearch.apiKey";
|
||||
|
||||
@@ -23,6 +63,8 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
searchCredential: { type: "top-level" },
|
||||
configuredCredential: { pluginId: "brave" },
|
||||
}),
|
||||
getConfiguredCredentialValue: resolveConfiguredBraveCredential,
|
||||
getConfiguredCredentialFallback: resolveLegacyTopLevelBraveCredential,
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
OpenClawPluginToolContext,
|
||||
OpenClawPluginToolFactory,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
BROWSER_REQUEST_GATEWAY_SCOPE,
|
||||
} from "./src/browser-gateway-contract.js";
|
||||
import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
|
||||
const BROWSER_CLI_DESCRIPTOR = {
|
||||
@@ -107,13 +111,13 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
{ commands: ["browser"], descriptors: [BROWSER_CLI_DESCRIPTOR] },
|
||||
);
|
||||
api.registerGatewayMethod(
|
||||
"browser.request",
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
async (opts) => {
|
||||
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
|
||||
return await handleBrowserGatewayRequest(opts);
|
||||
},
|
||||
{
|
||||
scope: "operator.admin",
|
||||
scope: BROWSER_REQUEST_GATEWAY_SCOPE,
|
||||
},
|
||||
);
|
||||
api.registerService(createLazyBrowserPluginService());
|
||||
|
||||
3
extensions/browser/src/browser-gateway-contract.ts
Normal file
3
extensions/browser/src/browser-gateway-contract.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const BROWSER_REQUEST_GATEWAY_METHOD = "browser.request" as const;
|
||||
export const BROWSER_REQUEST_GATEWAY_SCOPE = "operator.admin" as const;
|
||||
export const BROWSER_REQUEST_GATEWAY_SCOPES = [BROWSER_REQUEST_GATEWAY_SCOPE] as const;
|
||||
@@ -660,6 +660,20 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails node proxy calls cleanly when payloadJSON is malformed", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payloadJSON: "{not json",
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
|
||||
await expect(tool.execute?.("call-1", { action: "status", target: "node" })).rejects.toThrow(
|
||||
"browser proxy failed",
|
||||
);
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a browser doctor report on host", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", { action: "doctor" });
|
||||
|
||||
@@ -316,17 +316,27 @@ async function callBrowserProxy(params: {
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
);
|
||||
const parsed =
|
||||
payload?.payload ??
|
||||
(typeof payload?.payloadJSON === "string" && payload.payloadJSON
|
||||
? (JSON.parse(payload.payloadJSON) as BrowserProxyResult)
|
||||
: null);
|
||||
const parsed = unwrapBrowserProxyPayload(payload);
|
||||
if (!parsed || typeof parsed !== "object" || !("result" in parsed)) {
|
||||
throw new Error("browser proxy failed");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function unwrapBrowserProxyPayload(payload: { payload?: unknown; payloadJSON?: unknown } | null) {
|
||||
if (payload?.payload !== undefined) {
|
||||
return payload.payload;
|
||||
}
|
||||
if (typeof payload?.payloadJSON !== "string" || !payload.payloadJSON.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(payload.payloadJSON) as BrowserProxyResult;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
return await persistBrowserProxyFiles(files);
|
||||
}
|
||||
|
||||
34
extensions/browser/src/cli/browser-cli-shared.test.ts
Normal file
34
extensions/browser/src/cli/browser-cli-shared.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { callGatewayFromCli } from "./core-api.js";
|
||||
|
||||
type CallGatewayFromCliArgs = Parameters<typeof callGatewayFromCli>;
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
callGatewayFromCli: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("./core-api.js", () => ({
|
||||
callGatewayFromCli: gatewayMocks.callGatewayFromCli,
|
||||
}));
|
||||
|
||||
const { callBrowserRequest } = await import("./browser-cli-shared.js");
|
||||
|
||||
describe("callBrowserRequest", () => {
|
||||
beforeEach(() => {
|
||||
gatewayMocks.callGatewayFromCli.mockClear();
|
||||
});
|
||||
|
||||
it("requests the browser.request admin scope explicitly", async () => {
|
||||
await callBrowserRequest(
|
||||
{ json: true },
|
||||
{ method: "GET", path: "/status", query: { profile: "openclaw" } },
|
||||
{ progress: true },
|
||||
);
|
||||
|
||||
const call = gatewayMocks.callGatewayFromCli.mock.calls[0] as unknown as
|
||||
| CallGatewayFromCliArgs
|
||||
| undefined;
|
||||
const extra = call?.[3];
|
||||
expect(extra).toEqual({ progress: true, scopes: ["operator.admin"] });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
BROWSER_REQUEST_GATEWAY_SCOPES,
|
||||
} from "../browser-gateway-contract.js";
|
||||
import { callGatewayFromCli, type GatewayRpcOpts } from "./core-api.js";
|
||||
|
||||
export type BrowserParentOpts = GatewayRpcOpts & {
|
||||
@@ -44,7 +48,7 @@ export async function callBrowserRequest<T>(
|
||||
: undefined;
|
||||
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
|
||||
const payload = await callGatewayFromCli(
|
||||
"browser.request",
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
{ ...opts, timeout },
|
||||
{
|
||||
method: params.method,
|
||||
@@ -53,7 +57,7 @@ export async function callBrowserRequest<T>(
|
||||
body: params.body,
|
||||
timeoutMs: resolvedTimeout,
|
||||
},
|
||||
{ progress: extra?.progress },
|
||||
{ progress: extra?.progress, scopes: [...BROWSER_REQUEST_GATEWAY_SCOPES] },
|
||||
);
|
||||
if (payload === undefined) {
|
||||
throw new Error("Unexpected browser.request response");
|
||||
|
||||
@@ -239,4 +239,26 @@ describe("canvas documents", () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed encoded hosted canvas document paths", async () => {
|
||||
const stateDir = await mkdtemp(path.join(tmpdir(), "openclaw-canvas-documents-"));
|
||||
tempDirs.push(stateDir);
|
||||
const documentId = "cv_malformed";
|
||||
const documentDir = resolveCanvasDocumentDir(documentId, { stateDir });
|
||||
await mkdir(documentDir, { recursive: true });
|
||||
await writeFile(path.join(documentDir, "%E0%A4%A.html"), "literal-percent-name", "utf8");
|
||||
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%E0%A4%A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveCanvasHttpPathToLocalPath(
|
||||
`/__openclaw__/canvas/documents/${documentId}/%25E0%25A4%25A.html`,
|
||||
{ stateDir },
|
||||
),
|
||||
).toBe(path.join(documentDir, "%E0%A4%A.html"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,16 +153,17 @@ export function resolveCanvasHttpPathToLocalPath(
|
||||
}
|
||||
const pathWithoutQuery = trimmed.replace(/[?#].*$/, "");
|
||||
const relative = pathWithoutQuery.slice(prefix.length);
|
||||
const segments = relative
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
} catch {
|
||||
return segment;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
const segments: string[] = [];
|
||||
for (const segment of relative.split("/")) {
|
||||
if (!segment) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
segments.push(decodeURIComponent(segment));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,15 @@ describe("resolveFileWithinRoot", () => {
|
||||
|
||||
it("rejects traversal paths", async () => {
|
||||
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
|
||||
await fs.writeFile(path.join(root, "outside.txt"), "inside-root", "utf8");
|
||||
await expect(resolveFileWithinRoot(root, "/../outside.txt")).resolves.toBeNull();
|
||||
await expect(resolveFileWithinRoot(root, "/%2e%2e%2foutside.txt")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed URL encoding as a missing file", async () => {
|
||||
await withCanvasTemp("openclaw-canvas-resolver-", async (root) => {
|
||||
await expect(resolveFileWithinRoot(root, "/%E0%A4%A")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,11 +9,46 @@ export function normalizeUrlPath(rawPath: string): string {
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
function pathEscapesRoot(decodedPath: string): boolean {
|
||||
let depth = 0;
|
||||
for (const segment of decodedPath.split("/")) {
|
||||
if (segment === "" || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
if (segment === "..") {
|
||||
if (depth === 0) {
|
||||
return true;
|
||||
}
|
||||
depth--;
|
||||
continue;
|
||||
}
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function tryNormalizeUrlPath(rawPath: string): string | null {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(rawPath || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (pathEscapesRoot(decoded)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = path.posix.normalize(decoded);
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
export async function resolveFileWithinRoot(
|
||||
rootReal: string,
|
||||
urlPath: string,
|
||||
): Promise<CanvasOpenResult | null> {
|
||||
const normalized = normalizeUrlPath(urlPath);
|
||||
const normalized = tryNormalizeUrlPath(urlPath);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
const rel = normalized.replace(/^\/+/, "");
|
||||
if (rel.split("/").some((p) => p === "..")) {
|
||||
return null;
|
||||
|
||||
@@ -232,6 +232,10 @@ describe("canvas host", () => {
|
||||
expect(response.body).toContain("v1");
|
||||
expect(response.body).toContain(CANVAS_WS_PATH);
|
||||
|
||||
const malformed = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/%E0%A4%A`);
|
||||
expect(malformed.status).toBe(404);
|
||||
expect(malformed.body).toBe("not found");
|
||||
|
||||
const miss = await captureHandlerResponse(handler, "/");
|
||||
expect(miss.handled).toBe(false);
|
||||
|
||||
@@ -396,6 +400,9 @@ describe("canvas host", () => {
|
||||
const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`);
|
||||
expect(traversalRes.status).toBe(404);
|
||||
expect(traversalRes.body).toBe("not found");
|
||||
const malformedRes = await captureA2uiResponse(`${A2UI_PATH}/%E0%A4%A`);
|
||||
expect(malformedRes.status).toBe(404);
|
||||
expect(malformedRes.body).toBe("not found");
|
||||
const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`);
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(symlinkRes.body).toBe("not found");
|
||||
|
||||
142
extensions/clickclack/src/gateway.test.ts
Normal file
142
extensions/clickclack/src/gateway.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedClickClackAccount } from "./types.js";
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
close = vi.fn(() => {
|
||||
this.emit("close");
|
||||
});
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
client: {
|
||||
me: vi.fn(),
|
||||
events: vi.fn(),
|
||||
websocket: vi.fn(),
|
||||
channelMessages: vi.fn(),
|
||||
directMessages: vi.fn(),
|
||||
thread: vi.fn(),
|
||||
},
|
||||
handleClickClackInbound: vi.fn(),
|
||||
resolveWorkspaceId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./http-client.js", () => ({
|
||||
createClickClackClient: vi.fn(() => mocks.client),
|
||||
}));
|
||||
|
||||
vi.mock("./inbound.js", () => ({
|
||||
handleClickClackInbound: mocks.handleClickClackInbound,
|
||||
}));
|
||||
|
||||
vi.mock("./resolve.js", () => ({
|
||||
resolveWorkspaceId: mocks.resolveWorkspaceId,
|
||||
}));
|
||||
|
||||
import { startClickClackGatewayAccount } from "./gateway.js";
|
||||
|
||||
function createGatewayContext(
|
||||
abortSignal: AbortSignal,
|
||||
): ChannelGatewayContext<ResolvedClickClackAccount> {
|
||||
const setStatus = vi.fn();
|
||||
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return {
|
||||
cfg: {
|
||||
channels: {
|
||||
clickclack: {
|
||||
baseUrl: "https://clickclack.example",
|
||||
token: "test-token",
|
||||
workspace: "main",
|
||||
reconnectMs: 1,
|
||||
},
|
||||
},
|
||||
} as ChannelGatewayContext<ResolvedClickClackAccount>["cfg"],
|
||||
accountId: "default",
|
||||
account: {} as ResolvedClickClackAccount,
|
||||
runtime: {} as ChannelGatewayContext<ResolvedClickClackAccount>["runtime"],
|
||||
abortSignal,
|
||||
log,
|
||||
getStatus: () =>
|
||||
({ accountId: "default" }) as ReturnType<
|
||||
ChannelGatewayContext<ResolvedClickClackAccount>["getStatus"]
|
||||
>,
|
||||
setStatus,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ClickClack gateway", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.client.me.mockResolvedValue({
|
||||
id: "bot-user",
|
||||
display_name: "Bot",
|
||||
handle: "bot",
|
||||
avatar_url: "",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
mocks.client.events.mockResolvedValue([]);
|
||||
mocks.resolveWorkspaceId.mockResolvedValue("workspace-1");
|
||||
mocks.client.channelMessages.mockResolvedValue([
|
||||
{
|
||||
id: "msg-1",
|
||||
workspace_id: "workspace-1",
|
||||
channel_id: "chan-1",
|
||||
author_id: "human-1",
|
||||
thread_root_id: "msg-1",
|
||||
body: "hello",
|
||||
body_format: "markdown",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
author: {
|
||||
id: "human-1",
|
||||
kind: "human",
|
||||
display_name: "Human",
|
||||
handle: "human",
|
||||
avatar_url: "",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips malformed websocket frames without stopping the monitor", async () => {
|
||||
const socket = new FakeSocket();
|
||||
mocks.client.websocket.mockReturnValue(socket);
|
||||
const abort = new AbortController();
|
||||
const ctx = createGatewayContext(abort.signal);
|
||||
let runError: unknown;
|
||||
const run = startClickClackGatewayAccount(ctx).catch((error: unknown) => {
|
||||
runError = error;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
socket.emit("message", Buffer.from("{not json"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(runError).toBeUndefined();
|
||||
expect(ctx.log?.warn).toHaveBeenCalledWith(
|
||||
"[default] skipped malformed ClickClack websocket event",
|
||||
);
|
||||
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "evt-1",
|
||||
cursor: "cursor-1",
|
||||
type: "message.created",
|
||||
workspace_id: "workspace-1",
|
||||
channel_id: "chan-1",
|
||||
seq: 2,
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
payload: { message_id: "msg-1", author_id: "human-1" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.handleClickClackInbound).toHaveBeenCalledTimes(1));
|
||||
abort.abort();
|
||||
await run;
|
||||
expect(runError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,14 @@ function decodeSocketMessage(data: RawData): string {
|
||||
return Buffer.concat(data).toString("utf8");
|
||||
}
|
||||
|
||||
function parseSocketEvent(data: RawData): ClickClackEvent | null {
|
||||
try {
|
||||
return JSON.parse(decodeSocketMessage(data)) as ClickClackEvent;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function processEvent(params: {
|
||||
account: ResolvedClickClackAccount;
|
||||
config: CoreConfig;
|
||||
@@ -146,7 +154,11 @@ export async function startClickClackGatewayAccount(
|
||||
ctx.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
socket.on("message", (data) => {
|
||||
void (async () => {
|
||||
const event = JSON.parse(decodeSocketMessage(data)) as ClickClackEvent;
|
||||
const event = parseSocketEvent(data);
|
||||
if (!event) {
|
||||
ctx.log?.warn?.(`[${account.accountId}] skipped malformed ClickClack websocket event`);
|
||||
return;
|
||||
}
|
||||
afterCursor = event.cursor || afterCursor;
|
||||
await processEvent({
|
||||
account,
|
||||
|
||||
@@ -10,6 +10,13 @@ import {
|
||||
handleCodexConversationInboundClaim,
|
||||
} from "./src/conversation-binding.js";
|
||||
import { buildCodexMigrationProvider } from "./src/migration/provider.js";
|
||||
import {
|
||||
createCodexCliSessionNodeHostCommands,
|
||||
createCodexCliSessionNodeInvokePolicies,
|
||||
listCodexCliSessionsOnNode,
|
||||
resumeCodexCliSessionOnNode,
|
||||
resolveCodexCliSessionForBindingOnNode,
|
||||
} from "./src/node-cli-sessions.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "codex",
|
||||
@@ -30,10 +37,28 @@ export default definePluginEntry({
|
||||
buildCodexMediaUnderstandingProvider({ pluginConfig: api.pluginConfig }),
|
||||
);
|
||||
api.registerMigrationProvider(buildCodexMigrationProvider({ runtime: api.runtime }));
|
||||
api.registerCommand(createCodexCommand({ pluginConfig: api.pluginConfig }));
|
||||
for (const command of createCodexCliSessionNodeHostCommands()) {
|
||||
api.registerNodeHostCommand(command);
|
||||
}
|
||||
for (const policy of createCodexCliSessionNodeInvokePolicies()) {
|
||||
api.registerNodeInvokePolicy(policy);
|
||||
}
|
||||
api.registerCommand(
|
||||
createCodexCommand({
|
||||
pluginConfig: api.pluginConfig,
|
||||
deps: {
|
||||
listCodexCliSessionsOnNode: (params) =>
|
||||
listCodexCliSessionsOnNode({ runtime: api.runtime, ...params }),
|
||||
resolveCodexCliSessionForBindingOnNode: (params) =>
|
||||
resolveCodexCliSessionForBindingOnNode({ runtime: api.runtime, ...params }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
api.on("inbound_claim", (event, ctx) =>
|
||||
handleCodexConversationInboundClaim(event, ctx, {
|
||||
pluginConfig: resolveCurrentPluginConfig(),
|
||||
resumeCodexCliSessionOnNode: (params) =>
|
||||
resumeCodexCliSessionOnNode({ runtime: api.runtime, ...params }),
|
||||
}),
|
||||
);
|
||||
api.onConversationBindingResolved?.(handleCodexConversationBindingResolved);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
bridgeCodexAppServerStartOptions,
|
||||
refreshCodexAppServerAuthTokens,
|
||||
resolveCodexAppServerAuthAccountCacheKey,
|
||||
resolveCodexAppServerAuthProfileId,
|
||||
resolveCodexAppServerHomeDir,
|
||||
resolveCodexAppServerNativeHomeDir,
|
||||
} from "./auth-bridge.js";
|
||||
@@ -651,6 +652,65 @@ describe("bridgeCodexAppServerStartOptions", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("selects an oauthRef-backed Codex profile for app-server login", () => {
|
||||
expect(
|
||||
resolveCodexAppServerAuthProfileId({
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai-codex:default": {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "",
|
||||
refresh: "",
|
||||
expires: Date.now() + 60_000,
|
||||
oauthRef: {
|
||||
source: "openclaw-credentials",
|
||||
provider: "openai-codex",
|
||||
id: "0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("openai-codex:default");
|
||||
});
|
||||
|
||||
it("answers refresh requests from a discovered oauthRef-backed Codex profile", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({
|
||||
access: "refreshed-ref-backed-access-token",
|
||||
refresh: "refreshed-ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed-refreshed",
|
||||
});
|
||||
try {
|
||||
upsertAuthProfile({
|
||||
agentDir,
|
||||
profileId: "openai-codex:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "ref-backed-access-token",
|
||||
refresh: "ref-backed-refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
accountId: "account-ref-backed",
|
||||
email: "codex@example.test",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(refreshCodexAppServerAuthTokens({ agentDir })).resolves.toEqual({
|
||||
accessToken: "refreshed-ref-backed-access-token",
|
||||
chatgptAccountId: "account-ref-backed-refreshed",
|
||||
chatgptPlanType: null,
|
||||
});
|
||||
|
||||
expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("ref-backed-refresh-token");
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies native Codex CLI OAuth when no OpenClaw auth profile exists", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-"));
|
||||
const agentDir = path.join(root, "agent");
|
||||
|
||||
@@ -127,6 +127,42 @@ describe("CodexAppServerClient", () => {
|
||||
await expect(request).rejects.toHaveProperty("message", "Method not found");
|
||||
});
|
||||
|
||||
it("surfaces relogin details from Codex app-server RPC errors", async () => {
|
||||
const harness = createClientHarness();
|
||||
clients.push(harness.client);
|
||||
|
||||
const request = harness.client.request("thread/start", {});
|
||||
const outbound = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({
|
||||
id: outbound.id,
|
||||
error: {
|
||||
code: -32602,
|
||||
message: "failed to load configuration",
|
||||
data: {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(request).rejects.toHaveProperty(
|
||||
"message",
|
||||
"failed to load configuration: Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
);
|
||||
await expect(request).rejects.toHaveProperty("data", {
|
||||
reason: "cloudRequirements",
|
||||
errorCode: "Auth",
|
||||
action: "relogin",
|
||||
statusCode: 401,
|
||||
detail:
|
||||
"Your authentication session could not be refreshed automatically. Please log out and sign in again.",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects timed-out requests and ignores late responses", async () => {
|
||||
vi.useFakeTimers();
|
||||
const harness = createClientHarness();
|
||||
|
||||
@@ -42,13 +42,39 @@ export class CodexAppServerRpcError extends Error {
|
||||
readonly data?: JsonValue;
|
||||
|
||||
constructor(error: { code?: number; message: string; data?: JsonValue }, method: string) {
|
||||
super(error.message || `${method} failed`);
|
||||
super(formatCodexAppServerRpcErrorMessage(error, method));
|
||||
this.name = "CodexAppServerRpcError";
|
||||
this.code = error.code;
|
||||
this.data = error.data;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCodexAppServerRpcErrorMessage(
|
||||
error: { message: string; data?: JsonValue },
|
||||
method: string,
|
||||
): string {
|
||||
const message = error.message || `${method} failed`;
|
||||
const detail = readCodexAppServerRpcReloginDetail(error.data);
|
||||
return detail && !message.includes(detail) ? `${message}: ${detail}` : message;
|
||||
}
|
||||
|
||||
function readCodexAppServerRpcReloginDetail(data: JsonValue | undefined): string | undefined {
|
||||
const record = isJsonObject(data) ? data : undefined;
|
||||
const nested = isJsonObject(record?.error) ? record.error : record;
|
||||
if (!nested) {
|
||||
return undefined;
|
||||
}
|
||||
const isRelogin =
|
||||
nested.action === "relogin" ||
|
||||
(nested.reason === "cloudRequirements" && nested.errorCode === "Auth");
|
||||
const detail = typeof nested.detail === "string" ? nested.detail.trim() : "";
|
||||
return isRelogin && detail ? detail : undefined;
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is { [key: string]: JsonValue } {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function isCodexAppServerConnectionClosedError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
|
||||
@@ -320,6 +320,37 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("records internal UI source replies separately from outbound messaging evidence", async () => {
|
||||
const toolResult = textToolResult("Sent to current chat.", {
|
||||
status: "ok",
|
||||
deliveryStatus: "sent",
|
||||
sourceReplySink: "internal-ui",
|
||||
sourceReply: {
|
||||
text: "visible reply",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", toolResult);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "<think>private</think>visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent to current chat."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(bridge.telemetry.messagingToolSentTexts).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentMediaUrls).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([]);
|
||||
expect(bridge.telemetry.messagingToolSourceReplyPayloads).toEqual([
|
||||
{
|
||||
text: "visible reply",
|
||||
mediaUrl: "/tmp/reply.png",
|
||||
mediaUrls: ["/tmp/reply.png"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user