mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-12 17:23:20 +08:00
Compare commits
3 Commits
codex/refa
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e6efb0fb | ||
|
|
476ea0d097 | ||
|
|
4be2c52041 |
@@ -30,7 +30,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
|
||||
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
|
||||
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
|
||||
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- For Windows same-guest update checks, prefer the done-file/log-drain PowerShell runner pattern over one long-lived `prlctl exec ... powershell -EncodedCommand ...` transport. The guest can finish successfully while the outer `prlctl exec` still hangs.
|
||||
- The Windows same-guest update helper should write stage markers to its log before long steps like tgz download and `npm install -g` so the outer progress monitor does not sit on `waiting for first log line` during healthy but quiet installs.
|
||||
@@ -47,7 +46,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
- Default to the snapshot closest to `macOS 26.3.1 latest`.
|
||||
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
|
||||
- `parallels-macos-smoke.sh` now retries `snapshot-switch` once after force-stopping a stuck running/suspended guest. If Tahoe still times out after that recovery path, then treat it as a real Parallels/host issue and rerun manually.
|
||||
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
|
||||
- If a packaged install regresses with `500` on `/`, `/healthz`, or `__openclaw/control-ui-config.json` after `fresh.install-main` or `upgrade.install-main`, suspect bundled plugin runtime deps resolving from the package root `node_modules` rather than `dist/extensions/*/node_modules`. Repro quickly with a real `npm pack`/global install lane before blaming dashboard auth or Safari.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
@@ -66,11 +64,8 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Use PowerShell only as the transport with `-ExecutionPolicy Bypass`, then call the `.cmd` shims from inside it.
|
||||
- Multi-word `openclaw agent --message ...` checks should call `& $openclaw ...` inside PowerShell, not `Start-Process ... -ArgumentList` against `openclaw.cmd`, or Commander can see split argv and throw `too many arguments for 'agent'`.
|
||||
- Windows installer/tgz phases now retry once after guest-ready recheck; keep new Windows smoke steps idempotent so a transport-flake retry is safe.
|
||||
- If a Windows retry sees the VM become `suspended` or `stopped`, resume/start it before the next `prlctl exec`; otherwise the second attempt just repeats the same `rc=255`.
|
||||
- Windows global `npm install -g` phases can stay quiet for a minute or more even when healthy; inspect the phase log before calling it hung, and only treat it as a regression once the retry wrapper or timeout trips.
|
||||
- Fresh Windows tgz install phases should also use the background PowerShell runner plus done-file/log-drain pattern; do not rely on one long-lived `prlctl exec ... powershell ... npm install -g` transport for package installs.
|
||||
- Fresh Windows ref-mode onboard should use the same background PowerShell runner plus done-file/log-drain pattern as the npm-update helper, including startup materialization checks, host-side timeouts on short poll `prlctl exec` calls, and retry-on-poll-failure behavior for transient transport flakes.
|
||||
- Fresh Windows daemon-health reachability should use a hello-only gateway probe and a longer per-probe timeout than the default local attach path; full health RPCs are too eager during initial startup on current main.
|
||||
- Fresh Windows ref-mode agent verification should set `OPENAI_API_KEY` in the PowerShell environment before invoking `openclaw.cmd agent`, for the same pairing-required fallback reason as macOS.
|
||||
- The standalone Windows upgrade smoke lane should stop the managed gateway after `upgrade.install-main` and before `upgrade.onboard-ref`. Restarting before onboard can leave the old process alive on the pre-onboard token while onboard rewrites `~/.openclaw/openclaw.json`, which then fails `gateway-health` with `unauthorized: gateway token mismatch`.
|
||||
- If standalone Windows upgrade fails with a gateway token mismatch but `pnpm test:parallels:npm-update` passes, trust the mismatch as a standalone ref-onboard ordering bug first; the npm-update helper does not re-run ref-mode onboard on the same guest.
|
||||
@@ -87,7 +82,6 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Fresh `main` tgz smoke still needs the latest-release installer first because the snapshot has no Node or npm before bootstrap.
|
||||
- This snapshot does not have a usable `systemd --user` session; managed daemon install is unsupported.
|
||||
- The Linux smoke now falls back to a manual `setsid openclaw gateway run --bind loopback --port 18789 --force` launch with `HOME=/root` and the provider secret exported, then verifies `gateway status --deep --require-rpc` when available.
|
||||
- The Linux manual gateway launch should wait for `gateway status --deep --require-rpc` inside the `gateway-start` phase; otherwise the first status probe can race the background bind and fail a healthy lane.
|
||||
- If Linux gateway bring-up fails, inspect `/tmp/openclaw-parallels-linux-gateway.log` in the guest phase logs first; the common failure mode is a missing provider secret in the launched gateway environment.
|
||||
|
||||
## Discord roundtrip
|
||||
|
||||
@@ -55,8 +55,6 @@ Check in this order:
|
||||
- Was it fixed before release?
|
||||
3. Exploit path
|
||||
- Does the report show a real boundary bypass, not just prompt injection, local same-user control, or helper-level semantics?
|
||||
- If data only moves between trusted workspace-memory files called out in `SECURITY.md`, do not treat "injection markers" alone as a security bug.
|
||||
- In that case, frame sanitization as optional hardening only if it preserves expected memory workflows.
|
||||
4. Functional tradeoff
|
||||
- If a hardening change would reduce intended user functionality, call that out before proposing it.
|
||||
- Prefer fixes that preserve user workflows over deny-by-default regressions unless the boundary demands it.
|
||||
@@ -106,6 +104,5 @@ gh search prs --repo openclaw/openclaw --match title,body,comments -- "<terms>"
|
||||
- “fixed on main, unreleased” is usually not a close.
|
||||
- “needs attacker-controlled trusted local state first” is usually out of scope.
|
||||
- “same-host same-user process can already read/write local state” is usually out of scope.
|
||||
- “trusted workspace memory promotes/reindexes trusted workspace memory” is usually out of scope unless it crosses a documented boundary.
|
||||
- “helper function behaves differently than documented config semantics” is usually invalid.
|
||||
- If only the severity is wrong but the bug is real, keep it open and narrow the impact in the reply.
|
||||
|
||||
2
.github/actions/setup-node-env/action.yml
vendored
2
.github/actions/setup-node-env/action.yml
vendored
@@ -14,7 +14,7 @@ inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version for corepack.
|
||||
required: false
|
||||
default: "10.32.1"
|
||||
default: "10.23.0"
|
||||
install-bun:
|
||||
description: Whether to install Bun alongside Node.
|
||||
required: false
|
||||
|
||||
@@ -4,7 +4,7 @@ inputs:
|
||||
pnpm-version:
|
||||
description: pnpm version to activate via corepack.
|
||||
required: false
|
||||
default: "10.32.1"
|
||||
default: "10.23.0"
|
||||
cache-key-suffix:
|
||||
description: Suffix appended to the cache key.
|
||||
required: false
|
||||
|
||||
11
.github/labeler.yml
vendored
11
.github/labeler.yml
vendored
@@ -64,17 +64,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qqbot/**"
|
||||
- "docs/channels/qqbot.md"
|
||||
"channel: qa-channel":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-channel/**"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"extensions: qa-lab":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qa-lab/**"
|
||||
- "docs/concepts/qa-e2e-automation.md"
|
||||
- "docs/channels/qa-channel.md"
|
||||
"channel: signal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
8
.github/pull_request_template.md
vendored
8
.github/pull_request_template.md
vendored
@@ -35,17 +35,19 @@ If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Root Cause (if applicable)
|
||||
## Root Cause / Regression History (if applicable)
|
||||
|
||||
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
|
||||
|
||||
- Root cause:
|
||||
- Missing detection / guardrail:
|
||||
- Contributing context (if known):
|
||||
- Prior context (`git blame`, prior PR, issue, or refactor if known):
|
||||
- Why this regressed now:
|
||||
- If unknown, what was ruled out:
|
||||
|
||||
## Regression Test Plan (if applicable)
|
||||
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should catch this. Otherwise write `N/A`.
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
|
||||
|
||||
- Coverage level that should have caught this:
|
||||
- [ ] Unit test
|
||||
|
||||
8
.github/workflows/auto-response.yml
vendored
8
.github/workflows/auto-response.yml
vendored
@@ -407,12 +407,8 @@ jobs:
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(badBarnacleLabel)) {
|
||||
core.info(`Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
// `bad-barnacle` exempts PRs that Barnacle incorrectly marked dirty.
|
||||
if (labelSet.has(dirtyLabel) && !labelSet.has(badBarnacleLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -1,7 +1,5 @@
|
||||
name: CI
|
||||
|
||||
# Keep PR CI synchronized on branch updates.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -547,6 +545,7 @@ jobs:
|
||||
echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
||||
if [ "$TASK" = "channels" ]; then
|
||||
echo "OPENCLAW_VITEST_MAX_WORKERS=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_ISOLATE=1" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download dist artifact
|
||||
@@ -745,11 +744,6 @@ jobs:
|
||||
continue-on-error: true
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
- name: Check control UI locale sync
|
||||
id: control_ui_i18n
|
||||
continue-on-error: true
|
||||
run: pnpm ui:i18n:check
|
||||
|
||||
- name: Run gateway watch regression harness
|
||||
id: gateway_watch_regression
|
||||
continue-on-error: true
|
||||
@@ -782,7 +776,6 @@ jobs:
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
|
||||
EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME: ${{ steps.extension_relative_outside_package_boundary.outcome }}
|
||||
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
|
||||
CONTROL_UI_I18N_OUTCOME: ${{ steps.control_ui_i18n.outcome }}
|
||||
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
|
||||
run: |
|
||||
failures=0
|
||||
@@ -803,7 +796,6 @@ jobs:
|
||||
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
|
||||
"extension-relative-outside-package-boundary|$EXTENSION_RELATIVE_OUTSIDE_PACKAGE_BOUNDARY_OUTCOME" \
|
||||
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
|
||||
"ui:i18n:check|$CONTROL_UI_I18N_OUTCOME" \
|
||||
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
|
||||
name="${result%%|*}"
|
||||
outcome="${result#*|}"
|
||||
@@ -958,7 +950,7 @@ jobs:
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.32.1"
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node24"
|
||||
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
||||
# before install while still yielding zero pnpm store reuse.
|
||||
|
||||
172
.github/workflows/control-ui-locale-refresh.yml
vendored
172
.github/workflows/control-ui-locale-refresh.yml
vendored
@@ -1,172 +0,0 @@
|
||||
name: Control UI Locale Refresh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ui/src/i18n/locales/en.ts
|
||||
- ui/src/i18n/locales/*.ts
|
||||
- ui/src/i18n/.i18n/*
|
||||
- ui/src/i18n/lib/types.ts
|
||||
- ui/src/i18n/lib/registry.ts
|
||||
- scripts/control-ui-i18n.ts
|
||||
- .github/workflows/control-ui-locale-refresh.yml
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: control-ui-locale-refresh
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
has_locales: ${{ steps.plan.outputs.has_locales }}
|
||||
locales_json: ${{ steps.plan.outputs.locales_json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Plan locale matrix
|
||||
id: plan
|
||||
env:
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
all_locales_json='["zh-CN","zh-TW","pt-BR","de","es","ja-JP","ko","fr","tr","uk","id","pl"]'
|
||||
|
||||
if [ "$EVENT_NAME" != "push" ]; then
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
before_ref="$BEFORE_SHA"
|
||||
if [ -z "$before_ref" ] || [ "$before_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
before_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
|
||||
changed_files="$(git diff --name-only "$before_ref" HEAD)"
|
||||
echo "changed files:"
|
||||
printf '%s\n' "$changed_files"
|
||||
|
||||
if printf '%s\n' "$changed_files" | grep -Eq '^(ui/src/i18n/locales/en\.ts|ui/src/i18n/lib/types\.ts|ui/src/i18n/lib/registry\.ts|scripts/control-ui-i18n\.ts|\.github/workflows/control-ui-locale-refresh\.yml)$'; then
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$all_locales_json" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
locales_json="$(printf '%s\n' "$changed_files" | node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const changed = fs.readFileSync(0, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
const locales = new Set();
|
||||
for (const file of changed) {
|
||||
let match = file.match(/^ui\/src\/i18n\/locales\/(.+)\.ts$/);
|
||||
if (match && match[1] !== "en") {
|
||||
locales.add(match[1]);
|
||||
continue;
|
||||
}
|
||||
match = file.match(/^ui\/src\/i18n\/\.i18n\/(.+)\.(?:meta\.json|tm\.jsonl)$/);
|
||||
if (match) {
|
||||
locales.add(match[1]);
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify([...locales]));
|
||||
EOF
|
||||
)"
|
||||
|
||||
if [ "$locales_json" = "[]" ]; then
|
||||
echo "has_locales=false" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "has_locales=true" >> "$GITHUB_OUTPUT"
|
||||
echo "locales_json=$locales_json" >> "$GITHUB_OUTPUT"
|
||||
|
||||
refresh:
|
||||
needs: plan
|
||||
if: github.repository == 'openclaw/openclaw' && needs.plan.outputs.has_locales == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
locale: ${{ fromJson(needs.plan.outputs.locales_json) }}
|
||||
runs-on: ubuntu-latest
|
||||
name: Refresh ${{ matrix.locale }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure translation provider secrets exist
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Refresh control UI locale files
|
||||
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: gpt-5.4
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${{ matrix.locale }}" --write
|
||||
|
||||
- name: Commit and push locale updates
|
||||
env:
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- ui/src/i18n; then
|
||||
echo "No control UI locale changes for ${LOCALE}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A ui/src/i18n
|
||||
FAST_COMMIT=1 git commit -m "chore(ui): refresh ${LOCALE} control ui locale"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin "${TARGET_BRANCH}"
|
||||
git rebase "origin/${TARGET_BRANCH}"
|
||||
if git push origin HEAD:"${TARGET_BRANCH}"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
echo "Failed to push ${LOCALE} locale update after retries."
|
||||
exit 1
|
||||
70
.github/workflows/docs-sync-publish.yml
vendored
70
.github/workflows/docs-sync-publish.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Docs Sync Publish Repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- scripts/docs-sync-publish.mjs
|
||||
- .github/workflows/docs-sync-publish.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync-publish-repo:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Clone publish repo
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone \
|
||||
"https://x-access-token:${OPENCLAW_DOCS_SYNC_TOKEN}@github.com/openclaw/docs.git" \
|
||||
publish
|
||||
|
||||
- name: Sync docs into publish repo
|
||||
run: |
|
||||
node scripts/docs-sync-publish.mjs \
|
||||
--target "$GITHUB_WORKSPACE/publish" \
|
||||
--source-repo "$GITHUB_REPOSITORY" \
|
||||
--source-sha "$GITHUB_SHA"
|
||||
|
||||
- name: Commit publish repo sync
|
||||
working-directory: publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- docs .openclaw-sync; then
|
||||
echo "No publish-repo changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "openclaw-docs-sync[bot]"
|
||||
git config user.email "openclaw-docs-sync[bot]@users.noreply.github.com"
|
||||
git add docs .openclaw-sync
|
||||
git commit -m "chore(sync): mirror docs from $GITHUB_REPOSITORY@$GITHUB_SHA"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} failed; retrying."
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
echo "Failed to push publish-repo sync after retries."
|
||||
exit 1
|
||||
@@ -1,42 +0,0 @@
|
||||
name: Docs Trigger Locale Translate On Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch-translate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger locale translates in publish repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for event_type in \
|
||||
translate-zh-cn-release \
|
||||
translate-ja-jp-release \
|
||||
translate-es-release \
|
||||
translate-pt-br-release \
|
||||
translate-ko-release \
|
||||
translate-de-release \
|
||||
translate-fr-release \
|
||||
translate-ar-release \
|
||||
translate-it-release \
|
||||
translate-tr-release \
|
||||
translate-uk-release \
|
||||
translate-id-release \
|
||||
translate-pl-release
|
||||
do
|
||||
gh api repos/openclaw/docs/dispatches \
|
||||
--method POST \
|
||||
-f event_type="${event_type}" \
|
||||
-f client_payload[release_tag]="${RELEASE_TAG}" \
|
||||
-f client_payload[source_repository]="${GITHUB_REPOSITORY}" \
|
||||
-f client_payload[source_sha]="${GITHUB_SHA}"
|
||||
done
|
||||
2
.github/workflows/macos-release.yml
vendored
2
.github/workflows/macos-release.yml
vendored
@@ -20,7 +20,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
validate_macos_release_request:
|
||||
|
||||
27
.github/workflows/openclaw-npm-release.yml
vendored
27
.github/workflows/openclaw-npm-release.yml
vendored
@@ -37,7 +37,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preflight_openclaw_npm:
|
||||
@@ -129,31 +129,6 @@ jobs:
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Validate live cache credentials
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY}" ]]; then
|
||||
echo "Missing OPENAI_API_KEY secret for release live cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for release live cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
run: pnpm test:live:cache
|
||||
|
||||
- name: Pack prepared npm tarball
|
||||
id: packed_tarball
|
||||
env:
|
||||
|
||||
2
.github/workflows/plugin-clawhub-release.yml
vendored
2
.github/workflows/plugin-clawhub-release.yml
vendored
@@ -23,7 +23,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
CLAWHUB_REGISTRY: "https://clawhub.ai"
|
||||
CLAWHUB_REPOSITORY: "openclaw/clawhub"
|
||||
# Pinned to a reviewed ClawHub commit so release behavior stays reproducible.
|
||||
|
||||
2
.github/workflows/plugin-npm-release.yml
vendored
2
.github/workflows/plugin-npm-release.yml
vendored
@@ -38,7 +38,7 @@ concurrency:
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.32.1"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preview_plugins_npm:
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -4,7 +4,7 @@ node_modules
|
||||
docker-compose.override.yml
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
dist-runtime/
|
||||
dist-runtime
|
||||
pnpm-lock.yaml
|
||||
bun.lock
|
||||
bun.lockb
|
||||
@@ -136,17 +136,10 @@ ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
# Generated docs baseline artifacts (locally generated, only hashes tracked)
|
||||
docs/.generated/*.json
|
||||
docs/.generated/*.jsonl
|
||||
|
||||
# Deprecated changelog fragment workflow
|
||||
changelog/fragments/
|
||||
|
||||
# Local scratch workspace
|
||||
.tmp/
|
||||
.artifacts/
|
||||
test/fixtures/openclaw-vitest-unit-report.json
|
||||
analysis/
|
||||
.artifacts/qa-e2e/
|
||||
extensions/qa-lab/web/dist/
|
||||
|
||||
48
AGENTS.md
48
AGENTS.md
@@ -38,14 +38,8 @@
|
||||
- Plugin and extension boundary:
|
||||
- Public docs: `docs/plugins/building-plugins.md`, `docs/plugins/architecture.md`, `docs/plugins/sdk-overview.md`, `docs/plugins/sdk-entrypoints.md`, `docs/plugins/sdk-runtime.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/sdk-provider-plugins.md`
|
||||
- Definition files: `src/plugin-sdk/plugin-entry.ts`, `src/plugin-sdk/core.ts`, `src/plugin-sdk/provider-entry.ts`, `src/plugin-sdk/channel-contract.ts`, `scripts/lib/plugin-sdk-entrypoints.json`, `package.json`
|
||||
- Invariant: core must stay extension-agnostic. Adding a bundled or third-party extension should not require unrelated core edits just to teach core that the extension exists.
|
||||
- Rule: extensions must cross into core only through `openclaw/plugin-sdk/*`, manifest metadata, and documented runtime helpers. Do not import `src/**` from extension production code.
|
||||
- Rule: core code and tests must not deep-import bundled plugin internals such as a plugin's `src/**` files or `onboard.js`. If core needs a bundled plugin helper, expose it through that plugin's `api.ts` and, when it is a real cross-package contract, through `src/plugin-sdk/<id>.ts`.
|
||||
- Rule: do not add hardcoded bundled extension/provider/channel/capability id lists, maps, or named special cases in core when a manifest, capability, registry, or plugin-owned contract can express the same behavior.
|
||||
- Rule: extension-owned compatibility behavior belongs to the owning extension. Core may orchestrate generic doctor/config flows, but extension-specific legacy repairs, detection rules, onboarding, auth detection, and provider defaults should live in plugin-owned contracts.
|
||||
- Rule: for legacy config specifically, prefer doctor-owned repair paths over startup/load-time core migrations. Do not add new plugin-specific legacy migration logic to shared core/runtime surfaces when `openclaw doctor --fix` can own it.
|
||||
- Rule: when a test is asserting extension-specific behavior, keep that coverage in the owning extension when feasible. Core tests should assert generic contracts and registry/capability behavior, not extension internals.
|
||||
- Refactor trigger: if you encounter core code or tests that name a specific extension/provider/channel for extension-owned behavior, refactor toward a generic registry/capability/plugin-owned seam instead of adding another special case.
|
||||
- Compatibility: new plugin seams are allowed, but they must be added as documented, backwards-compatible, versioned contracts. We have third-party plugins in the wild and do not break them casually.
|
||||
- Channel boundary:
|
||||
- Public docs: `docs/plugins/sdk-channel-plugins.md`, `docs/plugins/architecture.md`
|
||||
@@ -61,11 +55,6 @@
|
||||
- Public docs: `docs/gateway/protocol.md`, `docs/gateway/bridge-protocol.md`, `docs/concepts/architecture.md`
|
||||
- Definition files: `src/gateway/protocol/schema.ts`, `src/gateway/protocol/schema/*.ts`, `src/gateway/protocol/index.ts`
|
||||
- Rule: protocol changes are contract changes. Prefer additive evolution; incompatible changes require explicit versioning, docs, and client/codegen follow-through.
|
||||
- Config contract boundary:
|
||||
- Canonical public config lives in exported config types, zod/schema surfaces, schema help/labels, generated config metadata, config baselines, and any user-facing gateway/config payloads. Keep those surfaces aligned.
|
||||
- When a legacy config key is retired from the public contract, remove it from every public config surface above. Keep backward compatibility only through raw-config migration/doctor seams unless explicit product policy says otherwise.
|
||||
- Do not reintroduce removed legacy aliases into public types/schema/help/baselines “for convenience”. If old configs still need to load, handle that in `legacy.migrations.*`, config ingest, or `openclaw doctor --fix`.
|
||||
- `hooks.internal.entries` is the canonical public hook config model. `hooks.internal.handlers` is compatibility-only input and must not be re-exposed in public schema/help/baseline surfaces.
|
||||
- Bundled plugin contract boundary:
|
||||
- Public docs: `docs/plugins/architecture.md`, `docs/plugins/manifest.md`, `docs/plugins/sdk-overview.md`
|
||||
- Definition files: `src/plugins/contracts/registry.ts`, `src/plugins/types.ts`, `src/plugins/public-artifacts.ts`
|
||||
@@ -73,7 +62,6 @@
|
||||
- Extension test boundary:
|
||||
- Keep extension-owned onboarding/config/provider coverage under the owning bundled plugin package when feasible.
|
||||
- If core tests need bundled plugin behavior, consume it through public `src/plugin-sdk/<id>.ts` facades or the plugin's `api.ts`, not private extension modules.
|
||||
- If a core test is asserting extension-specific behavior instead of a generic contract, move it to the owning extension package.
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
|
||||
@@ -88,24 +76,16 @@
|
||||
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
|
||||
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
|
||||
|
||||
## Docs i18n (generated publish locales)
|
||||
## Docs i18n (zh-CN)
|
||||
|
||||
- Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as the sibling `openclaw-docs` directory); do not add or edit localized docs under `docs/<locale>/**` here.
|
||||
- Those localized docs are autogenerated. Treat this repo's English docs plus glossary files as the source of truth, and let the publish/translation pipeline update `openclaw/docs`.
|
||||
- Pipeline: update English docs here → adjust the matching `docs/.i18n/glossary.<locale>.json` entries → let the publish-repo sync + `scripts/docs-i18n` run in `openclaw/docs` / local `openclaw-docs` clone → apply targeted fixes only if instructed.
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
|
||||
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
|
||||
- Translation memory lives in generated `docs/.i18n/*.tm.jsonl` files in the publish repo.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
|
||||
## Control UI i18n (generated in repo)
|
||||
|
||||
- Control UI foreign-language locale bundles are generated in this repo; do not hand-edit `ui/src/i18n/locales/*.ts` for non-English locales or `ui/src/i18n/.i18n/*` unless a targeted generated-output fix is explicitly requested.
|
||||
- Source of truth is `ui/src/i18n/locales/en.ts` plus the generator/runtime wiring in `scripts/control-ui-i18n.ts`, `ui/src/i18n/lib/types.ts`, and `ui/src/i18n/lib/registry.ts`.
|
||||
- Pipeline: update English control UI strings and locale wiring here → run `pnpm ui:i18n:sync` (or let `Control UI Locale Refresh` do it) → commit the regenerated locale bundles and `.i18n` metadata.
|
||||
- If the control UI locale outputs drift, regenerate them; do not manually translate or hand-maintain the generated locale files by default.
|
||||
|
||||
## exe.dev VM ops (general)
|
||||
|
||||
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).
|
||||
@@ -145,10 +125,10 @@
|
||||
- Formatting gate: the pre-commit hook runs `pnpm format` before `pnpm check`. If you want a formatting-only preflight locally, run `pnpm format` explicitly.
|
||||
- If you need a fast commit loop, `FAST_COMMIT=1 git commit ...` skips the hook’s repo-wide `pnpm format` and `pnpm check`; use that only when you are deliberately covering the touched surface some other way.
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
- Generated baseline drift detection uses SHA-256 hash files under `docs/.generated/` (`.sha256` files tracked in git; full JSON baselines are gitignored, generated locally for inspection).
|
||||
- Generated baseline artifacts live together under `docs/.generated/`.
|
||||
- Config schema drift uses `pnpm config:docs:gen` / `pnpm config:docs:check`.
|
||||
- Plugin SDK API drift uses `pnpm plugin-sdk:api:gen` / `pnpm plugin-sdk:api:check`.
|
||||
- If you change config schema/help or the public Plugin SDK surface, run the matching gen command and commit the updated `.sha256` hash file. Keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
|
||||
- If you change config schema/help or the public Plugin SDK surface, update the matching baseline artifact and keep the two drift-check flows adjacent in scripts/workflows/docs guidance rather than inventing a third pattern.
|
||||
- For narrowly scoped changes, prefer narrowly scoped tests that directly validate the touched behavior. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available.
|
||||
- Verification modes for work on `main`:
|
||||
- Default mode: `main` is relatively stable. Count pre-commit hook coverage when it already verified the current tree, avoid rerunning the exact same checks just for ceremony, and prefer keeping CI/main green before landing.
|
||||
@@ -160,14 +140,6 @@
|
||||
- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures.
|
||||
- Do not use scoped tests as permission to ignore plausibly related failures.
|
||||
|
||||
## Prompt Cache Stability
|
||||
|
||||
- Treat prompt-cache stability as correctness/perf-critical, not cosmetic.
|
||||
- Any code that assembles model or tool payloads from maps, sets, registries, plugin lists, MCP catalogs, filesystem reads, or network results must make ordering deterministic before building the request.
|
||||
- Do not rewrite older transcript/history bytes on every turn unless you intentionally want to invalidate the cached prefix. Legacy cleanup, pruning, normalization, and migration logic should preserve recent prompt bytes when possible.
|
||||
- If truncation or compaction is required, prefer mutating newest or tail content first so the cached prefix stays byte-identical for as long as possible.
|
||||
- For cache-sensitive changes, require a regression test that proves turn-to-turn prefix stability or deterministic request assembly; helper-local tests alone are not enough.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
@@ -219,10 +191,10 @@
|
||||
- Test performance guardrail: prefer narrow public SDK subpaths such as `models-provider-runtime`, `skill-commands-runtime`, and `reply-dispatch-runtime` over older broad helper barrels when both expose the needed helper.
|
||||
- Test performance guardrail: treat import-dominated test time as a boundary bug. Refactor the import surface before adding more cases to the slow file.
|
||||
- Agents MUST NOT modify baseline, inventory, ignore, snapshot, or expected-failure files to silence failing checks without explicit approval in this chat.
|
||||
- For targeted/local debugging, use the native root-project entrypoint: `pnpm test <path-or-filter> [vitest args...]` (for example `pnpm test src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses the repo's default config/profile/pool routing.
|
||||
- For targeted/local debugging, keep using the wrapper: `pnpm test -- <path-or-filter> [vitest args...]` (for example `pnpm test -- src/commands/onboard-search.test.ts -t "shows registered plugin providers"`); do not default to raw `pnpm vitest run ...` because it bypasses wrapper config/profile/pool routing.
|
||||
- Do not set test workers above 16; tried already.
|
||||
- Vitest now defaults to native root-project `threads`, with hard `forks` exceptions for `gateway`, `agents`, and `commands`. Keep new pool changes explicit and justified; use `OPENCLAW_VITEST_POOL=forks` for full local fork debugging.
|
||||
- If local Vitest runs cause memory pressure, the default worker budget now derives from host capabilities (CPU, memory band, current load). For a conservative explicit override during land/gate runs, use `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
|
||||
- Keep Vitest on `forks` only. Do not introduce or reintroduce any non-`forks` Vitest pool or alternate execution mode in configs, wrapper scripts, or default test commands without explicit approval in this chat. This includes `threads`, `vmThreads`, `vmForks`, and any future/nonstandard pool variant.
|
||||
- If local Vitest runs cause memory pressure, the wrapper now derives budgets from host capabilities (CPU, memory band, current load). For a conservative explicit override during land/gate runs, use `OPENCLAW_TEST_PROFILE=serial OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`.
|
||||
- Live tests (real keys): `OPENCLAW_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
|
||||
- `pnpm test:live` defaults quiet now. Keep `[live]` progress; suppress profile/gateway chatter. Full logs: `OPENCLAW_LIVE_TEST_QUIET=0 pnpm test:live`.
|
||||
- Full kit + what’s covered: `docs/help/testing.md`.
|
||||
@@ -280,8 +252,6 @@
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- Mobile pairing: `ws://` (cleartext) is allowed for private LAN addresses (RFC 1918, link-local, mDNS `.local`) and loopback. Private LAN hosts typically lack PKI-backed identity, so requiring TLS there adds complexity without meaningful security gain. `wss://` is required for Tailscale and public endpoints.
|
||||
- Security report scope: reports that treat cleartext `ws://` mobile pairing over private LAN as a vulnerability are out of scope unless they demonstrate a trust-boundary bypass beyond passive network observation on the same LAN.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
|
||||
313
CHANGELOG.md
313
CHANGELOG.md
@@ -4,222 +4,88 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config: remove legacy public config aliases such as `talk.voiceId` / `talk.apiKey`, `agents.*.sandbox.perSession`, `browser.ssrfPolicy.allowPrivateNetwork`, `hooks.internal.handlers`, and channel/group/room `allow` toggles in favor of the canonical public paths and `enabled`, while keeping load-time compatibility and `openclaw doctor --fix` migration support for existing configs. (#60726) Thanks @vincentkoc.
|
||||
|
||||
### Changes
|
||||
|
||||
- Memory/dreaming (experimental): add weighted short-term recall promotion, a `/dreaming` command, Dreams UI, multilingual conceptual tagging, and doctor/status repair support, while refactoring dreaming from competing modes into three cooperative phases (light, deep, REM) with independent schedules and recovery behavior so durable memory promotion can run in the background with less manual setup. (#60569, #60697) Thanks @vignesh07.
|
||||
- Memory/dreaming: add configurable aging controls (`recencyHalfLifeDays`, `maxAgeDays`) plus optional verbose logging so operators can tune recall decay and inspect promotion decisions more easily.
|
||||
- Memory/dreaming: add REM preview tooling (`openclaw memory rem-harness`, `promote-explain`), surface possible lasting truths during REM staging, and make deep promotion replay-safe so reruns reconcile instead of duplicating `MEMORY.md` entries.
|
||||
- Agents/video generation: add the built-in `video_generate` tool so agents can create videos through configured providers and return the generated media directly in the reply.
|
||||
- Control UI/multilingual: add localized control UI support for Simplified Chinese, Traditional Chinese, Brazilian Portuguese, German, Spanish, Japanese, Korean, French, Turkish, Indonesian, Polish, and Ukrainian. Thanks @vincentkoc.
|
||||
- iOS/exec approvals: add generic APNs approval notifications that open an in-app exec approval modal, fetch command details only after authenticated operator reconnect, and clear stale notification state when the approval resolves. (#60239) Thanks @ngutman.
|
||||
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
|
||||
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
|
||||
- Plugins: add plugin-config TUI prompts to guided onboarding/setup flows, and add `openclaw plugins install --force` so existing plugin and hook-pack targets can be replaced without using the dangerous-code override flag. (#60590, #60544)
|
||||
- Channels/context visibility: add configurable `contextVisibility` per channel (`all`, `allowlist`, `allowlist_quote`) so supplemental quote, thread, and fetched history context can be filtered by sender allowlists instead of always passing through as received.
|
||||
- Providers: add bundled Qwen, Fireworks AI, and StepFun providers, plus MiniMax TTS, Ollama Web Search, and MiniMax Search integrations for chat, speech, and search workflows. (#60032, #55921, #59318, #54648)
|
||||
- Providers/Amazon Bedrock: add bundled Mantle support plus inference-profile discovery and automatic request-region injection so Bedrock-hosted Claude, GPT-OSS, Qwen, Kimi, GLM, and similar routes work with less manual setup. (#61296, #61299) Thanks @wirjo.
|
||||
- Providers/request overrides: add shared model and media request transport overrides across OpenAI-, Anthropic-, Google-, and compatible provider paths, including headers, auth, proxy, and TLS controls. (#60200)
|
||||
- Prompt caching: keep prompt prefixes more reusable across transport fallback, deterministic MCP tool ordering, compaction, embedded image history, normalized system-prompt fingerprints, `openclaw status --verbose` cache diagnostics, and the removal of duplicate in-band tool inventories from agent system prompts so follow-up turns hit cache more reliably. (#58036, #58037, #58038, #59054, #60603, #60691) Thanks @bcherny and @vincentkoc.
|
||||
- Providers/OpenAI: add forward-compat `openai-codex/gpt-5.4-mini`, an opt-in GPT personality, and provider-owned GPT-5 prompt contributions so Codex/GPT runs stay cache-stable and compatible with bundled catalog lag.
|
||||
- Providers/Anthropic: remove the Claude CLI backend and setup-token from new onboarding, keep existing configured legacy profiles runnable, and have `openclaw doctor` repair or remove stale `anthropic:claude-cli` state during migration.
|
||||
- Agents/progress: add experimental structured plan updates and structured execution item events so compatible UIs can show clearer step-by-step progress during long-running runs.
|
||||
- Agents/Claude CLI: expose OpenClaw tools to background Claude CLI runs through a loopback MCP bridge and switch bundled runs to stdin + `stream-json` partial-message streaming so prompts stop riding argv, long replies show live progress, and final session/usage metadata still land cleanly. (#35676) Thanks @mylukin.
|
||||
- ACPX/runtime: embed the ACP runtime directly in the bundled `acpx` plugin, remove the extra external ACP CLI hop, harden live ACP session binding and reuse, and add a generic `reply_dispatch` hook so bundled plugins like ACPX can own reply interception without hardcoded ACP paths in core auto-reply routing. (#61319)
|
||||
- Config/schema: enrich the exported `openclaw config schema` JSON Schema with field titles and descriptions so editors, agents, and other schema consumers receive the same config help metadata. (#60067) Thanks @solavrc.
|
||||
- Agents/cache: diagnostics: add prompt-cache break diagnostics, trace live cache scenarios through embedded runner paths, and show cache reuse explicitly in `openclaw status --verbose`. Thanks @vincentkoc.
|
||||
- Agents/cache: stabilize cache-relevant system prompt fingerprints by normalizing equivalent structured prompt whitespace, line endings, hook-added system context, and runtime capability ordering so semantically unchanged prompts reuse KV/cache more reliably. Thanks @vincentkoc.
|
||||
- Agents/tool prompts: remove the duplicate in-band tool inventory from agent system prompts so tool-calling models rely on the structured tool definitions as the single source of truth, improving prompt stability and reducing stale tool guidance.
|
||||
- Tools/video generation: add bundled xAI (`grok-imagine-video`) and Alibaba Model Studio Wan video providers, plus live-test/default model wiring for both.
|
||||
- Providers/CLI: remove bundled CLI text-provider backends and the `agents.defaults.cliBackends` surface, while keeping ACP harness sessions and Gemini media understanding on the native bundled providers.
|
||||
- Matrix/exec approvals: clarify unavailable-approval replies so Matrix no longer claims chat approvals are unsupported when native exec approvals are merely unconfigured. (#61424) Thanks @gumadeiras.
|
||||
- Docs/IRC: replace public IRC hostname examples with `irc.example.com` and recommend private servers for bot coordination while listing common public networks for intentional use.
|
||||
- Memory/dreaming: write dreaming trail content to top-level `DREAMS.md` instead of daily memory notes, update `/dreaming` help text to point there, and keep `DREAMS.md` available for explicit reads without pulling it into default recall. Thanks @davemorin.
|
||||
- Matrix/exec approvals: add Matrix-native exec approval prompts with account-scoped approvers, channel-or-DM delivery, and room-thread aware resolution handling. (#58635) Thanks @gumadeiras.
|
||||
- Providers/StepFun: add the bundled StepFun provider plugin with standard and Step Plan endpoints, China/global onboarding choices, `step-3.5-flash` on both catalogs, and `step-3.5-flash-2603` currently exposed on Step Plan. (#60032) Thanks @hengm3467.
|
||||
- Providers/config: add full `models.providers.*.request` transport overrides for model-provider paths, including headers, auth, proxy, and TLS, and keep media provider HTTP request transport overrides aligned with the same request-policy surface. (#60200) Thanks @vincentkoc.
|
||||
- Control UI/skills: add ClawHub search, detail, and install flows directly in the Skills panel. (#60134) Thanks @samzong.
|
||||
- Outbound/runtime seams: split delivery, target-resolution, and session/transcript helper loading into narrower runtime seams so outbound hot paths and their owner tests avoid broader setup fan-out. (#60311) Thanks @shakkernerd.
|
||||
- Plugins/browser seams: split browser and WhatsApp plugin-sdk seams into narrower browser, approval-auth, and target-helper facades so hot paths and owner tests avoid broader runtime fan-out. (#60376) Thanks @shakkernerd.
|
||||
- Tests/runtime: trim local unit-test import/runtime fan-out across browser, WhatsApp, cron, task, and reply flows so owner suites start faster with lower shared-worker overhead while preserving the same focused behavior coverage. (#60249) Thanks @shakkernerd.
|
||||
- Tests/secrets runtime: restore split secrets suite cache and env isolation cleanup so broader runs do not leak stale plugin or provider snapshot state. (#60395) Thanks @shakkernerd.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120) Thanks @eleqtrizit and @pgondhi987.
|
||||
- Providers/OpenAI: make GPT-5 and Codex runs act sooner with lower-verbosity defaults, visible progress during tool work, and a one-shot retry when a turn only narrates the plan instead of taking action.
|
||||
- Providers/OpenAI and reply delivery: preserve native `reasoning.effort: "none"` and strict schemas where supported, add GPT-5.4 assistant `phase` metadata across replay and the Gateway `/v1/responses` layer, and keep commentary buffered until `final_answer` so web chat, session previews, embedded replies, and Telegram partials stop leaking planning text. Fixes #59150, #59643, #61282.
|
||||
- Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.
|
||||
- Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw `<media:audio>` placeholders. (#61008) Thanks @manueltarouca.
|
||||
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
|
||||
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
|
||||
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
|
||||
- Discord/reply tags: strip leaked `[[reply_to_current]]` control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.
|
||||
- Discord/replies: replace the unshipped `replyToOnlyWhenBatched` flag with `replyToMode: "batched"` so native reply references only attach on debounced multi-message turns while explicit reply tags still work.
|
||||
- Discord/image generation: include the real generated `MEDIA:` paths in tool output, avoid duplicate plain-output media requeueing, and persist volatile workspace-generated media into durable outbound media before final reply delivery so generated image replies stop pointing at missing local files.
|
||||
- Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.
|
||||
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
|
||||
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, and restore spoken replies on both node-scoped and gateway-backed sessions by keeping reply routing and embedded transport overrides aligned with the current playback path. (#60306, #61164, #61214)
|
||||
- Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.
|
||||
- Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.
|
||||
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.
|
||||
- Matrix/DM sessions: add `channels.matrix.dm.sessionScope`, shared-session collision notices, and aligned outbound session reuse so separate Matrix DM rooms can keep distinct context when configured. (#61373) Thanks @gumadeiras.
|
||||
- Matrix: move legacy top-level `avatarUrl` into the default account during multi-account promotion and keep env-backed account setup avatar config persisted. (#61437) Thanks @gumadeiras.
|
||||
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
|
||||
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
|
||||
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
|
||||
- Sandbox/SSH: reject hardlinked files during cross-device rename fallback so EXDEV file copies preserve the same pinned file-boundary checks as direct reads.
|
||||
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.
|
||||
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
|
||||
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.
|
||||
- Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.
|
||||
- Auto-reply: unify reply lifecycle ownership across preflight compaction, session rotation, CLI-backed runs, and gateway restart handling so `/stop` and same-session overlap checks target the right active turn and restart-interrupted turns return the restart notice instead of being silently dropped. (#61267) Thanks @dutifulbob.
|
||||
- Reply delivery: prevent duplicate block replies on `text_end` channels so providers that emit explicit text-end boundaries no longer double-send the same final message. (#61530)
|
||||
- Gateway/startup: default `gateway.mode` to `local` when unset, detect PID recycling in gateway lock files on Windows and macOS, and show startup progress so healthy restarts stop getting blocked by stale locks. (#54801, #60085, #59843) Thanks @BradGroux and @TonyDerek-dot.
|
||||
- Gateway/macOS: let launchd `KeepAlive` own in-process gateway restarts again, adding a short supervised-exit delay so rapid restarts avoid launchd crash-loop unloads while `openclaw gateway restart` still reports real LaunchAgent errors synchronously.
|
||||
- Gateway/macOS: re-bootstrap the LaunchAgent if `launchctl kickstart -k` unloads it during restart so failed restarts do not leave the gateway unmanaged until manual repair.
|
||||
- Gateway/macOS: recover installed-but-unloaded LaunchAgents during `openclaw gateway start` and `restart`, while still preferring live unmanaged gateways during restart recovery. (#43766) Thanks @HenryC-3.
|
||||
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loudly when `/Run` does not start, and report fast failed restarts accurately instead of pretending they timed out after 60 seconds. (#59335) Thanks @tmimmanuel.
|
||||
- Windows/restart: fall back to the installed Startup-entry launcher when the scheduled task was never registered, so `/restart` can relaunch the gateway on Windows setups where `schtasks` install fell back during onboarding. (#58943) Thanks @imechZhangLY.
|
||||
- Windows/restart: clean up stale gateway listeners before Windows self-restart and treat listener and argv probe failures as inconclusive, so scheduled-task relaunch no longer falls into an `EADDRINUSE` retry loop. (#60480) Thanks @arifahmedjoy.
|
||||
- Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.
|
||||
- CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.
|
||||
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
|
||||
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
|
||||
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
|
||||
- Exec/remote skills: stop advertising `exec host=node` when the current exec policy cannot route to a node, and clarify blocked exec-host override errors with both the requested host and allowed config path.
|
||||
- Agents/Claude CLI/security: clear inherited Claude Code config-root and plugin-root env overrides like `CLAUDE_CONFIG_DIR` and `CLAUDE_CODE_PLUGIN_*`, so OpenClaw-launched Claude CLI runs cannot be silently pointed at an alternate Claude config/plugin tree with different hooks, plugins, or auth context. Thanks @vincentkoc.
|
||||
- Agents/Claude CLI/security: clear inherited Claude Code provider-routing and managed-auth env overrides, and mark OpenClaw-launched Claude CLI runs as host-managed, so Claude CLI backdoor sessions cannot be silently redirected to proxy, Bedrock, Vertex, Foundry, or parent-managed token contexts. Thanks @vincentkoc.
|
||||
- Agents/Claude CLI/security: force host-managed Claude CLI backdoor runs to `--setting-sources user`, even under custom backend arg overrides, so repo-local `.claude` project/local settings, hooks, and plugin discovery do not silently execute inside non-interactive OpenClaw sessions. Thanks @vincentkoc.
|
||||
- Agents/Claude CLI: treat malformed bare `--permission-mode` backend overrides as missing and fail safe back to `bypassPermissions`, so custom `cliBackends.claude-cli.args` security config cannot accidentally consume the next flag as a bogus permission mode. Thanks @vincentkoc.
|
||||
- Gateway/device pairing: require non-admin paired-device sessions to manage only their own device for token rotate/revoke and paired-device removal, blocking cross-device token theft inside pairing-scoped sessions. (#50627) Thanks @coygeek.
|
||||
- Gateway/plugin routes: keep gateway-auth plugin runtime routes on write-only fallback scopes unless a trusted-proxy caller explicitly declares narrower `x-openclaw-scopes`, so plugin HTTP handlers no longer mint admin-level runtime scopes on missing or untrusted HTTP scope headers. (#59815) Thanks @pgondhi987.
|
||||
- Build/types: fix the Node `createRequire(...)` helper typing so provider-runtime lazy loads compile cleanly again and `pnpm build` no longer fails in the Pi embedded provider error-pattern path.
|
||||
- Gateway/security: scope loopback browser-origin auth throttling by normalized origin so one localhost Control UI tab cannot lock out a different localhost browser origin after repeated auth failures.
|
||||
- Gateway/auth: serialize async shared-secret auth attempts per client so concurrent Tailscale-capable failures cannot overrun the intended auth rate-limit budget. Thanks @Telecaster2147.
|
||||
- Device pairing/security: keep non-operator device scope checks bound to the requested role prefix so bootstrap verification cannot redeem `operator.*` scopes through `node` auth. (#57258) Thanks @jlapenna.
|
||||
- Device pairing: reject rotating device tokens into roles that were never approved during pairing, and keep reconnect role checks bounded to the paired device's approved role set. (#60462) Thanks @eleqtrizit.
|
||||
- Gateway/device auth: reuse cached device-token scopes only for cached-token reconnects, while keeping explicit `deviceToken` scope requests and empty-cache fallbacks intact so reconnects preserve `operator.read` without breaking explicit auth flows. (#46032) Thanks @caicongyang.
|
||||
- Mobile pairing/security: fail closed for internal `/pair` setup-code issuance, cleanup, and approval paths when gateway pairing scopes are missing, and keep approval-time requested-scope enforcement on the internal command path. (#55996) Thanks @coygeek.
|
||||
- Mobile pairing/bootstrap: keep QR bootstrap handoff tokens bounded to the mobile-safe contract so node handoff stays unscoped and operator handoff drops mixed `node.*`, `operator.admin`, and `operator.pairing` scopes.
|
||||
- Mobile pairing/Android: tighten secure endpoint handling so Tailscale and public remote setup reject cleartext endpoints, private LAN pairing still works, merged-role approvals mint both node and operator device tokens, and bootstrap tokens survive node auto-pair until operator approval finishes. (#60128, #60208, #60221) Thanks @obviyus.
|
||||
- Android/canvas security: require exact normalized A2UI URL matches before forwarding canvas bridge actions, rejecting query mismatches and descendant paths while still allowing fragment-only A2UI navigation.
|
||||
- Synology Chat/security: default low-level HTTPS helper TLS verification to on so helper/API defaults match the shipped safe account default, and only explicit `allowInsecureSsl: true` opts out.
|
||||
- Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.
|
||||
- Plugins/marketplace: block remote marketplace symlink escapes without breaking ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
|
||||
- Telegram/local Bot API: honor `channels.telegram.apiRoot` for buffered media downloads, add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP setups, and require `channels.telegram.trustedLocalFileRoots` before reading absolute Bot API `file_path` values. (#59544, #60705) Thanks @SARAMALI15792 and @obviyus.
|
||||
- Outbound/sanitizer: strip leaked `<tool_call>`, `<function_calls>`, and model special tokens from shared user-visible assistant text, including truncated tool-call streams, so internal scaffolding no longer bleeds into replies across surfaces. (#60619) Thanks @oliviareid-svg.
|
||||
- Agents/errors: surface an explicit disk-full message when local session or transcript writes fail with `ENOSPC`/`disk full`, so those runs stop degrading into opaque `NO_REPLY`-style failures. Thanks @vincentkoc.
|
||||
- Exec approvals: remove heuristic command-obfuscation gating from host exec so gateway and node runs rely on explicit policy, allowlist, and strict inline-eval rules only.
|
||||
- Agents/tool results: cap live tool-result persistence and overflow-recovery truncation at 40k characters so oversized tool output stays bounded without discarding recent context entirely.
|
||||
- Discord/video replies: split text-plus-video deliveries into a text reply followed by a media-only send, and let live provider auth checks honor manifest-declared API key env vars like `MODELSTUDIO_API_KEY`.
|
||||
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.
|
||||
- Plugin SDK/facades: back-fill bundled plugin facade sentinels before plugin-id tracking re-enters config loading, so CLI/provider startup no longer crashes with `shouldNormalizeGoogleProviderConfig is not a function` or other empty-facade reads during bundled plugin re-entry. Thanks @adam91holt.
|
||||
- Plugins/facades: back-fill facade sentinels before tracked-plugin resolution re-enters config loading, so facade exports stay defined during circular provider normalization. (#61180) Thanks @adam91holt.
|
||||
- Discord/image generation: include the real generated `MEDIA:` paths in tool output and avoid duplicate plain-output media requeueing so Discord image replies stop pointing at missing local files.
|
||||
- Slack: route live DM replies back to the concrete inbound DM channel while keeping persisted routing metadata user-scoped, so normal assistant replies stop disappearing when pairing and system messages still arrive. (#59030) Thanks @afurm.
|
||||
- Discord/reply tags: strip leaked `[[reply_to_current]]` control tags from preview text and honor explicit reply-tag threading during final delivery, so Discord replies stay attached to the triggering message instead of printing reply metadata into chat.
|
||||
- Telegram: fix current-model checks in the model picker, HTML-format non-default `/model` confirmations, explicit topic replies, persisted reaction ownership across restarts, caption-media placeholder and `file_id` preservation on download failure, and upgraded-install inbound image reads. (#60384, #60042, #59634, #59207, #59948, #59971) Thanks @sfuminya, @GitZhangChi, @dashhuang, @samzong, @v1p0r, and @neeravmakwana.
|
||||
- Telegram: restore DM voice-note preflight transcription so direct-message audio stops arriving as raw `<media:audio>` placeholders. (#61008) Thanks @manueltarouca.
|
||||
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
|
||||
- Telegram/native command menu: trim long menu descriptions before dropping commands so sub-100 command sets can still fit Telegram's payload budget and keep more `/` entries visible. (#61129) Thanks @neeravmakwana.
|
||||
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
|
||||
- Discord: keep REST, webhook, and monitor traffic on the configured proxy, preserve component-only media sends, honor `@everyone` and `@here` mention gates, keep ACK reactions on the active account, and split voice connect/playback timeouts so auto-join is more reliable. (#57465, #60361, #60345) Thanks @geekhuashan.
|
||||
- WhatsApp: restore `channels.whatsapp.blockStreaming` and reset watchdog timeouts after reconnect so quiet chats stop falling into reconnect loops. (#60007, #60069) Thanks @MonkeyLeeT and @mcaxtr.
|
||||
- Memory: keep `memory-core` builtin embedding registration on the already-registered path so selecting `memory-core` no longer recurses through plugin discovery and crashes during startup. (#61402) Thanks @ngutman.
|
||||
- MS Teams: download inline DM images via Graph API and preserve channel reply threading in proactive fallback. (#52212, #55198) Thanks @Ted-developer and @hyojin.
|
||||
- MS Teams: replace the deprecated Teams SDK HttpPlugin stub with `httpServerAdapter` so recurring gateway deprecation warnings stop firing and the Express 5 compatibility workaround stays on the supported SDK path. (#60939) Thanks @coolramukaka-sys.
|
||||
- Matrix/exec approvals: anchor seeded approval reactions to the primary Matrix prompt event, resolve them from event metadata instead of prompt text, and clean up chunked approval prompts correctly. (#60931) Thanks @gumadeiras.
|
||||
- Matrix: recover more reliably when secret storage or recovery keys are missing by recreating secret storage during repair and backup reset, hold crypto snapshot locks during persistence, and surface explicit too-large attachment markers. (#59846, #59851, #60599, #60289) Thanks @al3mart, @emonty, and @efe-arv.
|
||||
- Android/Talk Mode: cancel in-flight `talk.speak` playback when speech is explicitly stopped, so stale replies stop starting after barge-in or manual stop. (#61164) Thanks @obviyus.
|
||||
- Android/Talk Mode: restore spoken assistant replies on node-scoped sessions by keeping reply routing synced to the resolved node session key and pausing mic capture during reply playback. (#60306) Thanks @MKV21.
|
||||
- Android/Talk Mode: restore voice replies on gateway-backed talk mode sessions by updating embedded runner transport overrides to the current agent transport API. (#61214) Thanks @obviyus.
|
||||
- Voice-call/OpenAI: pass full plugin config into realtime transcription provider resolution so streaming calls can discover the bundled OpenAI realtime transcription provider again. Fixes #60936. Thanks @sliekens and @vincentkoc.
|
||||
- Control UI/chat: add a per-session thinking-level picker in the chat header and mobile chat settings, and keep the browser bundle on UI-local thinking/session-key helpers so Safari no longer crashes on Node-only imports before rendering chat controls.
|
||||
- Control UI: keep Stop visible during tool-only execution, preserve pending-send busy state, and clear stale ClawHub search results as soon as the query changes. (#54528, #59800, #60267) Thanks @chziyue and @frankekn.
|
||||
- Control UI/avatar: honor `ui.assistant.avatar` when serving `/avatar/:agentId` so Appearance UI avatar paths stop falling back to initials placeholders. (#60778) Thanks @hannasdev.
|
||||
- Control UI/cron: highlight the Cron refresh button while refresh is in flight so the page's loading state stays visible even when prior data remains on screen. (#60394) Thanks @coder-zhuzm.
|
||||
- Control UI/Overview: prevent gateway access token/password visibility toggle buttons from overlapping their inputs at narrow widths. (#56924) Thanks @bbddbb1.
|
||||
- CLI/skills JSON: route `skills list --json`, `skills info --json`, and `skills check --json` output to stdout instead of stderr so machine-readable consumers receive JSON on the expected stream again. (#60914; fixes #57599; landed from contributor PR #57611 by @Aftabbs) Thanks @Aftabbs.
|
||||
- CLI/Commander: preserve Commander-computed exit codes for argument and help-error paths, and cover the user-argv parse mode in the regression tests so invalid CLI invocations no longer report success when exits are intercepted. (#60923) Thanks @Linux2010.
|
||||
- Cron: replay interrupted recurring jobs on the first gateway restart instead of waiting for a second restart. (#60583) Thanks @joelnishanth.
|
||||
- Cron: send failure notifications through the job's primary delivery channel using the same session context as successful delivery when no explicit `failureDestination` is configured. (#60622) Thanks @artwalker.
|
||||
- Live model switching: only treat explicit user-driven model changes as pending live switches, so fallback rotation, heartbeat overrides, and compaction no longer trip `LiveSessionModelSwitchError` before making an API call. (#60266) Thanks @kiranvk-2011.
|
||||
- Exec approvals: reuse durable exact-command `allow-always` approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182) Thanks @luoyanglang, @SnowSky1, and @pgondhi987.
|
||||
- Node exec approvals: keep node-host `system.run` approvals bound to the prepared execution plan across async forwarding, so mutable script operands still get approval-time binding and drift revalidation instead of dropping back to unbound execution.
|
||||
- Agents/exec approvals: let `exec-approvals.json` agent security override stricter gateway tool defaults so approved subagents can use `security: “full”` without falling back to allowlist enforcement again. (#60310) Thanks @lml2468.
|
||||
- Agents/exec: restore `host=node` routing for node-pinned and `host=auto` sessions, while still blocking sandboxed `auto` sessions from jumping to gateway. (#60788) Thanks @openperf.
|
||||
- Exec/heartbeat: use the canonical `exec-event` wake reason for `notifyOnExit` so background exec completions still trigger follow-up turns when `HEARTBEAT.md` is empty or comments-only. (#41479) Thanks @rstar327.
|
||||
- Heartbeat: skip wake delivery when the target session lane is already busy so the pending event is retried instead of getting drained too early. (#40526) Thanks @lucky7323.
|
||||
- Group chats/agent prompts: tell models to minimize empty lines and use normal chat-style spacing so group replies avoid document-style blank-line formatting.
|
||||
- Providers/OpenAI GPT: treat short approval turns like `ok do it` and `go ahead` as immediate action turns, and trim overly memo-like GPT-5 chat confirmations so OpenAI replies stay shorter and more conversational by default.
|
||||
- Providers/OpenAI Codex: split native `contextWindow` from runtime `contextTokens`, keep the default effective cap at `272000`, and expose a per-model `contextTokens` override on `models.providers.*.models[]`.
|
||||
- Providers/OpenAI-compatible WS: compute fallback token totals from normalized usage when providers omit or zero `total_tokens`, so DashScope-compatible sessions stop storing zero totals after alias normalization. (#54940) Thanks @lyfuci.
|
||||
- Agents/OpenAI: mark Claude-compatible file tool schemas as `additionalProperties: false` so direct OpenAI GPT-5 routes stop rejecting the `read` tool with invalid strict-schema errors.
|
||||
- Agents/OpenAI: fall back to `strict: false` for native OpenAI tool calls when a tool schema is not strict-compatible, and normalize empty-object tool schemas to include `required: []`, so direct GPT-5 routes stop failing with invalid strict-schema errors like missing `path` in `required`.
|
||||
- Agents/GPT: add explicit work-item lifecycle events for embedded runs, use them to surface real progress more reliably, and stop counting tool-started turns as planning-only retries.
|
||||
- Plugins/OpenAI: enable `gpt-image-1` reference-image edits through `/images/edits` multipart uploads, and stop inferring unsupported resolution overrides when no explicit `size` or `resolution` is provided.
|
||||
- Agents/replay: remove the malformed assistant-content canonicalization repair from replay history sanitization instead of extending that legacy repair path into replay validation.
|
||||
- Plugins/OpenAI: tune the OpenAI prompt overlay for live-chat cadence so GPT replies stay shorter, more human, and less wall-of-text by default.
|
||||
- Providers/compat: stop forcing OpenAI-only defaults on proxy and custom OpenAI-compatible routes, preserve native vendor-specific reasoning/tool/streaming behavior across Anthropic-compatible, Moonshot, Mistral, ModelStudio, OpenRouter, xAI, and Z.ai endpoints, and route GitHub Copilot Claude models through Anthropic Messages instead of OpenAI Responses.
|
||||
- Providers/GitHub Copilot: send IDE identity headers on runtime model requests and GitHub token exchange so IDE-authenticated Copilot runs stop failing with missing `Editor-Version`. (#60641) Thanks @VACInc and @vincentkoc.
|
||||
- Providers/OpenRouter failover: classify `403 “Key limit exceeded”` spending-limit responses as billing so model fallback continues instead of stopping on generic auth. (#59892) Thanks @rockcent.
|
||||
- Providers/Anthropic: keep `claude-cli/*` auth on live Claude CLI credentials at runtime, avoid persisting stale bearer-token profiles, and suppress macOS Keychain prompts during non-interactive Claude CLI setup. (#61234) Thanks @darkamenosa.
|
||||
- Providers/Anthropic: when Claude CLI auth becomes the default, write a real `claude-cli` auth profile so local and gateway agent runs can use Claude CLI immediately without missing-API-key failures. Thanks @vincentkoc.
|
||||
- Providers/Anthropic Vertex: honor `cacheRetention: “long”` with the real 1-hour prompt-cache TTL on Vertex AI endpoints, and default `anthropic-vertex` cache retention like direct Anthropic. (#60888) Thanks @affsantos.
|
||||
- Agents/Anthropic: preserve native `toolu_*` replay ids on direct Anthropic and Anthropic Vertex paths so cache-sensitive history stops rewriting known-valid Anthropic tool-use ids. (#52612)
|
||||
- Providers/Google: add model-level `cacheRetention` support for direct Gemini system prompts by creating, reusing, and refreshing `cachedContents` automatically on Google AI Studio runs. (#51372) Thanks @rafaelmariano-glitch.
|
||||
- Google Gemini CLI auth: detect bundled npm installs by scanning packaged bundle files for the Gemini OAuth client config, so `npm install -g @google/gemini-cli` layouts work again. (#60486) Thanks @wzfmini01.
|
||||
- Google Gemini CLI auth: detect personal OAuth mode from local Gemini settings and skip Code Assist project discovery for those logins, so personal Google accounts stop failing with `loadCodeAssist 400 Bad Request`. (#49226) Thanks @bobworrall.
|
||||
- Google Gemini CLI auth: improve OAuth credential discovery across Windows nvm and Homebrew libexec installs, and align Code Assist metadata so Gemini login stops failing on packaged CLI layouts. (#40729) Thanks @hughcube.
|
||||
- Google Gemini CLI models: add forward-compat support for stable `gemini-2.5-*` model ids by letting the bundled CLI provider clone them from Google templates, so `gemini-2.5-flash-lite` and related configured models stop showing up as missing. (#35274) Thanks @mySebbe.
|
||||
- Google image generation: disable pinned DNS for Gemini image requests and honor explicit `pinDns` overrides in shared provider HTTP helpers so proxy-backed image generation works again. (#59873) Thanks @luoyanglang.
|
||||
- Providers/Microsoft Foundry: preserve explicit image capability on normalized Foundry deployments, repair stale GPT/o-series text-only model metadata across gateway and runtime paths, and keep unknown fallback models from borrowing unrelated image support.
|
||||
- Providers/Model Studio: preserve native streaming usage reporting for DashScope-compatible endpoints even when they are configured under a generic provider key, so streamed token totals stop sticking at zero. (#52395) Thanks @IVY-AI-gif.
|
||||
- Providers/Z.AI: preserve explicitly registered `glm-5-*` variants like `glm-5-turbo` instead of intercepting them with the generic GLM-5 forward-compat shim. (#48185) Thanks @haoyu-haoyu.
|
||||
- Amazon Bedrock/aws-sdk auth: stop injecting the fake `AWS_PROFILE` apiKey marker when no AWS auth env vars exist, so instance-role and other default-chain setups keep working without poisoning provider config. (#61194) Thanks @wirjo.
|
||||
- Agents/Kimi tool-call repair: preserve tool arguments that were already present on streamed tool calls when later malformed deltas fail reevaluation, while still dropping stale repair-only state before `toolcall_end`.
|
||||
- Plugins/Kimi Coding: parse tagged tool calls and keep Anthropic-native tool payloads so Kimi coding endpoints execute tools instead of echoing raw markup. (#60051, #60391) Thanks @obviyus and @Eric-Guo.
|
||||
- Media understanding: auto-register image-capable config providers for vision routing, so custom GLM-style provider ids with image models stop failing with “no media-understanding provider registered”. (#51418) Thanks @xydt-610.
|
||||
- Plugins/media understanding: enable bundled Groq and Deepgram providers by default so configured transcription models work without extra plugin activation config. (#59982) Thanks @yxjsxy.
|
||||
- MiniMax/pricing: keep bundled MiniMax highspeed pricing distinct in provider catalogs and preserve the lower M2.5 cache-read pricing when onboarding older MiniMax models. (#54214) Thanks @octo-patch.
|
||||
- MiniMax: advertise image input on bundled `MiniMax-M2.7` and `MiniMax-M2.7-highspeed` model definitions so image-capable flows can route through the M2.7 family correctly. (#54843) Thanks @MerlinMiao88888888.
|
||||
- Models/MiniMax: honor `MINIMAX_API_HOST` for implicit bundled MiniMax provider catalogs so China-hosted API-key setups pick `api.minimaxi.com/anthropic` without manual provider config. (#34524) Thanks @caiqinghua.
|
||||
- Usage/MiniMax: invert remaining-style `usage_percent` fields when MiniMax reports only remaining percentage data, so usage bars stop showing nearly-full remaining quota as nearly-exhausted usage. (#60254) Thanks @jwchmodx.
|
||||
- Usage/MiniMax: let usage snapshots treat `minimax-portal` and MiniMax CN aliases as the same MiniMax quota surface, and prefer stored MiniMax OAuth before falling back to Coding Plan keys.
|
||||
- Usage/MiniMax: prefer the chat-model `model_remains` entry and derive Coding Plan window labels from MiniMax interval timestamps so MiniMax usage snapshots stop picking zero-budget media rows and misreporting 4h windows as `5h`. (#52349) Thanks @IVY-AI-gif.
|
||||
- Model picker/providers: treat bundled BytePlus and Volcengine plan aliases as their native providers during setup, and expose their bundled standard/coding catalogs before auth so setup can suggest the right models. (#58819) Thanks @Luckymingxuan.
|
||||
- Tools/web_search (Kimi): when `tools.web.search.kimi.baseUrl` is unset, inherit native Moonshot chat `baseUrl` (`.ai` / `.cn`) so China console keys authenticate on the same host as chat. Fixes #44851. (#56769) Thanks @tonga54.
|
||||
- Agents/Claude CLI: keep non-interactive `--permission-mode bypassPermissions` when custom `cliBackends.claude-cli.args` override defaults, including fallback resolution before the runtime plugin registry is active, so cron and heartbeat Claude CLI runs do not regress to interactive approval mode. (#61114) Thanks @cathrynlavery and @thewilloftheshadow.
|
||||
- Agents/Claude CLI: persist explicit `openclaw agent --session-id` runs under a stable session key so follow-ups can reuse the stored CLI binding and resume the same underlying Claude session.
|
||||
- Agents/Claude CLI: persist routed Claude session bindings, rotate them on `/new` and `/reset`, and keep live Claude CLI model switches moving across the configured Claude family so resumed sessions follow the real active thread and model. Thanks @vincentkoc.
|
||||
- Agents/CLI backends: invalidate stored CLI session reuse when local CLI login state or the selected auth profile credential changes, so relogin and token rotation stop resuming stale sessions.
|
||||
- Agents/Claude CLI/images: reuse stable hydrated image file paths and preserve shared media extensions like HEIC when passing image refs to local CLI runs, so Claude CLI image prompts stop thrashing KV cache prefixes and oddball image formats do not fall back to `.bin`. Thanks @vincentkoc.
|
||||
- Agents/compaction: keep assistant tool calls and displaced tool results in the same compaction chunk so strict summarization providers stop rejecting orphaned tool pairs. (#58849) Thanks @openperf.
|
||||
- Agents/failover: scope Anthropic `An unknown error occurred` failover matching by provider so generic internal unknown-error text no longer triggers retryable timeout fallback. (#59325) Thanks @aaron-he-zhu.
|
||||
- Agents/subagents: honor allowlist validation, auth-profile handoff, and session override state when a subagent retries after `LiveSessionModelSwitchError`. (#58178) Thanks @openperf.
|
||||
- Agents/runtime: make default subagent allowlists, inherited skills/workspaces, and duplicate session-id resolution behave more predictably, and include value-shape hints in missing-parameter tool errors. (#59944, #59992, #59858, #55317) Thanks @hclsys, @gumadeiras, @joelnishanth, and @priyansh19.
|
||||
- Agents/pairing: merge completion announce delivery context with the requester session fallback so missing `to` still reaches the original channel, and include `operator.talk.secrets` in CLI default operator scopes for node-role device pairing approvals. (#56481) Thanks @maxpetrusenko.
|
||||
- Agents/scheduling: steer background-now work toward automatic completion wake and treat `process` polling as on-demand inspection or intervention instead of default completion handling. (#60877) Thanks @vincentkoc.
|
||||
- Agents/skills: skip `.git` and `node_modules` when mirroring skills into sandbox workspaces so read-only sandboxes do not copy repo history or dependency trees. (#61090) Thanks @joelnishanth.
|
||||
- ACP/agents: inherit the target agent workspace for cross-agent ACP spawns and fall back safely when the inherited workspace no longer exists. (#58438) Thanks @zssggle-rgb.
|
||||
- ACPX/Windows: preserve backslashes and absolute `.exe` paths in Claude CLI parsing, and fail fast on wrapper-script targets with guidance to use `cmd.exe /c`, `powershell.exe -File`, or `node <script>`. (#60689) Thanks @steipete.
|
||||
- Auth/failover: persist selected fallback overrides before retrying, shorten `auth_permanent` lockouts, and refresh websocket/shared-auth sessions only when real auth changes occur so retries and secret rotations behave predictably. (#60404, #60323, #60387) Thanks @extrasmall0 and @mappel-nv.
|
||||
- Gateway/channels: pin the initial startup channel registry before later plugin-registry churn so configured channels stay visible and `channels.status` stops falling back to empty `channelOrder` / `channels` payloads after runtime plugin loads.
|
||||
- Prompt caching: order stable workspace project-context files before `HEARTBEAT.md` and keep `HEARTBEAT.md` below the system-prompt cache boundary so heartbeat churn does not invalidate the stable project-context prefix. (#58979) Thanks @yozu and @vincentkoc.
|
||||
- Prompt caching: route Codex Responses and Anthropic Vertex through boundary-aware cache shaping, and report the actual outbound system prompt in cache traces so cache reuse and misses line up with what providers really receive. Thanks @vincentkoc.
|
||||
- Agents/cache: preserve the full 3-turn prompt-cache image window across tool loops, keep colliding bundled MCP tool definitions deterministic, and reapply Anthropic Vertex cache shaping after payload hook replacements so KV/cache reuse stays stable. Thanks @vincentkoc.
|
||||
- Status/cache: restore `cacheRead` and `cacheWrite` in transcript fallback so `/status` keeps showing cache hit percentages when session logs are the only complete usage source. (#59247) Thanks @stuartsy.
|
||||
- Status/usage: let `/status` and `session_status` fall back to transcript token totals when the session meta store stayed at zero, so LM Studio, Ollama, DashScope, and similar OpenAI-compatible providers stop showing `Context: 0/...`. (#55041) Thanks @jjjojoj.
|
||||
- Mattermost/config schema: accept `groups.*.requireMention` again so existing Mattermost configs no longer fail strict validation after upgrade. (#58271) Thanks @MoerAI.
|
||||
- Doctor/config: compare normalized `talk` configs by deep structural equality instead of key-order-sensitive serialization so `openclaw doctor --fix` stops repeatedly reporting/applying no-op `talk.provider/providers` normalization. (#59911) Thanks @ejames-dev.
|
||||
- Anthropic CLI onboarding: rewrite migrated fallback model refs during non-interactive Claude CLI setup too, so onboarding and scripted setup no longer keep stale `anthropic/*` fallbacks after switching the primary model to `claude-cli/*`. Thanks @vincentkoc.
|
||||
- Models/Anthropic CLI auth: replace migrated `agents.defaults.models` allowlists when `openclaw models auth login --provider anthropic --method cli --set-default` switches to `claude-cli/*`, so stale `anthropic/*` entries do not linger beside the migrated Claude CLI defaults. Thanks @vincentkoc.
|
||||
- Doctor/Claude CLI: add dedicated Claude CLI health checks so `openclaw doctor` can spot missing local installs or broken auth before agent runs fail. Thanks @vincentkoc.
|
||||
- Plugins/auth-choice: apply provider-owned auth config patches without recursively preserving replaced default-model maps, so Anthropic Claude CLI and similar migrations can intentionally swap model allowlists during onboarding and setup instead of accumulating stale entries. Thanks @vincentkoc.
|
||||
- Plugins/onboarding: write dotted plugin uiHint paths like Brave `webSearch.mode` as nested plugin config so `llm-context` setup stops failing validation. (#61159) Thanks @obviyus.
|
||||
- Plugins/install: preserve unsafe override flags across linked plugin and hook-pack probes so local `--link` installs honor the documented override behavior. (#60624) Thanks @JerrettDavis.
|
||||
- Plugins/cache: inherit the active gateway workspace for provider, web-search, and web-fetch snapshot loads when callers omit `workspaceDir`, so compatible plugin registries and snapshot caches stop missing on gateway-owned runtime paths. (#61138) Thanks @jzakirov.
|
||||
- Plugin SDK/context engines: export the missing context-engine result and subagent lifecycle types from `openclaw/plugin-sdk` so context engine plugins can type `ContextEngine` implementations without local workarounds. (#61251) Thanks @DaevMithran.
|
||||
- Tasks/maintenance: reconcile stale cron and chat-backed CLI task rows against live cron-job and agent-run ownership instead of treating any persisted session key as proof that the task is still running. (#60310) Thanks @lml2468.
|
||||
- Plugins: suppress trust-warning noise during non-activating snapshot and CLI metadata loads. (#61427) Thanks @gumadeiras.
|
||||
- Agents/video generation: accept `agents.defaults.videoGenerationModel` in strict config validation and `openclaw config set/get`, so gateways using `video_generate` no longer fail to boot after enabling a video model.
|
||||
- Matrix/streaming: add a quiet preview mode for streamed Matrix replies, keep legacy `partial` preview-first behavior, and finalize quiet media captions correctly so previews stop notifying early without dropping final text semantics. (#61450) Thanks @gumadeiras.
|
||||
- Skills/uv install: block workspace `.env` from overriding `UV_PYTHON` and strip related interpreter override keys from uv skill-install subprocesses so repository-controlled env files cannot steer the selected Python runtime. (#59178) Thanks @pgondhi987.
|
||||
- Telegram/reactions: preserve `reactionNotifications: "own"` across gateway restarts by persisting sent-message ownership state instead of treating cold cache as a permissive fallback. (#59207) Thanks @samzong.
|
||||
- Gateway/startup: detect PID recycling in gateway lock files on Windows and macOS, and add startup progress so stale lock conflicts no longer block healthy restarts. (#59843) Thanks @TonyDerek-dot.
|
||||
- MS Teams/DM media: download inline images in 1:1 chats via Graph API so Teams DM image attachments stop failing to load. (#52212) Thanks @Ted-developer.
|
||||
- MS Teams/threading: preserve channel reply threading in proactive fallback so replies stay in the original thread instead of dropping into the channel root. (#55198) Thanks @hyojin.
|
||||
- Telegram/media: preserve `<media:...>` placeholders and `file_id` in captioned messages when Bot API downloads fail, so agents still receive media context. (#59948) Thanks @v1p0r.
|
||||
- Telegram/media: keep inbound image attachments readable on upgraded installs where legacy state roots still differ from the managed config-dir media cache. (#59971) Thanks @neeravmakwana.
|
||||
- Telegram/local Bot API: thread `channels.telegram.apiRoot` through buffered reply-media and album downloads so self-hosted Bot API file paths stop falling back to `api.telegram.org` and 404ing. (#59544) Thanks @SARAMALI15792.
|
||||
- Telegram/replies: preserve explicit topic targets when `replyTo` is present while still inheriting the current topic for same-chat replies without an explicit topic. (#59634) Thanks @dashhuang.
|
||||
- Telegram/native commands: clean up metadata-driven progress placeholders when replies fall back, edits fail, or local exec approval prompts are suppressed. (#59300) Thanks @jalehman.
|
||||
- Media/request overrides: resolve shared and capability-filtered media request SecretRefs correctly and expose media transport override fields to schema-driven config consumers. (#59848) Thanks @vincentkoc.
|
||||
- Providers/request overrides: stop advertising unsupported proxy and TLS transport settings on `models.providers.*.request`, and fail closed if unvalidated config tries to route LLM model-provider traffic through dead transport fields. (#59682) Thanks @vincentkoc.
|
||||
- Discord/mentions: treat `@everyone` and `@here` as valid mention-gate triggers in guild preflight so mention-required bots still respond to those broadcasts. (#60343) Thanks @geekhuashan.
|
||||
- Matrix: allow secret-storage recreation during automatic repair bootstrap so clients that lose their recovery key can recover and persist new cross-signing keys. (#59846) Thanks @al3mart.
|
||||
- Matrix/crypto persistence: capture and write the IndexedDB snapshot while holding the snapshot file lock so concurrent gateway and CLI persists cannot overwrite newer crypto state. (#59851) Thanks @al3mart.
|
||||
- Ollama/auth: prefer real cloud auth over local marker during model auth resolution so cloud-backed Ollama auth does not get shadowed by stale local-only markers.
|
||||
- Plugins/Kimi Coding: parse tagged Kimi tool-call text into structured tool calls on the provider stream path so tools execute instead of echoing raw markup. (#60051) Thanks @obviyus.
|
||||
- Channels/passive hooks: emit passive message hooks for mention-skipped Telegram and Signal group messages when `ingest` is enabled, including wildcard/default fallback and per-group override handling. (#60018) Thanks @obviyus.
|
||||
- Plugins/manifest registry: stop warning when an explicit manifest `id` intentionally differs from the discovery hint. (#59185) Thanks @samzong.
|
||||
- WhatsApp/streaming: honor `channels.whatsapp.blockStreaming` again for inbound auto-replies so progressive block replies can be enabled explicitly instead of being forced to final-only delivery. Thanks @mcaxtr.
|
||||
- Auth/failover: shorten `auth_permanent` lockouts, add dedicated config knobs for permanent-auth backoff, and downgrade ambiguous auth-ish upstream incidents to retryable auth failures so providers recover automatically after transient outages. (#60404) Thanks @extrasmall0.
|
||||
- Providers/GitHub Copilot: route Claude models through Anthropic Messages with Copilot-compatible headers and Anthropic prompt-cache markers instead of forcing the OpenAI Responses transport.
|
||||
- Plugins/runtime: reuse compatible active registries for `web_search` and `web_fetch` provider snapshot resolution so repeated runtime reads do not re-import the same bundled plugin set on each agent message. Related #48380.
|
||||
- Infra/tailscale: ignore `OPENCLAW_TEST_TAILSCALE_BINARY` outside explicit test environments and block it from workspace `.env`, so test-only binary overrides cannot be injected through trusted repository state. (#58468) Thanks @eleqtrizit.
|
||||
- Plugins/OpenAI: enable reference-image edits for `gpt-image-1` by routing edit calls to `/images/edits` with multipart image uploads, and update image-generation capability/docs metadata accordingly. Thanks @steipete.
|
||||
- Agents/tools: include value-shape hints in missing-parameter tool errors so dropped, empty-string, and wrong-type write payloads are easier to diagnose from logs. (#55317) Thanks @priyansh19.
|
||||
- Android/assistant: keep queued App Actions prompts pending when auto-send enqueue is rejected, so transient chat-health drops do not silently lose the assistant request. Thanks @obviyus.
|
||||
- Plugins/startup: migrate legacy `tools.web.search.<provider>` config before strict startup validation, and record plugin failure phase/timestamp so degraded plugin startup is easier to diagnose from logs and `plugins list`.
|
||||
- Plugins/Google: separate OAuth CSRF state from PKCE code verifier during Gemini browser sign-in so state validation and token exchange use independent values. (#59116) Thanks @eleqtrizit.
|
||||
- Agents/subagents: honor `agents.defaults.subagents.allowAgents` for `sessions_spawn` and `agents_list`, so default cross-agent allowlists work without duplicating per-agent config. (#59944) Thanks @hclsys.
|
||||
- Agents/tools: normalize only truly empty MCP tool schemas to `{ type: "object", properties: {} }` so OpenAI accepts parameter-free tools without rewriting unrelated conditional schemas. (#60176) Thanks @Bartok9.
|
||||
- Update/npm: prefer the npm binary that owns the installed global OpenClaw prefix during package self-update, so mixed Homebrew-plus-nvm setups update the right install. (#60153) Thanks @jayeshp19.
|
||||
- Plugins/browser: block SSRF redirect bypass by installing a real-time Playwright route handler before `page.goto()` so navigation to private/internal IPs is intercepted and aborted mid-redirect instead of checked post-hoc. (#58771) Thanks @pgondhi987.
|
||||
- Android/gateway: require TLS for non-loopback remote gateway endpoints while still allowing local loopback and emulator cleartext setup flows. (#58475) Thanks @eleqtrizit.
|
||||
- Exec/Windows: hide transient console windows for `runExec` and `runCommandWithTimeout` child-process launches, matching other Windows exec paths and stopping visible shell flashes during tool runs. (#59466) Thanks @lawrence3699.
|
||||
- Zalo/webhook: scope replay-dedupe cache key to path and account using `JSON.stringify` so multi-account deployments do not silently drop events due to cross-account cache poisoning. (#59387) Thanks @pgondhi987.
|
||||
- Exec/Windows: reject malformed drive-less rooted executable paths like `:\Users\...` so approval and allowlist candidate resolution no longer treat them as cwd-relative commands. (#58040) Thanks @SnowSky1.
|
||||
- Exec/preflight: fail closed on complex interpreter invocations that would otherwise skip script-content validation, and correctly inspect quoted script paths before host execution. Thanks @pgondhi987.
|
||||
- Exec/Windows: include Windows-compatible env override keys like `ProgramFiles(x86)` in system-run approval binding so changed approved values are rejected instead of silently passing unbound. (#59182) Thanks @pgondhi987.
|
||||
- ACP/Windows spawn: fail closed on unresolved `.cmd` and `.bat` OpenClaw wrappers unless a caller explicitly opts into shell fallback, so Windows ACP launches do not silently drop into shell-mediated execution when wrapper unwrapping fails. (#58436) Thanks @eleqtrizit.
|
||||
- Exec/Windows: prefer strict-inline-eval denial over generic allowlist prompts for interpreter carriers, while keeping persisted Windows allow-always approvals argv-bound. (#59780) Thanks @luoyanglang.
|
||||
- Gateway/connect: omit admin-scoped config and auth metadata from lower-privilege `hello-ok` snapshots while preserving those fields for admin reconnects. (#58469) Thanks @eleqtrizit.
|
||||
- iOS/canvas: restrict A2UI bridge trust to the bundled scaffold and exact capability-backed remote canvas URLs, so generic `canvas.navigate` and `canvas.present` loads no longer gain action-dispatch authority. (#58471) Thanks @eleqtrizit.
|
||||
- Agents/tool policy: preserve restrictive plugin-only allowlists instead of silently widening access to core tools, and keep allowlist warnings aligned with the enforced policy. (#58476) Thanks @eleqtrizit.
|
||||
- Hooks/session_end: preserve deterministic reason metadata for custom reset aliases and overlapping idle-plus-daily rollovers so plugins can rely on lifecycle reason reporting. (#59715) Thanks @jalehman.
|
||||
- Tools/image generation: stop inferring unsupported resolution overrides for OpenAI reference-image edits when no explicit `size` or `resolution` is provided, so default edit flows no longer fail before the provider request is sent.
|
||||
- Agents/sessions: release embedded runner session locks even when teardown cleanup throws, so timed-out or failed cleanup paths no longer leave sessions wedged until the stale-lock watchdog recovers them. (#59194) Thanks @samzong.
|
||||
- Slack/app manifest: add the missing `groups:read` scope to the onboarding and example Slack app manifest so apps copied from the OpenClaw templates can resolve private group conversations reliably.
|
||||
- Mobile pairing/Android: stop generating Tailscale and public mobile setup codes that point at unusable cleartext remote gateways, keep private LAN pairing allowed, and make Android reject insecure remote endpoints with clearer guidance while mixed bootstrap approvals honor operator scopes correctly. (#60128) Thanks @obviyus.
|
||||
- Telegram/media: add `channels.telegram.network.dangerouslyAllowPrivateNetwork` for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve `api.telegram.org` to private/internal/special-use addresses.
|
||||
- Discord/proxy: keep Carbon REST, monitor startup, and webhook sends on the configured Discord proxy while falling back cleanly when the proxy URL is invalid, so Discord replies and deploys do not hard-fail on malformed proxy config. (#57465) Thanks @geekhuashan.
|
||||
- Discord/components: keep modal-trigger and spoiler-file component messages on the component path when sending media, so classic-message fallback does not silently drop component-only behavior. (#60361) Thanks @geekhuashan.
|
||||
- Mobile pairing/device approval: mint both node and operator device tokens when one approval grants merged roles, so mixed mobile bootstrap pairings stop reconnecting as operator-only and showing the node offline. (#60208) Thanks @obviyus.
|
||||
- Agents/tool policy: stop `tools.profile` warnings from flagging runtime-gated baseline core tools as unknown when the coding profile is missing tools like `code_execution`, `x_search`, `image`, or `image_generate`, while still warning on explicit extra allowlist entries. Thanks @vincentkoc.
|
||||
- Sessions/resolution: collapse alias-duplicate session-id matches before scoring, keep distinct structural ties ambiguous, and prefer current-store reuse when resolving equal cross-store duplicates so follow-up turns stop dropping or duplicating sessions on timestamp ties.
|
||||
- Mobile pairing/bootstrap: keep setup bootstrap tokens alive through the initial node auto-pair so the same QR bootstrap token can finish operator approval, then revoke it after the full issued profile connects successfully. (#60221) Thanks @obviyus.
|
||||
- Plugins/allowlists: let explicit bundled chat channel enablement bypass `plugins.allow`, while keeping auto-enabled channel activation and startup sidecars behind restrictive allowlists. (#60233) Thanks @dorukardahan.
|
||||
- Allowlist/commands: require owner access for `/allowlist add` and `/allowlist remove` so command-authorized non-owners cannot mutate persisted allowlists. (#59836) Thanks @eleqtrizit.
|
||||
- Control UI/skills: clear stale ClawHub results immediately when the search query changes, so debounced searches cannot keep outdated install targets visible. Related #60134.
|
||||
- Discord/ack reactions: keep automatic ACK reaction auth on the active hydrated Discord account so SecretRef-backed and non-default-account reactions stop falling back to stale default config resolution. (#60081) Thanks @FunJim.
|
||||
- Telegram/model switching: render non-default `/model` callback confirmations with HTML formatting so Telegram shows the selected model in bold instead of raw `**...**` markers. (#60042) Thanks @GitZhangChi.
|
||||
- Plugins/update: allow `openclaw plugins update` to use `--dangerously-force-unsafe-install` for built-in dangerous-code false positives during plugin updates. (#60066) Thanks @huntharo.
|
||||
- Gateway/auth: disconnect shared-auth websocket sessions only for effective auth rotations on restart-capable config writes, and keep `config.set` auth edits from dropping still-valid live sessions. (#60387) Thanks @mappel-nv.
|
||||
- Control UI/chat: keep the Stop button visible during tool-only execution so abortable runs do not fall back to Send while tools are still running. (#54528) thanks @chziyue.
|
||||
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
|
||||
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
|
||||
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
|
||||
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
@@ -249,13 +115,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sandbox/security: block credential-path binds even when sandbox home paths resolve through canonical aliases, so agent containers cannot mount user secret stores through alternate home-directory paths. (#59157) Thanks @eleqtrizit.
|
||||
- Gateway/Windows scheduled tasks: preserve Task Scheduler settings on reinstall, fail loud when Scheduled Task `/Run` does not start, and report fast failed restarts with the actual elapsed time instead of a fake 60s timeout. (#59335) Thanks @tmimmanuel.
|
||||
- Control UI/model picker: preserve already-qualified `provider/model` refs from the server so models whose ids already contain slashes stop being double-prefixed and remapped to the wrong provider. (#49874) Thanks @ShionEria.
|
||||
- Models/selection: resolve bare model ids in session model switches against the configured allowlist before falling back to the current session provider, so Control UI model picks stop drifting into `google/k2p5` and similar wrong-provider refs. (#51580) Thanks @honwee.
|
||||
|
||||
## 2026.4.1-beta.1
|
||||
|
||||
- Providers/transport policy: centralize request auth, proxy, TLS, and header shaping across shared HTTP, stream, and websocket paths, block insecure TLS/runtime transport overrides, and keep proxy-hop TLS separate from target mTLS settings. (#59682) Thanks @vincentkoc.
|
||||
- Providers/OpenRouter: gate documented OpenRouter attribution to native OpenRouter endpoints or the default route so custom proxy base URLs do not inherit OpenRouter request headers.
|
||||
- Providers/Copilot: classify native GitHub Copilot API hosts in the shared provider endpoint resolver and harden token-derived proxy endpoint parsing so Copilot base URL routing stays centralized and fails closed on malformed hints. (#59644) Thanks @vincentkoc.
|
||||
@@ -283,7 +142,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Image generation/providers: stop inferring private-network access from configured OpenAI, MiniMax, and fal image base URLs, and cap shared HTTP error-body reads so hostile or misconfigured endpoints fail closed without relaxing SSRF policy or buffering unbounded error payloads. Thanks @vincentkoc.
|
||||
- Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.
|
||||
- Browser/CDP: normalize trailing-dot localhost absolute-form hosts before loopback checks so remote CDP websocket URLs like `ws://localhost.:...` rewrite back to the configured remote host. (#59236) Thanks @mappel-nv.
|
||||
- Browser/attach-only profiles: disconnect cached Playwright CDP sessions when stopping attach-only or remote CDP profiles, while still reporting never-started local managed profiles as not stopped. (#60097) Thanks @pedh.
|
||||
- Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.
|
||||
- Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.
|
||||
- Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta.
|
||||
@@ -309,16 +167,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec/node hosts: stop forwarding the gateway workspace cwd to remote node exec when no workdir was explicitly requested, so cross-platform node approvals fall back to the node default cwd instead of failing with `SYSTEM_RUN_DENIED`. (#58977) Thanks @Starhappysh.
|
||||
- TUI/chat: keep pending local sends visible and reconciled across history reloads, make busy/error recovery clearer through fallback and terminal-error paths, and reclaim transcript width for long links and paths. (#59800) Thanks @vincentkoc.
|
||||
- Exec approvals/channels: decouple initiating-surface approval availability from native delivery enablement so Telegram, Slack, and Discord still expose approvals when approvers exist and native target routing is configured separately. (#59776) Thanks @joelnishanth.
|
||||
- Agents/logging: keep orphaned-user transcript repair warnings focused on interactive runs, and downgrade background-trigger repairs (`heartbeat`, `cron`, `memory`, `overflow`) to debug logs to reduce false-alarm gateway noise.
|
||||
- Gateway/node pairing: require `operator.pairing` for node approvals end-to-end, while still requiring `operator.write` or `operator.admin` when the pending node commands need those higher scopes. (#60461) Thanks @eleqtrizit.
|
||||
- Providers/OpenRouter: gate Anthropic prompt-cache `cache_control` markers to native/default OpenRouter routes and preserve them for native OpenRouter hosts behind custom provider ids. Thanks @vincentkoc.
|
||||
- Browser/CDP: validate both initial and discovered CDP websocket endpoints before connect so strict SSRF policy blocks cross-host pivots and direct websocket targets. (#60469) Thanks @eleqtrizit.
|
||||
- Browser/profiles: reject remote browser profile `cdpUrl` values that violate strict SSRF policy before saving config, with clearer validation errors for blocked endpoints. (#60477) Thanks @eleqtrizit.
|
||||
- Browser/screenshots: stop sending `fromSurface: false` on CDP screenshots so managed Chrome 146+ browsers can capture images again. (#60682) Thanks @mvanhorn.
|
||||
- Mattermost/slash commands: harden native slash-command callback token validation to use constant-time secret comparison, matching the existing interaction-token path.
|
||||
- Control UI/mobile chat: reduce narrow-screen overflow by shrinking the chat pane minimum width, removing extra mobile padding, widening message groups, and hiding avatars on very small screens. (#60220) Thanks @macdao.
|
||||
- Android/Talk Mode: route spoken replies through `talk.speak`, keep compressed playback cleanup deterministic, and fall back to local TTS for legacy gateways that omit Talk error reasons. (#60954) Thanks @obviyus.
|
||||
- Android/Talk Mode: keep reply-speaker routing and teardown behavior aligned with the new remote playback path. (#60954) Thanks @MKV21.
|
||||
|
||||
## 2026.4.1
|
||||
|
||||
@@ -348,12 +196,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
|
||||
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
|
||||
- WhatsApp/groups: fix bot waking up on self-number quoted replies in groups with `selfChatMode` enabled. (#60148) Thanks @lurebat
|
||||
- Device pairing: require `operator.pairing` or `operator.admin` for internal `/pair` setup-code, QR, and cleanup commands so lower-privilege gateway callers cannot mint or revoke pairing bootstrap material. (#60491) Thanks @eleqtrizit.
|
||||
- Agents/failover: unify structured and raw provider error classification so provider-specific `400`/`422` payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu.
|
||||
- Auth profiles/store: coerce misplaced SecretRef objects out of plaintext `key` and `token` fields during store load so agents without ACP runtime stop crashing on `.trim()` after upgrade. (#58923) Thanks @openperf.
|
||||
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
|
||||
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
|
||||
- Tools/web_search (Kimi): replay native Moonshot `$web_search` arguments verbatim, disable thinking for `kimi-k2.5`, and add Moonshot region/model setup prompts so bundled Kimi web search works again. (#59356) Thanks @Innocent-children.
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
@@ -727,9 +569,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
|
||||
- Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963)
|
||||
- Status/port diagnostics: treat single-process dual-stack loopback gateway listeners as healthy in `openclaw status --all`, suppressing false "port already in use" conflict warnings. (#53398) Thanks @DanWebb1949.
|
||||
- CLI/Docker: treat loopback private-host CLI gateway connects as local for silent pairing auto-approval, while keeping remote backend and public-host CLI connects behind pairing. (#55113) Thanks @sar618.
|
||||
|
||||
## 2026.3.24
|
||||
|
||||
### Breaking
|
||||
|
||||
|
||||
@@ -85,12 +85,6 @@ Welcome to the lobster tank! 🦞
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## PR Limits
|
||||
|
||||
We cap at **10 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.
|
||||
|
||||
For coordinated change sets that genuinely need more than 10 PRs, join the **#clawtributors** channel in Discord and talk to maintainers first.
|
||||
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
|
||||
@@ -97,7 +97,6 @@ RUN pnpm build:docker
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV OPENCLAW_PREFER_PNPM=1
|
||||
RUN pnpm ui:build
|
||||
RUN pnpm qa:lab:build
|
||||
|
||||
# Prune dev dependencies and strip build-only metadata before copying
|
||||
# runtime assets into the final image.
|
||||
@@ -157,7 +156,6 @@ COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
|
||||
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||
COPY --from=runtime-assets --chown=node:node /app/qa ./qa
|
||||
|
||||
# In npm-installed Docker images, prefer the copied source extension tree for
|
||||
# bundled discovery so package metadata that points at source entries stays valid.
|
||||
|
||||
18
README.md
18
README.md
@@ -34,7 +34,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="16.66%">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://openai.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/openai-light.svg">
|
||||
@@ -42,15 +42,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<a href="https://github.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/github-light.svg">
|
||||
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/github.svg" alt="GitHub" height="28">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.nvidia.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/nvidia.svg">
|
||||
@@ -58,7 +50,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/vercel-light.svg">
|
||||
@@ -66,7 +58,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://blacksmith.sh/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/blacksmith-light.svg">
|
||||
@@ -74,7 +66,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="16.66%">
|
||||
<td align="center" width="20%">
|
||||
<a href="https://www.convex.dev/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/sponsors/convex-light.svg">
|
||||
|
||||
@@ -97,7 +97,6 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
|
||||
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
|
||||
|
||||
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
|
||||
- Direct localhost/loopback Control UI and Gateway WebSocket sessions authenticated with the shared gateway secret (`token` / `password`) are in that same trusted-operator bucket. Local auto-paired device sessions on that path are expected to retain full localhost operator capability; they do not create a separate `operator.write` vs `operator.admin` security boundary.
|
||||
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) and direct tool endpoint (`POST /tools/invoke`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
|
||||
- Concretely, on the OpenAI-compatible HTTP surface:
|
||||
- shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026040501
|
||||
versionName = "2026.4.5"
|
||||
versionCode = 2026040301
|
||||
versionName = "2026.4.3"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@@ -239,52 +239,44 @@ tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
val variantName = variant.name
|
||||
val variantNameCapitalized = variantName.replaceFirstChar(Char::titlecase)
|
||||
val stripTaskName = "strip${variantNameCapitalized}DnsjavaServiceDescriptor"
|
||||
val mergeTaskName = "merge${variantNameCapitalized}JavaResource"
|
||||
val minifyTaskName = "minify${variantNameCapitalized}WithR8"
|
||||
val stripReleaseDnsjavaServiceDescriptor =
|
||||
tasks.register("stripReleaseDnsjavaServiceDescriptor") {
|
||||
val mergedJar =
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/merged_java_res/$variantName/$mergeTaskName/base.jar",
|
||||
"intermediates/merged_java_res/release/mergeReleaseJavaResource/base.jar",
|
||||
)
|
||||
|
||||
val stripTask =
|
||||
tasks.register(stripTaskName) {
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
inputs.file(mergedJar)
|
||||
outputs.file(mergedJar)
|
||||
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
doLast {
|
||||
val jarFile = mergedJar.get().asFile
|
||||
if (!jarFile.exists()) {
|
||||
return@doLast
|
||||
}
|
||||
|
||||
tasks.matching { it.name == mergeTaskName }.configureEach {
|
||||
finalizedBy(stripTask)
|
||||
}
|
||||
tasks.matching { it.name == minifyTaskName }.configureEach {
|
||||
dependsOn(stripTask)
|
||||
val unpackDir = temporaryDir.resolve("merged-java-res")
|
||||
delete(unpackDir)
|
||||
copy {
|
||||
from(zipTree(jarFile))
|
||||
into(unpackDir)
|
||||
exclude(dnsjavaInetAddressResolverService)
|
||||
}
|
||||
delete(jarFile)
|
||||
ant.invokeMethod(
|
||||
"zip",
|
||||
mapOf(
|
||||
"destfile" to jarFile.absolutePath,
|
||||
"basedir" to unpackDir.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "stripReleaseDnsjavaServiceDescriptor" }.configureEach {
|
||||
dependsOn("mergeReleaseJavaResource")
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "minifyReleaseWithR8" }.configureEach {
|
||||
dependsOn(stripReleaseDnsjavaServiceDescriptor)
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ class NodeRuntime(
|
||||
|
||||
private val identityStore = DeviceIdentityStore(appContext)
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
private var activeGatewayAuth: GatewayConnectAuth? = null
|
||||
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
@@ -300,11 +299,6 @@ class NodeRuntime(
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
showLocalCanvasOnConnect()
|
||||
val endpoint = connectedEndpoint
|
||||
val auth = activeGatewayAuth
|
||||
if (endpoint != null && auth != null) {
|
||||
maybeStartOperatorSessionAfterNodeConnect(endpoint, auth)
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
@@ -351,8 +345,6 @@ class NodeRuntime(
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { operatorConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
).also { speaker ->
|
||||
speaker.setPlaybackEnabled(prefs.speakerEnabled.value)
|
||||
}
|
||||
@@ -381,10 +373,11 @@ class NodeRuntime(
|
||||
parseChatSendRunId(response) ?: idempotencyKey
|
||||
},
|
||||
speakAssistantReply = { text ->
|
||||
// Voice-tab replies should speak through the dedicated reply speaker.
|
||||
// Relying on talkMode.ttsOnAllResponses here can drop playback if the
|
||||
// chat-event path misses the terminal event for this turn.
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
// Skip if TalkModeManager is handling TTS (ttsOnAllResponses) to avoid
|
||||
// double-speaking the same assistant reply from both pipelines.
|
||||
if (!talkMode.ttsOnAllResponses) {
|
||||
voiceReplySpeaker.speakAssistantReply(text)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -423,19 +416,14 @@ class NodeRuntime(
|
||||
session = operatorSession,
|
||||
supportsChatSubscribe = true,
|
||||
isConnected = { operatorConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
)
|
||||
}
|
||||
|
||||
private fun syncMainSessionKey(agentId: String?) {
|
||||
val resolvedKey = resolveNodeMainSessionKey(agentId)
|
||||
// Always push the resolved session key into TalkMode, even when the
|
||||
// state flow value is unchanged, so lazy TalkMode instances do not
|
||||
// stay on the default "main" session key.
|
||||
talkMode.setMainSessionKey(resolvedKey)
|
||||
if (_mainSessionKey.value == resolvedKey) return
|
||||
_mainSessionKey.value = resolvedKey
|
||||
talkMode.setMainSessionKey(resolvedKey)
|
||||
chat.applyMainSessionKey(resolvedKey)
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
@@ -595,11 +583,12 @@ class NodeRuntime(
|
||||
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
// MicCaptureManager handles STT + send to gateway, while the dedicated
|
||||
// reply speaker handles TTS for assistant replies in the voice tab.
|
||||
// MicCaptureManager handles STT + send to gateway.
|
||||
// TalkModeManager plays TTS on assistant responses.
|
||||
micCapture.setMicEnabled(enabled)
|
||||
if (enabled) {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
// Mic on = user is on voice screen and wants TTS responses.
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
externalAudioCaptureActive.value = enabled
|
||||
@@ -760,8 +749,8 @@ class NodeRuntime(
|
||||
prefs.setTalkEnabled(value)
|
||||
if (value) {
|
||||
// Tapping mic on interrupts any active TTS (barge-in)
|
||||
stopVoicePlayback()
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.stopTts()
|
||||
talkMode.ttsOnAllResponses = true
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
micCapture.setMicEnabled(value)
|
||||
@@ -776,25 +765,18 @@ class NodeRuntime(
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.setPlaybackEnabled(value)
|
||||
}
|
||||
// Keep TalkMode in sync so any active Talk playback also respects speaker mute.
|
||||
// Keep TalkMode in sync so speaker mute works when ttsOnAllResponses is active.
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
private fun stopActiveVoiceSession() {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
stopVoicePlayback()
|
||||
talkMode.stopTts()
|
||||
micCapture.setMicEnabled(false)
|
||||
prefs.setTalkEnabled(false)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
private fun stopVoicePlayback() {
|
||||
talkMode.stopTts()
|
||||
if (voiceReplySpeakerLazy.isInitialized()) {
|
||||
voiceReplySpeaker.stopTts()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint =
|
||||
connectedEndpoint ?: run {
|
||||
@@ -811,14 +793,15 @@ class NodeRuntime(
|
||||
auth: GatewayConnectAuth,
|
||||
reconnect: Boolean = false,
|
||||
) {
|
||||
activeGatewayAuth = auth
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
val operatorAuth =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = auth,
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
val connectOperator =
|
||||
shouldConnectOperatorSession(
|
||||
auth.token,
|
||||
auth.bootstrapToken,
|
||||
auth.password,
|
||||
loadStoredRoleDeviceToken("operator"),
|
||||
)
|
||||
if (operatorAuth == null) {
|
||||
if (!connectOperator) {
|
||||
operatorConnected = false
|
||||
operatorStatusText = "Offline"
|
||||
operatorSession.disconnect()
|
||||
@@ -826,9 +809,9 @@ class NodeRuntime(
|
||||
} else {
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
operatorAuth.bootstrapToken,
|
||||
operatorAuth.password,
|
||||
auth.token,
|
||||
auth.bootstrapToken,
|
||||
auth.password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
@@ -841,7 +824,7 @@ class NodeRuntime(
|
||||
connectionManager.buildNodeConnectOptions(),
|
||||
tls,
|
||||
)
|
||||
if (reconnect && operatorAuth != null) {
|
||||
if (reconnect && connectOperator) {
|
||||
operatorSession.reconnect()
|
||||
}
|
||||
if (reconnect) {
|
||||
@@ -939,33 +922,8 @@ class NodeRuntime(
|
||||
return deviceAuthStore.loadToken(deviceId, role)
|
||||
}
|
||||
|
||||
private fun maybeStartOperatorSessionAfterNodeConnect(
|
||||
endpoint: GatewayEndpoint,
|
||||
auth: GatewayConnectAuth,
|
||||
) {
|
||||
if (operatorConnected || operatorStatusText == "Connecting…") {
|
||||
return
|
||||
}
|
||||
val operatorAuth =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = auth,
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
) ?: return
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
operatorAuth.bootstrapToken,
|
||||
operatorAuth.password,
|
||||
connectionManager.buildOperatorConnectOptions(),
|
||||
connectionManager.resolveTlsParams(endpoint),
|
||||
)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
@@ -1301,47 +1259,18 @@ class NodeRuntime(
|
||||
|
||||
}
|
||||
|
||||
internal fun resolveOperatorSessionConnectAuth(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
storedOperatorToken: String?,
|
||||
): NodeRuntime.GatewayConnectAuth? {
|
||||
val explicitToken = auth.token?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitToken != null) {
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = explicitToken,
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
val explicitPassword = auth.password?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (explicitPassword != null) {
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = null,
|
||||
password = explicitPassword,
|
||||
)
|
||||
}
|
||||
|
||||
val storedToken = storedOperatorToken?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (storedToken != null) {
|
||||
// Bootstrap can seed the operator token, but operator should reconnect
|
||||
// through the stored device-token path rather than bootstrap auth itself.
|
||||
return NodeRuntime.GatewayConnectAuth(
|
||||
token = null,
|
||||
bootstrapToken = null,
|
||||
password = null,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun shouldConnectOperatorSession(
|
||||
auth: NodeRuntime.GatewayConnectAuth,
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
storedOperatorToken: String?,
|
||||
): Boolean {
|
||||
return resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
|
||||
return (
|
||||
!token.isNullOrBlank() ||
|
||||
!bootstrapToken.isNullOrBlank() ||
|
||||
!password.isNullOrBlank() ||
|
||||
!storedOperatorToken.isNullOrBlank()
|
||||
)
|
||||
}
|
||||
|
||||
private enum class HomeCanvasGatewayState {
|
||||
|
||||
@@ -1,92 +1,32 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
data class DeviceAuthEntry(
|
||||
val token: String,
|
||||
val role: String,
|
||||
val scopes: List<String>,
|
||||
val updatedAtMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class PersistedDeviceAuthMetadata(
|
||||
val scopes: List<String> = emptyList(),
|
||||
val updatedAtMs: Long = 0L,
|
||||
)
|
||||
|
||||
interface DeviceAuthTokenStore {
|
||||
fun loadEntry(deviceId: String, role: String): DeviceAuthEntry?
|
||||
fun loadToken(deviceId: String, role: String): String? = loadEntry(deviceId, role)?.token
|
||||
fun saveToken(deviceId: String, role: String, token: String, scopes: List<String> = emptyList())
|
||||
fun loadToken(deviceId: String, role: String): String?
|
||||
fun saveToken(deviceId: String, role: String, token: String)
|
||||
fun clearToken(deviceId: String, role: String)
|
||||
}
|
||||
|
||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? {
|
||||
override fun loadToken(deviceId: String, role: String): String? {
|
||||
val key = tokenKey(deviceId, role)
|
||||
val token = prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val metadata =
|
||||
prefs.getString(metadataKey(deviceId, role))
|
||||
?.let { raw ->
|
||||
runCatching { json.decodeFromString<PersistedDeviceAuthMetadata>(raw) }.getOrNull()
|
||||
}
|
||||
return DeviceAuthEntry(
|
||||
token = token,
|
||||
role = normalizedRole,
|
||||
scopes = metadata?.scopes ?: emptyList(),
|
||||
updatedAtMs = metadata?.updatedAtMs ?: 0L,
|
||||
)
|
||||
return prefs.getString(key)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) {
|
||||
val normalizedScopes = normalizeScopes(scopes)
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.putString(key, token.trim())
|
||||
prefs.putString(
|
||||
metadataKey(deviceId, role),
|
||||
json.encodeToString(
|
||||
PersistedDeviceAuthMetadata(
|
||||
scopes = normalizedScopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun clearToken(deviceId: String, role: String) {
|
||||
val key = tokenKey(deviceId, role)
|
||||
prefs.remove(key)
|
||||
prefs.remove(metadataKey(deviceId, role))
|
||||
}
|
||||
|
||||
private fun tokenKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
val normalizedDevice = deviceId.trim().lowercase()
|
||||
val normalizedRole = role.trim().lowercase()
|
||||
return "gateway.deviceToken.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun metadataKey(deviceId: String, role: String): String {
|
||||
val normalizedDevice = normalizeDeviceId(deviceId)
|
||||
val normalizedRole = normalizeRole(role)
|
||||
return "gateway.deviceTokenMeta.$normalizedDevice.$normalizedRole"
|
||||
}
|
||||
|
||||
private fun normalizeDeviceId(deviceId: String): String = deviceId.trim().lowercase()
|
||||
|
||||
private fun normalizeRole(role: String): String = role.trim().lowercase()
|
||||
|
||||
private fun normalizeScopes(scopes: List<String>): List<String> {
|
||||
return scopes
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ data class GatewayConnectErrorDetails(
|
||||
val code: String?,
|
||||
val canRetryWithDeviceToken: Boolean,
|
||||
val recommendedNextStep: String?,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
private data class SelectedConnectAuth(
|
||||
@@ -117,8 +116,6 @@ class GatewaySession(
|
||||
val details: GatewayConnectErrorDetails? = null,
|
||||
)
|
||||
|
||||
data class RpcResult(val ok: Boolean, val payloadJson: String?, val error: ErrorShape?)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val writeLock = Mutex()
|
||||
private val pending = ConcurrentHashMap<String, CompletableDeferred<RpcResponse>>()
|
||||
@@ -199,13 +196,6 @@ class GatewaySession(
|
||||
}
|
||||
|
||||
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
|
||||
val res = requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun requestDetailed(method: String, paramsJson: String?, timeoutMs: Long = 15_000): RpcResult {
|
||||
val conn = currentConnection ?: throw IllegalStateException("not connected")
|
||||
val params =
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
@@ -214,7 +204,9 @@ class GatewaySession(
|
||||
json.parseToJsonElement(paramsJson)
|
||||
}
|
||||
val res = conn.request(method, params, timeoutMs)
|
||||
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
|
||||
if (res.ok) return res.payloadJson ?: ""
|
||||
val err = res.error
|
||||
throw IllegalStateException("${err?.code ?: "UNAVAILABLE"}: ${err?.message ?: "request failed"}")
|
||||
}
|
||||
|
||||
suspend fun refreshNodeCanvasCapability(timeoutMs: Long = 8_000): Boolean {
|
||||
@@ -426,63 +418,11 @@ class GatewaySession(
|
||||
}
|
||||
throw GatewayConnectFailure(error)
|
||||
}
|
||||
handleConnectSuccess(res, identity.deviceId, selectedAuth.authSource)
|
||||
handleConnectSuccess(res, identity.deviceId)
|
||||
connectDeferred.complete(Unit)
|
||||
}
|
||||
|
||||
private fun shouldPersistBootstrapHandoffTokens(authSource: GatewayConnectAuthSource): Boolean {
|
||||
if (authSource != GatewayConnectAuthSource.BOOTSTRAP_TOKEN) return false
|
||||
if (isLoopbackGatewayHost(endpoint.host)) return true
|
||||
return tls != null
|
||||
}
|
||||
|
||||
private fun filteredBootstrapHandoffScopes(role: String, scopes: List<String>): List<String>? {
|
||||
return when (role.trim()) {
|
||||
"node" -> emptyList()
|
||||
"operator" -> {
|
||||
val allowedOperatorScopes =
|
||||
setOf(
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
)
|
||||
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistBootstrapHandoffToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
val filteredScopes = filteredBootstrapHandoffScopes(role, scopes) ?: return
|
||||
deviceAuthStore.saveToken(deviceId, role, token, filteredScopes)
|
||||
}
|
||||
|
||||
private fun persistIssuedDeviceToken(
|
||||
authSource: GatewayConnectAuthSource,
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: List<String>,
|
||||
) {
|
||||
if (authSource == GatewayConnectAuthSource.BOOTSTRAP_TOKEN) {
|
||||
if (!shouldPersistBootstrapHandoffTokens(authSource)) return
|
||||
persistBootstrapHandoffToken(deviceId, role, token, scopes)
|
||||
return
|
||||
}
|
||||
deviceAuthStore.saveToken(deviceId, role, token, scopes)
|
||||
}
|
||||
|
||||
private fun handleConnectSuccess(
|
||||
res: RpcResponse,
|
||||
deviceId: String,
|
||||
authSource: GatewayConnectAuthSource,
|
||||
) {
|
||||
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
|
||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||
pendingDeviceTokenRetry = false
|
||||
@@ -492,27 +432,8 @@ class GatewaySession(
|
||||
val authObj = obj["auth"].asObjectOrNull()
|
||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
|
||||
val authScopes =
|
||||
authObj?.get("scopes").asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull() }
|
||||
?: emptyList()
|
||||
if (!deviceToken.isNullOrBlank()) {
|
||||
persistIssuedDeviceToken(authSource, deviceId, authRole, deviceToken, authScopes)
|
||||
}
|
||||
if (shouldPersistBootstrapHandoffTokens(authSource)) {
|
||||
authObj?.get("deviceTokens").asArrayOrNull()
|
||||
?.mapNotNull { it.asObjectOrNull() }
|
||||
?.forEach { tokenEntry ->
|
||||
val handoffToken = tokenEntry["deviceToken"].asStringOrNull()
|
||||
val handoffRole = tokenEntry["role"].asStringOrNull()
|
||||
val handoffScopes =
|
||||
tokenEntry["scopes"].asArrayOrNull()
|
||||
?.mapNotNull { it.asStringOrNull() }
|
||||
?: emptyList()
|
||||
if (!handoffToken.isNullOrBlank() && !handoffRole.isNullOrBlank()) {
|
||||
persistBootstrapHandoffToken(deviceId, handoffRole, handoffToken, handoffScopes)
|
||||
}
|
||||
}
|
||||
deviceAuthStore.saveToken(deviceId, authRole, deviceToken)
|
||||
}
|
||||
val rawCanvas = obj["canvasHostUrl"].asStringOrNull()
|
||||
canvasHostUrl = normalizeCanvasHostUrl(rawCanvas, endpoint, isTlsConnection = tls != null)
|
||||
@@ -639,7 +560,6 @@ class GatewaySession(
|
||||
code = it["code"].asStringOrNull(),
|
||||
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
|
||||
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||
reason = it["reason"].asStringOrNull(),
|
||||
)
|
||||
}
|
||||
ErrorShape(code, msg, details)
|
||||
@@ -979,8 +899,6 @@ private fun formatGatewayAuthorityHost(host: String): String {
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
|
||||
@@ -14,31 +14,30 @@ object CanvasActionTrust {
|
||||
if (candidateUri.scheme.equals("file", ignoreCase = true)) {
|
||||
return false
|
||||
}
|
||||
val normalizedCandidate = normalizeTrustedRemoteA2uiUri(candidateUri) ?: return false
|
||||
|
||||
return trustedA2uiUrls.any { trusted ->
|
||||
matchesTrustedRemoteA2uiUrlExact(normalizedCandidate, trusted)
|
||||
isTrustedA2uiPage(candidateUri, trusted)
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesTrustedRemoteA2uiUrlExact(candidateUri: URI, trustedUrl: String): Boolean {
|
||||
private fun isTrustedA2uiPage(candidateUri: URI, trustedUrl: String): Boolean {
|
||||
val trustedUri = parseUri(trustedUrl) ?: return false
|
||||
val normalizedTrusted = normalizeTrustedRemoteA2uiUri(trustedUri) ?: return false
|
||||
return candidateUri == normalizedTrusted
|
||||
if (!candidateUri.scheme.equals(trustedUri.scheme, ignoreCase = true)) return false
|
||||
if (candidateUri.host?.equals(trustedUri.host, ignoreCase = true) != true) return false
|
||||
if (effectivePort(candidateUri) != effectivePort(trustedUri)) return false
|
||||
|
||||
val trustedPath = trustedUri.rawPath?.takeIf { it.isNotBlank() } ?: return false
|
||||
val candidatePath = candidateUri.rawPath?.takeIf { it.isNotBlank() } ?: return false
|
||||
val trustedPrefix = if (trustedPath.endsWith("/")) trustedPath else "$trustedPath/"
|
||||
return candidatePath == trustedPath || candidatePath.startsWith(trustedPrefix)
|
||||
}
|
||||
|
||||
private fun normalizeTrustedRemoteA2uiUri(uri: URI): URI? {
|
||||
// Keep Android trust normalization aligned with iOS ScreenController:
|
||||
// exact remote URL match, scheme/host normalized, fragment ignored.
|
||||
val scheme = uri.scheme?.lowercase() ?: return null
|
||||
if (scheme != "http" && scheme != "https") return null
|
||||
|
||||
val host = uri.host?.trim()?.takeIf { it.isNotEmpty() }?.lowercase() ?: return null
|
||||
|
||||
return try {
|
||||
URI(scheme, uri.userInfo, host, uri.port, uri.rawPath, uri.rawQuery, null)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
private fun effectivePort(uri: URI): Int {
|
||||
if (uri.port >= 0) return uri.port
|
||||
return when (uri.scheme?.lowercase()) {
|
||||
"https" -> 443
|
||||
"http" -> 80
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import ai.openclaw.app.gateway.GatewayClientInfo
|
||||
import ai.openclaw.app.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewayTlsParams
|
||||
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.VoiceWakeMode
|
||||
|
||||
@@ -34,7 +34,7 @@ class ConnectionManager(
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
val cleartextAllowedHost = isPrivateLanGatewayHost(endpoint.host)
|
||||
val cleartextAllowedHost = isLoopbackGatewayHost(endpoint.host)
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled && cleartextAllowedHost) return null
|
||||
|
||||
@@ -163,7 +163,7 @@ private fun disableForceDarkIfSupported(settings: WebSettings) {
|
||||
WebSettingsCompat.setForceDark(settings, WebSettingsCompat.FORCE_DARK_OFF)
|
||||
}
|
||||
|
||||
internal class CanvasA2UIActionBridge(
|
||||
private class CanvasA2UIActionBridge(
|
||||
private val isTrustedPage: () -> Boolean,
|
||||
private val onMessage: (String) -> Unit,
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.gateway.isPrivateLanGatewayHost
|
||||
import ai.openclaw.app.gateway.isLoopbackGatewayHost
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.net.URI
|
||||
@@ -56,7 +56,7 @@ internal data class GatewayScannedSetupCodeResult(
|
||||
|
||||
private val gatewaySetupJson = Json { ignoreUnknownKeys = true }
|
||||
private const val remoteGatewaySecurityRule =
|
||||
"Tailscale and public mobile nodes require wss:// or Tailscale Serve. ws:// is allowed for private LAN, localhost, and the Android emulator."
|
||||
"Non-loopback mobile nodes require wss:// or Tailscale Serve. ws:// is allowed only for localhost and the Android emulator."
|
||||
private const val remoteGatewaySecurityFix =
|
||||
"Use a private LAN host/address, or enable Tailscale Serve / expose a wss:// gateway URL."
|
||||
|
||||
@@ -143,7 +143,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
if (!tls && !isPrivateLanGatewayHost(host)) {
|
||||
if (!tls && !isLoopbackGatewayHost(host)) {
|
||||
return GatewayEndpointParseResult(error = GatewayEndpointValidationError.INSECURE_REMOTE_URL)
|
||||
}
|
||||
val defaultPort =
|
||||
|
||||
@@ -14,13 +14,11 @@ import android.speech.SpeechRecognizer
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -90,7 +88,6 @@ class MicCaptureManager(
|
||||
val isSending: StateFlow<Boolean> = _isSending
|
||||
|
||||
private val messageQueue = ArrayDeque<String>()
|
||||
private val messageQueueLock = Any()
|
||||
private var flushedPartialTranscript: String? = null
|
||||
private var pendingRunId: String? = null
|
||||
private var pendingAssistantEntryId: String? = null
|
||||
@@ -102,63 +99,11 @@ class MicCaptureManager(
|
||||
private var transcriptFlushJob: Job? = null
|
||||
private var pendingRunTimeoutJob: Job? = null
|
||||
private var stopRequested = false
|
||||
private val ttsPauseLock = Any()
|
||||
private var ttsPauseDepth = 0
|
||||
private var resumeMicAfterTts = false
|
||||
|
||||
private fun enqueueMessage(message: String) {
|
||||
synchronized(messageQueueLock) {
|
||||
messageQueue.addLast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapshotMessageQueue(): List<String> {
|
||||
return synchronized(messageQueueLock) {
|
||||
messageQueue.toList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasQueuedMessages(): Boolean {
|
||||
return synchronized(messageQueueLock) {
|
||||
messageQueue.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
private fun firstQueuedMessage(): String? {
|
||||
return synchronized(messageQueueLock) {
|
||||
messageQueue.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeFirstQueuedMessage(): String? {
|
||||
return synchronized(messageQueueLock) {
|
||||
if (messageQueue.isEmpty()) null else messageQueue.removeFirst()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queuedMessageCount(): Int {
|
||||
return synchronized(messageQueueLock) {
|
||||
messageQueue.size
|
||||
}
|
||||
}
|
||||
|
||||
fun setMicEnabled(enabled: Boolean) {
|
||||
if (_micEnabled.value == enabled) return
|
||||
_micEnabled.value = enabled
|
||||
if (enabled) {
|
||||
val pausedForTts =
|
||||
synchronized(ttsPauseLock) {
|
||||
if (ttsPauseDepth > 0) {
|
||||
resumeMicAfterTts = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
if (pausedForTts) {
|
||||
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
|
||||
return
|
||||
}
|
||||
start()
|
||||
sendQueuedIfIdle()
|
||||
} else {
|
||||
@@ -181,58 +126,6 @@ class MicCaptureManager(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun pauseForTts() {
|
||||
val shouldPause =
|
||||
synchronized(ttsPauseLock) {
|
||||
ttsPauseDepth += 1
|
||||
if (ttsPauseDepth > 1) return@synchronized false
|
||||
resumeMicAfterTts = _micEnabled.value
|
||||
val active = resumeMicAfterTts || recognizer != null || _isListening.value
|
||||
if (!active) return@synchronized false
|
||||
stopRequested = true
|
||||
restartJob?.cancel()
|
||||
restartJob = null
|
||||
transcriptFlushJob?.cancel()
|
||||
transcriptFlushJob = null
|
||||
_isListening.value = false
|
||||
_inputLevel.value = 0f
|
||||
_liveTranscript.value = null
|
||||
_statusText.value = if (_isSending.value) "Speaking · waiting for reply" else "Speaking…"
|
||||
true
|
||||
}
|
||||
if (!shouldPause) return
|
||||
withContext(Dispatchers.Main) {
|
||||
recognizer?.cancel()
|
||||
recognizer?.destroy()
|
||||
recognizer = null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resumeAfterTts() {
|
||||
val shouldResume =
|
||||
synchronized(ttsPauseLock) {
|
||||
if (ttsPauseDepth == 0) return@synchronized false
|
||||
ttsPauseDepth -= 1
|
||||
if (ttsPauseDepth > 0) return@synchronized false
|
||||
val resume = resumeMicAfterTts && _micEnabled.value
|
||||
resumeMicAfterTts = false
|
||||
if (!resume) {
|
||||
_statusText.value =
|
||||
when {
|
||||
_micEnabled.value && _isSending.value -> "Listening · sending queued voice"
|
||||
_micEnabled.value -> "Listening"
|
||||
_isSending.value -> "Mic off · sending…"
|
||||
else -> "Mic off"
|
||||
}
|
||||
}
|
||||
resume
|
||||
}
|
||||
if (!shouldResume) return
|
||||
stopRequested = false
|
||||
start()
|
||||
sendQueuedIfIdle()
|
||||
}
|
||||
|
||||
fun onGatewayConnectionChanged(connected: Boolean) {
|
||||
gatewayConnected = connected
|
||||
if (connected) {
|
||||
@@ -244,7 +137,7 @@ class MicCaptureManager(
|
||||
pendingRunId = null
|
||||
pendingAssistantEntryId = null
|
||||
_isSending.value = false
|
||||
if (hasQueuedMessages()) {
|
||||
if (messageQueue.isNotEmpty()) {
|
||||
_statusText.value = queuedWaitingStatus()
|
||||
}
|
||||
}
|
||||
@@ -352,7 +245,7 @@ class MicCaptureManager(
|
||||
_statusText.value =
|
||||
when {
|
||||
_isSending.value -> "Listening · sending queued voice"
|
||||
hasQueuedMessages() -> "Listening · ${queuedMessageCount()} queued"
|
||||
messageQueue.isNotEmpty() -> "Listening · ${messageQueue.size} queued"
|
||||
else -> "Listening"
|
||||
}
|
||||
_isListening.value = true
|
||||
@@ -385,7 +278,7 @@ class MicCaptureManager(
|
||||
role = VoiceConversationRole.User,
|
||||
text = message,
|
||||
)
|
||||
enqueueMessage(message)
|
||||
messageQueue.addLast(message)
|
||||
publishQueue()
|
||||
}
|
||||
|
||||
@@ -404,12 +297,12 @@ class MicCaptureManager(
|
||||
}
|
||||
|
||||
private fun publishQueue() {
|
||||
_queuedMessages.value = snapshotMessageQueue()
|
||||
_queuedMessages.value = messageQueue.toList()
|
||||
}
|
||||
|
||||
private fun sendQueuedIfIdle() {
|
||||
if (_isSending.value) return
|
||||
if (!hasQueuedMessages()) {
|
||||
if (messageQueue.isEmpty()) {
|
||||
if (_micEnabled.value) {
|
||||
_statusText.value = "Listening"
|
||||
} else {
|
||||
@@ -422,7 +315,7 @@ class MicCaptureManager(
|
||||
return
|
||||
}
|
||||
|
||||
val next = firstQueuedMessage() ?: return
|
||||
val next = messageQueue.first()
|
||||
_isSending.value = true
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
@@ -440,7 +333,7 @@ class MicCaptureManager(
|
||||
if (runId == null) {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
removeFirstQueuedMessage()
|
||||
messageQueue.removeFirst()
|
||||
publishQueue()
|
||||
_isSending.value = false
|
||||
pendingAssistantEntryId = null
|
||||
@@ -486,7 +379,8 @@ class MicCaptureManager(
|
||||
private fun completePendingTurn() {
|
||||
pendingRunTimeoutJob?.cancel()
|
||||
pendingRunTimeoutJob = null
|
||||
if (removeFirstQueuedMessage() != null) {
|
||||
if (messageQueue.isNotEmpty()) {
|
||||
messageQueue.removeFirst()
|
||||
publishQueue()
|
||||
}
|
||||
pendingRunId = null
|
||||
@@ -496,7 +390,7 @@ class MicCaptureManager(
|
||||
}
|
||||
|
||||
private fun queuedWaitingStatus(): String {
|
||||
return "${queuedMessageCount()} queued · waiting for gateway"
|
||||
return "${messageQueue.size} queued · waiting for gateway"
|
||||
}
|
||||
|
||||
private fun appendConversation(
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaPlayer
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
internal class TalkAudioPlayer(
|
||||
private val context: Context,
|
||||
) {
|
||||
private val lock = Any()
|
||||
private var active: ActivePlayback? = null
|
||||
|
||||
suspend fun play(audio: TalkSpeakAudio) {
|
||||
when (val mode = resolvePlaybackMode(audio)) {
|
||||
is TalkPlaybackMode.Pcm -> playPcm(audio.bytes, mode.sampleRate)
|
||||
is TalkPlaybackMode.Compressed -> playCompressed(audio.bytes, mode.fileExtension)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
synchronized(lock) {
|
||||
active?.cancel()
|
||||
active = null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolvePlaybackMode(audio: TalkSpeakAudio): TalkPlaybackMode {
|
||||
return resolvePlaybackMode(
|
||||
outputFormat = audio.outputFormat,
|
||||
mimeType = audio.mimeType,
|
||||
fileExtension = audio.fileExtension,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun resolvePlaybackMode(
|
||||
outputFormat: String?,
|
||||
mimeType: String?,
|
||||
fileExtension: String?,
|
||||
): TalkPlaybackMode {
|
||||
val normalizedOutputFormat = outputFormat?.trim()?.lowercase()
|
||||
if (normalizedOutputFormat != null) {
|
||||
val pcmSampleRate = parsePcmSampleRate(normalizedOutputFormat)
|
||||
if (pcmSampleRate != null) {
|
||||
return TalkPlaybackMode.Pcm(sampleRate = pcmSampleRate)
|
||||
}
|
||||
}
|
||||
val normalizedMimeType = mimeType?.trim()?.lowercase()
|
||||
val extension =
|
||||
normalizeExtension(
|
||||
fileExtension ?: inferExtension(outputFormat = normalizedOutputFormat, mimeType = normalizedMimeType),
|
||||
)
|
||||
if (extension != null) {
|
||||
return TalkPlaybackMode.Compressed(fileExtension = extension)
|
||||
}
|
||||
throw IllegalStateException("Unsupported talk audio format")
|
||||
}
|
||||
|
||||
private fun parsePcmSampleRate(outputFormat: String): Int? {
|
||||
return when (outputFormat) {
|
||||
"pcm_16000" -> 16_000
|
||||
"pcm_22050" -> 22_050
|
||||
"pcm_24000" -> 24_000
|
||||
"pcm_44100" -> 44_100
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferExtension(outputFormat: String?, mimeType: String?): String? {
|
||||
return when {
|
||||
outputFormat == "mp3" || outputFormat?.startsWith("mp3_") == true || mimeType == "audio/mpeg" -> ".mp3"
|
||||
outputFormat == "opus" || outputFormat?.startsWith("opus_") == true || mimeType == "audio/ogg" -> ".ogg"
|
||||
outputFormat?.endsWith("-wav") == true || mimeType == "audio/wav" -> ".wav"
|
||||
outputFormat?.endsWith("-webm") == true || mimeType == "audio/webm" -> ".webm"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeExtension(value: String?): String? {
|
||||
val trimmed = value?.trim()?.lowercase().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
return if (trimmed.startsWith(".")) trimmed else ".$trimmed"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun playPcm(bytes: ByteArray, sampleRate: Int) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val minBufferSize =
|
||||
AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBufferSize <= 0) {
|
||||
throw IllegalStateException("AudioTrack buffer unavailable")
|
||||
}
|
||||
val track =
|
||||
AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.build(),
|
||||
)
|
||||
.setTransferMode(AudioTrack.MODE_STATIC)
|
||||
.setBufferSizeInBytes(maxOf(minBufferSize, bytes.size))
|
||||
.build()
|
||||
val finished = CompletableDeferred<Unit>()
|
||||
val playback =
|
||||
ActivePlayback(
|
||||
cancel = {
|
||||
finished.completeExceptionally(CancellationException("assistant speech cancelled"))
|
||||
runCatching { track.pause() }
|
||||
runCatching { track.flush() }
|
||||
runCatching { track.stop() }
|
||||
},
|
||||
)
|
||||
register(playback)
|
||||
try {
|
||||
val written = track.write(bytes, 0, bytes.size)
|
||||
if (written != bytes.size) {
|
||||
throw IllegalStateException("AudioTrack write failed")
|
||||
}
|
||||
val totalFrames = bytes.size / 2
|
||||
track.play()
|
||||
while (track.playState == AudioTrack.PLAYSTATE_PLAYING) {
|
||||
if (track.playbackHeadPosition >= totalFrames) {
|
||||
finished.complete(Unit)
|
||||
break
|
||||
}
|
||||
delay(20)
|
||||
}
|
||||
if (!finished.isCompleted) {
|
||||
finished.complete(Unit)
|
||||
}
|
||||
finished.await()
|
||||
} finally {
|
||||
clear(playback)
|
||||
runCatching { track.pause() }
|
||||
runCatching { track.flush() }
|
||||
runCatching { track.stop() }
|
||||
track.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun playCompressed(bytes: ByteArray, fileExtension: String) {
|
||||
val tempFile = withContext(Dispatchers.IO) {
|
||||
File.createTempFile("talk-audio-", fileExtension, context.cacheDir).apply {
|
||||
writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
try {
|
||||
val finished = CompletableDeferred<Unit>()
|
||||
val player =
|
||||
withContext(Dispatchers.Main) {
|
||||
MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build(),
|
||||
)
|
||||
setDataSource(tempFile.absolutePath)
|
||||
setOnCompletionListener {
|
||||
finished.complete(Unit)
|
||||
}
|
||||
setOnErrorListener { _, what, extra ->
|
||||
finished.completeExceptionally(IllegalStateException("MediaPlayer error ($what/$extra)"))
|
||||
true
|
||||
}
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
val playback =
|
||||
ActivePlayback(
|
||||
cancel = {
|
||||
finished.completeExceptionally(CancellationException("assistant speech cancelled"))
|
||||
runCatching { player.stop() }
|
||||
},
|
||||
)
|
||||
register(playback)
|
||||
try {
|
||||
withContext(Dispatchers.Main) {
|
||||
player.start()
|
||||
}
|
||||
finished.await()
|
||||
} finally {
|
||||
clear(playback)
|
||||
withContext(Dispatchers.Main) {
|
||||
runCatching { player.stop() }
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
withContext(Dispatchers.IO) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun register(playback: ActivePlayback) {
|
||||
synchronized(lock) {
|
||||
active?.cancel()
|
||||
active = playback
|
||||
}
|
||||
}
|
||||
|
||||
private fun clear(playback: ActivePlayback) {
|
||||
synchronized(lock) {
|
||||
if (active === playback) {
|
||||
active = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
internal sealed interface TalkPlaybackMode {
|
||||
data class Pcm(val sampleRate: Int) : TalkPlaybackMode
|
||||
|
||||
data class Compressed(val fileExtension: String) : TalkPlaybackMode
|
||||
}
|
||||
|
||||
private class ActivePlayback(
|
||||
val cancel: () -> Unit,
|
||||
)
|
||||
@@ -14,21 +14,19 @@ import android.os.SystemClock
|
||||
import android.speech.RecognitionListener
|
||||
import android.speech.RecognizerIntent
|
||||
import android.speech.SpeechRecognizer
|
||||
import android.util.Log
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -48,8 +46,6 @@ class TalkModeManager(
|
||||
private val session: GatewaySession,
|
||||
private val supportsChatSubscribe: Boolean,
|
||||
private val isConnected: () -> Boolean,
|
||||
private val onBeforeSpeak: suspend () -> Unit = {},
|
||||
private val onAfterSpeak: suspend () -> Unit = {},
|
||||
) {
|
||||
companion object {
|
||||
private const val tag = "TalkMode"
|
||||
@@ -61,8 +57,6 @@ class TalkModeManager(
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val talkSpeakClient = TalkSpeakClient(session = session, json = json)
|
||||
private val talkAudioPlayer = TalkAudioPlayer(context)
|
||||
|
||||
private val _isEnabled = MutableStateFlow(false)
|
||||
val isEnabled: StateFlow<Boolean> = _isEnabled
|
||||
@@ -107,7 +101,6 @@ class TalkModeManager(
|
||||
private val playbackGeneration = AtomicLong(0L)
|
||||
|
||||
private var ttsJob: Job? = null
|
||||
private val ttsJobLock = Any()
|
||||
private val ttsLock = Any()
|
||||
private var textToSpeech: TextToSpeech? = null
|
||||
private var textToSpeechInit: CompletableDeferred<TextToSpeech>? = null
|
||||
@@ -170,11 +163,8 @@ class TalkModeManager(
|
||||
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
|
||||
if (!assistant.isNullOrBlank()) {
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
_statusText.value = "Speaking…"
|
||||
runPlaybackSession(playbackToken) {
|
||||
playAssistant(assistant, playbackToken)
|
||||
}
|
||||
playAssistant(assistant, playbackToken)
|
||||
} else {
|
||||
_statusText.value = "No reply"
|
||||
}
|
||||
@@ -190,12 +180,14 @@ class TalkModeManager(
|
||||
|
||||
fun playTtsForText(text: String) {
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
scope.launch {
|
||||
ttsJob?.cancel()
|
||||
ttsJob = scope.launch {
|
||||
reloadConfig()
|
||||
runPlaybackSession(playbackToken) {
|
||||
playAssistant(text, playbackToken)
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
_isSpeaking.value = true
|
||||
_statusText.value = "Speaking…"
|
||||
playAssistant(text, playbackToken)
|
||||
ttsJob = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +258,7 @@ class TalkModeManager(
|
||||
if (playbackEnabled == enabled) return
|
||||
playbackEnabled = enabled
|
||||
if (!enabled) {
|
||||
playbackGeneration.incrementAndGet()
|
||||
stopSpeaking()
|
||||
}
|
||||
}
|
||||
@@ -277,11 +270,10 @@ class TalkModeManager(
|
||||
suspend fun speakAssistantReply(text: String) {
|
||||
if (!playbackEnabled) return
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
stopSpeaking(resetInterrupt = false)
|
||||
ensureConfigLoaded()
|
||||
runPlaybackSession(playbackToken) {
|
||||
playAssistant(text, playbackToken)
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
playAssistant(text, playbackToken)
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
@@ -491,10 +483,9 @@ class TalkModeManager(
|
||||
}
|
||||
Log.d(tag, "assistant text ok chars=${assistant.length}")
|
||||
val playbackToken = playbackGeneration.incrementAndGet()
|
||||
cancelActivePlayback()
|
||||
runPlaybackSession(playbackToken) {
|
||||
playAssistant(assistant, playbackToken)
|
||||
}
|
||||
stopSpeaking(resetInterrupt = false)
|
||||
ensurePlaybackActive(playbackToken)
|
||||
playAssistant(assistant, playbackToken)
|
||||
} catch (err: Throwable) {
|
||||
if (err is CancellationException) {
|
||||
Log.d(tag, "finalize speech cancelled")
|
||||
@@ -664,87 +655,22 @@ class TalkModeManager(
|
||||
requestAudioFocusForTts()
|
||||
|
||||
try {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
when (val result = talkSpeakClient.synthesize(text = cleaned, directive = directive)) {
|
||||
is TalkSpeakResult.Success -> {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
talkAudioPlayer.play(result.audio)
|
||||
ensurePlaybackActive(playbackToken)
|
||||
Log.d(tag, "talk.speak ok durMs=${SystemClock.elapsedRealtime() - started}")
|
||||
}
|
||||
is TalkSpeakResult.FallbackToLocal -> {
|
||||
Log.d(tag, "talk.speak unavailable; using local TTS: ${result.message}")
|
||||
speakWithSystemTts(cleaned, directive, playbackToken)
|
||||
Log.d(tag, "system tts ok durMs=${SystemClock.elapsedRealtime() - started}")
|
||||
}
|
||||
is TalkSpeakResult.Failure -> {
|
||||
throw IllegalStateException(result.message)
|
||||
}
|
||||
}
|
||||
val ttsStarted = SystemClock.elapsedRealtime()
|
||||
speakWithSystemTts(cleaned, directive, playbackToken)
|
||||
Log.d(tag, "system tts ok durMs=${SystemClock.elapsedRealtime() - ttsStarted}")
|
||||
} catch (err: Throwable) {
|
||||
if (isPlaybackCancelled(err, playbackToken)) {
|
||||
Log.d(tag, "assistant speech cancelled")
|
||||
return
|
||||
}
|
||||
_statusText.value = "Speak failed: ${err.message ?: err::class.simpleName}"
|
||||
Log.w(tag, "talk playback failed: ${err.message ?: err::class.simpleName}")
|
||||
Log.w(tag, "system tts failed: ${err.message ?: err::class.simpleName}")
|
||||
} finally {
|
||||
|
||||
_isSpeaking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runPlaybackSession(
|
||||
playbackToken: Long,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val currentJob = coroutineContext[Job]
|
||||
var shouldResumeAfterSpeak = false
|
||||
try {
|
||||
val claimedPlayback =
|
||||
synchronized(ttsJobLock) {
|
||||
if (!playbackEnabled || playbackToken != playbackGeneration.get()) {
|
||||
false
|
||||
} else {
|
||||
ttsJob = currentJob
|
||||
true
|
||||
}
|
||||
}
|
||||
if (!claimedPlayback) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
return
|
||||
}
|
||||
ensurePlaybackActive(playbackToken)
|
||||
shouldResumeAfterSpeak = true
|
||||
onBeforeSpeak()
|
||||
ensurePlaybackActive(playbackToken)
|
||||
_isSpeaking.value = true
|
||||
_statusText.value = "Speaking…"
|
||||
block()
|
||||
} finally {
|
||||
synchronized(ttsJobLock) {
|
||||
if (ttsJob === currentJob) {
|
||||
ttsJob = null
|
||||
}
|
||||
}
|
||||
_isSpeaking.value = false
|
||||
if (shouldResumeAfterSpeak) {
|
||||
withContext(NonCancellable) {
|
||||
onAfterSpeak()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelActivePlayback() {
|
||||
val activeJob =
|
||||
synchronized(ttsJobLock) {
|
||||
ttsJob
|
||||
}
|
||||
activeJob?.cancel()
|
||||
talkAudioPlayer.stop()
|
||||
stopTextToSpeechPlayback()
|
||||
}
|
||||
|
||||
private suspend fun speakWithSystemTts(text: String, directive: TalkDirective?, playbackToken: Long) {
|
||||
ensurePlaybackActive(playbackToken)
|
||||
val engine = ensureTextToSpeech()
|
||||
@@ -829,16 +755,15 @@ class TalkModeManager(
|
||||
}
|
||||
|
||||
private fun stopSpeaking(resetInterrupt: Boolean = true) {
|
||||
playbackGeneration.incrementAndGet()
|
||||
if (!_isSpeaking.value) {
|
||||
cancelActivePlayback()
|
||||
stopTextToSpeechPlayback()
|
||||
abandonAudioFocus()
|
||||
return
|
||||
}
|
||||
if (resetInterrupt) {
|
||||
lastInterruptedAtSeconds = null
|
||||
}
|
||||
cancelActivePlayback()
|
||||
stopTextToSpeechPlayback()
|
||||
_isSpeaking.value = false
|
||||
abandonAudioFocus()
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal data class TalkSpeakAudio(
|
||||
val bytes: ByteArray,
|
||||
val provider: String,
|
||||
val outputFormat: String?,
|
||||
val voiceCompatible: Boolean?,
|
||||
val mimeType: String?,
|
||||
val fileExtension: String?,
|
||||
)
|
||||
|
||||
internal sealed interface TalkSpeakResult {
|
||||
data class Success(val audio: TalkSpeakAudio) : TalkSpeakResult
|
||||
|
||||
data class FallbackToLocal(val message: String) : TalkSpeakResult
|
||||
|
||||
data class Failure(val message: String) : TalkSpeakResult
|
||||
}
|
||||
|
||||
internal class TalkSpeakClient(
|
||||
private val session: GatewaySession? = null,
|
||||
private val json: Json = Json { ignoreUnknownKeys = true },
|
||||
private val requestDetailed: (suspend (String, String, Long) -> GatewaySession.RpcResult)? = null,
|
||||
) {
|
||||
suspend fun synthesize(text: String, directive: TalkDirective?): TalkSpeakResult {
|
||||
val response =
|
||||
try {
|
||||
performRequest(
|
||||
method = "talk.speak",
|
||||
paramsJson = json.encodeToString(TalkSpeakRequest.from(text = text, directive = directive)),
|
||||
timeoutMs = 45_000,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return TalkSpeakResult.Failure(err.message ?: "talk.speak request failed")
|
||||
}
|
||||
if (!response.ok) {
|
||||
val error = response.error
|
||||
val message = error?.message ?: "talk.speak request failed"
|
||||
return if (isFallbackEligible(error)) {
|
||||
TalkSpeakResult.FallbackToLocal(message)
|
||||
} else {
|
||||
TalkSpeakResult.Failure(message)
|
||||
}
|
||||
}
|
||||
val payload =
|
||||
try {
|
||||
json.decodeFromString<TalkSpeakResponse>(response.payloadJson ?: "")
|
||||
} catch (err: Throwable) {
|
||||
return TalkSpeakResult.Failure(err.message ?: "talk.speak payload invalid")
|
||||
}
|
||||
val bytes =
|
||||
try {
|
||||
android.util.Base64.decode(payload.audioBase64, android.util.Base64.DEFAULT)
|
||||
} catch (err: Throwable) {
|
||||
return TalkSpeakResult.Failure(err.message ?: "talk.speak audio decode failed")
|
||||
}
|
||||
if (bytes.isEmpty()) {
|
||||
return TalkSpeakResult.Failure("talk.speak returned empty audio")
|
||||
}
|
||||
return TalkSpeakResult.Success(
|
||||
TalkSpeakAudio(
|
||||
bytes = bytes,
|
||||
provider = payload.provider,
|
||||
outputFormat = payload.outputFormat,
|
||||
voiceCompatible = payload.voiceCompatible,
|
||||
mimeType = payload.mimeType,
|
||||
fileExtension = payload.fileExtension,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isFallbackEligible(error: GatewaySession.ErrorShape?): Boolean {
|
||||
val reason = error?.details?.reason
|
||||
if (reason == null) return true
|
||||
return reason == "talk_unconfigured" ||
|
||||
reason == "talk_provider_unsupported" ||
|
||||
reason == "method_unavailable"
|
||||
}
|
||||
|
||||
private suspend fun performRequest(
|
||||
method: String,
|
||||
paramsJson: String,
|
||||
timeoutMs: Long,
|
||||
): GatewaySession.RpcResult {
|
||||
requestDetailed?.let { return it(method, paramsJson, timeoutMs) }
|
||||
val activeSession = session ?: throw IllegalStateException("session missing")
|
||||
return activeSession.requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class TalkSpeakRequest(
|
||||
val text: String,
|
||||
val voiceId: String? = null,
|
||||
val modelId: String? = null,
|
||||
val outputFormat: String? = null,
|
||||
val speed: Double? = null,
|
||||
val rateWpm: Int? = null,
|
||||
val stability: Double? = null,
|
||||
val similarity: Double? = null,
|
||||
val style: Double? = null,
|
||||
val speakerBoost: Boolean? = null,
|
||||
val seed: Long? = null,
|
||||
val normalize: String? = null,
|
||||
val language: String? = null,
|
||||
val latencyTier: Int? = null,
|
||||
) {
|
||||
companion object {
|
||||
fun from(text: String, directive: TalkDirective?): TalkSpeakRequest {
|
||||
return TalkSpeakRequest(
|
||||
text = text,
|
||||
voiceId = directive?.voiceId,
|
||||
modelId = directive?.modelId,
|
||||
outputFormat = directive?.outputFormat,
|
||||
speed = directive?.speed,
|
||||
rateWpm = directive?.rateWpm,
|
||||
stability = directive?.stability,
|
||||
similarity = directive?.similarity,
|
||||
style = directive?.style,
|
||||
speakerBoost = directive?.speakerBoost,
|
||||
seed = directive?.seed,
|
||||
normalize = directive?.normalize,
|
||||
language = directive?.language,
|
||||
latencyTier = directive?.latencyTier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class TalkSpeakResponse(
|
||||
val audioBase64: String,
|
||||
val provider: String,
|
||||
val outputFormat: String? = null,
|
||||
val voiceCompatible: Boolean? = null,
|
||||
val mimeType: String? = null,
|
||||
val fileExtension: String? = null,
|
||||
)
|
||||
@@ -10,7 +10,7 @@
|
||||
<parameter
|
||||
android:name="prompt"
|
||||
android:key="prompt"
|
||||
android:mimeType="https://schema.org/Text"
|
||||
android:mimeType="text/*"
|
||||
android:required="true" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
@@ -21,72 +21,17 @@ import java.util.UUID
|
||||
@Config(sdk = [34])
|
||||
class GatewayBootstrapAuthTest {
|
||||
@Test
|
||||
fun skipsOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
|
||||
storedOperatorToken = "",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
)
|
||||
fun connectsOperatorSessionWhenBootstrapAuthExists() {
|
||||
assertTrue(shouldConnectOperatorSession(token = "", bootstrapToken = "bootstrap-1", password = "", storedOperatorToken = ""))
|
||||
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
shouldConnectOperatorSession(
|
||||
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
|
||||
storedOperatorToken = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveOperatorSessionConnectAuthUsesStoredTokenPathAfterBootstrapHandoff() {
|
||||
val resolved =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
|
||||
storedOperatorToken = "stored-token",
|
||||
)
|
||||
|
||||
assertEquals(NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = null, password = null), resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveOperatorSessionConnectAuthPrefersExplicitSharedAuth() {
|
||||
val resolved =
|
||||
resolveOperatorSessionConnectAuth(
|
||||
auth = NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = "shared-password"),
|
||||
storedOperatorToken = "stored-token",
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = null, password = null),
|
||||
resolved,
|
||||
)
|
||||
fun skipsOperatorSessionOnlyWhenNoSharedBootstrapOrStoredAuthExists() {
|
||||
assertTrue(shouldConnectOperatorSession(token = "shared-token", bootstrapToken = "bootstrap-1", password = null, storedOperatorToken = null))
|
||||
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = "bootstrap-1", password = "shared-password", storedOperatorToken = null))
|
||||
assertTrue(shouldConnectOperatorSession(token = null, bootstrapToken = null, password = null, storedOperatorToken = "stored-token"))
|
||||
assertFalse(shouldConnectOperatorSession(token = null, bootstrapToken = "", password = null, storedOperatorToken = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -152,7 +97,7 @@ class GatewayBootstrapAuthTest {
|
||||
|
||||
assertEquals("fp-1", prefs.loadGatewayTlsFingerprint(endpoint.stableId))
|
||||
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "nodeSession"))
|
||||
assertNull(desiredBootstrapToken(runtime, "operatorSession"))
|
||||
assertEquals("setup-bootstrap-token", desiredBootstrapToken(runtime, "operatorSession"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package ai.openclaw.app.gateway
|
||||
|
||||
import ai.openclaw.app.SecurePrefs
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class DeviceAuthStoreTest {
|
||||
@Test
|
||||
fun saveTokenPersistsNormalizedScopesMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
val store = DeviceAuthStore(prefs)
|
||||
|
||||
store.saveToken(
|
||||
deviceId = " Device-1 ",
|
||||
role = " Operator ",
|
||||
token = " operator-token ",
|
||||
scopes = listOf("operator.write", "operator.read", "operator.write", " "),
|
||||
)
|
||||
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
assertNotNull(entry)
|
||||
assertEquals("operator-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(listOf("operator.read", "operator.write"), entry?.scopes)
|
||||
assertTrue((entry?.updatedAtMs ?: 0L) > 0L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadEntryReadsLegacyTokenWithoutMetadata() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val securePrefs =
|
||||
app.getSharedPreferences(
|
||||
"openclaw.node.secure.test.${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
val prefs = SecurePrefs(app, securePrefsOverride = securePrefs)
|
||||
prefs.putString("gateway.deviceToken.device-1.operator", "legacy-token")
|
||||
val store = DeviceAuthStore(prefs)
|
||||
|
||||
val entry = store.loadEntry("device-1", "operator")
|
||||
assertNotNull(entry)
|
||||
assertEquals("legacy-token", entry?.token)
|
||||
assertEquals("operator", entry?.role)
|
||||
assertEquals(emptyList<String>(), entry?.scopes)
|
||||
assertEquals(0L, entry?.updatedAtMs)
|
||||
}
|
||||
}
|
||||
@@ -35,18 +35,12 @@ private const val CONNECT_CHALLENGE_FRAME =
|
||||
"""{"type":"event","event":"connect.challenge","payload":{"nonce":"android-test-nonce"}}"""
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
private val tokens = mutableMapOf<String, DeviceAuthEntry>()
|
||||
private val tokens = mutableMapOf<String, String>()
|
||||
|
||||
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? = tokens["${deviceId.trim()}|${role.trim()}"]
|
||||
override fun loadToken(deviceId: String, role: String): String? = tokens["${deviceId.trim()}|${role.trim()}"]?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] =
|
||||
DeviceAuthEntry(
|
||||
token = token.trim(),
|
||||
role = role.trim(),
|
||||
scopes = scopes,
|
||||
updatedAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||
}
|
||||
|
||||
override fun clearToken(deviceId: String, role: String) {
|
||||
@@ -219,144 +213,6 @@ class GatewaySessionInvokeTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connect_storesPrimaryDeviceTokenFromSuccessfulSharedTokenConnect() = runBlocking {
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, _ ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
connectResponseFrame(
|
||||
id,
|
||||
authJson = """{"deviceToken":"shared-node-token","role":"node","scopes":[]}""",
|
||||
),
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
connectNodeSession(
|
||||
session = harness.session,
|
||||
port = server.port,
|
||||
token = "shared-auth-token",
|
||||
bootstrapToken = null,
|
||||
)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
|
||||
assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node"))
|
||||
assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator"))
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bootstrapConnect_storesAdditionalBoundedDeviceTokensOnTrustedTransport() = runBlocking {
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, _ ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
connectResponseFrame(
|
||||
id,
|
||||
authJson =
|
||||
"""{"deviceToken":"bootstrap-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"bootstrap-operator-token","role":"operator","scopes":["operator.admin","operator.approvals","operator.read","operator.talk.secrets","operator.write"]}]}""",
|
||||
),
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
connectNodeSession(
|
||||
session = harness.session,
|
||||
port = server.port,
|
||||
token = null,
|
||||
bootstrapToken = "bootstrap-token",
|
||||
)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
|
||||
val nodeEntry = harness.deviceAuthStore.loadEntry(deviceId, "node")
|
||||
val operatorEntry = harness.deviceAuthStore.loadEntry(deviceId, "operator")
|
||||
assertEquals("bootstrap-node-token", nodeEntry?.token)
|
||||
assertEquals(emptyList<String>(), nodeEntry?.scopes)
|
||||
assertEquals("bootstrap-operator-token", operatorEntry?.token)
|
||||
assertEquals(
|
||||
listOf("operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"),
|
||||
operatorEntry?.scopes,
|
||||
)
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonBootstrapConnect_ignoresAdditionalBootstrapDeviceTokens() = runBlocking {
|
||||
val json = testJson()
|
||||
val connected = CompletableDeferred<Unit>()
|
||||
val lastDisconnect = AtomicReference("")
|
||||
val server =
|
||||
startGatewayServer(json) { webSocket, id, method, _ ->
|
||||
when (method) {
|
||||
"connect" -> {
|
||||
webSocket.send(
|
||||
connectResponseFrame(
|
||||
id,
|
||||
authJson =
|
||||
"""{"deviceToken":"shared-node-token","role":"node","scopes":[],"deviceTokens":[{"deviceToken":"shared-operator-token","role":"operator","scopes":["operator.approvals","operator.read"]}]}""",
|
||||
),
|
||||
)
|
||||
webSocket.close(1000, "done")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val harness =
|
||||
createNodeHarness(
|
||||
connected = connected,
|
||||
lastDisconnect = lastDisconnect,
|
||||
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||
|
||||
try {
|
||||
connectNodeSession(
|
||||
session = harness.session,
|
||||
port = server.port,
|
||||
token = "shared-auth-token",
|
||||
bootstrapToken = null,
|
||||
)
|
||||
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||
|
||||
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
|
||||
assertEquals("shared-node-token", harness.deviceAuthStore.loadToken(deviceId, "node"))
|
||||
assertNull(harness.deviceAuthStore.loadToken(deviceId, "operator"))
|
||||
} finally {
|
||||
shutdownHarness(harness, server)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||
val handshakeOrigin = AtomicReference<String?>(null)
|
||||
@@ -614,14 +470,9 @@ class GatewaySessionInvokeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectResponseFrame(
|
||||
id: String,
|
||||
canvasHostUrl: String? = null,
|
||||
authJson: String? = null,
|
||||
): String {
|
||||
private fun connectResponseFrame(id: String, canvasHostUrl: String? = null): String {
|
||||
val canvas = canvasHostUrl?.let { "\"canvasHostUrl\":\"$it\"," } ?: ""
|
||||
val auth = authJson?.let { "\"auth\":$it," } ?: ""
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas$auth"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
return """{"type":"res","id":"$id","ok":true,"payload":{$canvas"snapshot":{"sessionDefaults":{"mainSessionKey":"main"}}}}"""
|
||||
}
|
||||
|
||||
private fun startGatewayServer(
|
||||
|
||||
@@ -39,34 +39,4 @@ class CanvasActionTrustTest {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsFragmentOnlyDifferenceForTrustedA2uiPage() {
|
||||
assertTrue(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android#step2",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsQueryMismatchOnTrustedOriginAndPath() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=ios",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsDescendantPathUnderTrustedA2uiRoot() {
|
||||
assertFalse(
|
||||
CanvasActionTrust.isTrustedCanvasActionUrl(
|
||||
rawUrl = "https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/child/index.html?platform=android",
|
||||
trustedA2uiUrls = listOf("https://canvas.example.com:9443/__openclaw__/cap/token/__openclaw__/a2ui/?platform=android"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanCanStayCleartextWhenToggleIsOff() {
|
||||
fun resolveTlsParamsForEndpoint_manualPrivateLanRequiresTlsWhenToggleIsOff() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "192.168.1.20", port = 18789)
|
||||
|
||||
val params =
|
||||
@@ -118,7 +118,9 @@ class ConnectionManagerTest {
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertNull(params)
|
||||
assertEquals(true, params?.required)
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -146,7 +148,7 @@ class ConnectionManagerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsCanStayCleartext() {
|
||||
fun resolveTlsParamsForEndpoint_discoveryPrivateLanWithoutHintsRequiresTls() {
|
||||
val endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "_openclaw-gw._tcp.|local.|Test",
|
||||
@@ -164,7 +166,9 @@ class ConnectionManagerTest {
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertNull(params)
|
||||
assertEquals(true, params?.required)
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CanvasA2UIActionBridgeTest {
|
||||
@Test
|
||||
fun forwardsTrimmedPayloadFromTrustedPage() {
|
||||
val forwarded = mutableListOf<String>()
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { true },
|
||||
onMessage = { forwarded += it },
|
||||
)
|
||||
|
||||
bridge.postMessage(" {\"ok\":true} ")
|
||||
|
||||
assertEquals(listOf("{\"ok\":true}"), forwarded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsPayloadFromUntrustedPage() {
|
||||
val forwarded = mutableListOf<String>()
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { false },
|
||||
onMessage = { forwarded += it },
|
||||
)
|
||||
|
||||
bridge.postMessage("{\"ok\":true}")
|
||||
|
||||
assertTrue(forwarded.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsBlankPayloadBeforeForwarding() {
|
||||
val forwarded = mutableListOf<String>()
|
||||
val bridge =
|
||||
CanvasA2UIActionBridge(
|
||||
isTrustedPage = { true },
|
||||
onMessage = { forwarded += it },
|
||||
)
|
||||
|
||||
bridge.postMessage(" ")
|
||||
bridge.postMessage(null)
|
||||
|
||||
assertTrue(forwarded.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -290,19 +290,11 @@ class GatewayConfigResolverTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointResultAcceptsLanCleartextGateway() {
|
||||
fun parseGatewayEndpointResultFlagsInsecureLanCleartextGateway() {
|
||||
val parsed = parseGatewayEndpointResult("ws://192.168.1.20:18789")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "192.168.1.20",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://192.168.1.20:18789",
|
||||
),
|
||||
parsed.config,
|
||||
)
|
||||
assertNull(parsed.error)
|
||||
assertNull(parsed.config)
|
||||
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, parsed.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TalkAudioPlayerTest {
|
||||
@Test
|
||||
fun resolvesPcmPlaybackFromOutputFormat() {
|
||||
val mode =
|
||||
TalkAudioPlayer.resolvePlaybackMode(
|
||||
outputFormat = "pcm_24000",
|
||||
mimeType = null,
|
||||
fileExtension = null,
|
||||
)
|
||||
|
||||
assertEquals(TalkPlaybackMode.Pcm(sampleRate = 24_000), mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesCompressedPlaybackFromMimeType() {
|
||||
val mode =
|
||||
TalkAudioPlayer.resolvePlaybackMode(
|
||||
outputFormat = null,
|
||||
mimeType = "audio/mpeg",
|
||||
fileExtension = null,
|
||||
)
|
||||
|
||||
assertEquals(TalkPlaybackMode.Compressed(fileExtension = ".mp3"), mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesProvidedExtensionForCompressedPlayback() {
|
||||
val mode =
|
||||
TalkAudioPlayer.resolvePlaybackMode(
|
||||
outputFormat = null,
|
||||
mimeType = "audio/webm",
|
||||
fileExtension = "webm",
|
||||
)
|
||||
|
||||
assertTrue(mode is TalkPlaybackMode.Compressed)
|
||||
assertEquals(".webm", (mode as TalkPlaybackMode.Compressed).fileExtension)
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.DeviceAuthEntry
|
||||
import ai.openclaw.app.gateway.DeviceAuthTokenStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class TalkModeManagerTest {
|
||||
@Test
|
||||
fun stopTtsCancelsTrackedPlaybackJob() {
|
||||
val manager = createManager()
|
||||
val playbackJob = Job()
|
||||
|
||||
setPrivateField(manager, "ttsJob", playbackJob)
|
||||
playbackGeneration(manager).set(7L)
|
||||
|
||||
manager.stopTts()
|
||||
|
||||
assertTrue(playbackJob.isCancelled)
|
||||
assertEquals(8L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablingPlaybackCancelsTrackedJobOnce() {
|
||||
val manager = createManager()
|
||||
val playbackJob = Job()
|
||||
|
||||
setPrivateField(manager, "ttsJob", playbackJob)
|
||||
playbackGeneration(manager).set(11L)
|
||||
|
||||
manager.setPlaybackEnabled(false)
|
||||
manager.setPlaybackEnabled(false)
|
||||
|
||||
assertTrue(playbackJob.isCancelled)
|
||||
assertEquals(12L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
private fun createManager(): TalkModeManager {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
val session =
|
||||
GatewaySession(
|
||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||
identityStore = DeviceIdentityStore(app),
|
||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
||||
onConnected = { _, _, _ -> },
|
||||
onDisconnected = {},
|
||||
onEvent = { _, _ -> },
|
||||
)
|
||||
return TalkModeManager(
|
||||
context = app,
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
session = session,
|
||||
supportsChatSubscribe = false,
|
||||
isConnected = { true },
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun playbackGeneration(manager: TalkModeManager): AtomicLong {
|
||||
return readPrivateField(manager, "playbackGeneration") as AtomicLong
|
||||
}
|
||||
|
||||
private fun setPrivateField(target: Any, name: String, value: Any?) {
|
||||
val field = target.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
field.set(target, value)
|
||||
}
|
||||
|
||||
private fun readPrivateField(target: Any, name: String): Any? {
|
||||
val field = target.javaClass.getDeclaredField(name)
|
||||
field.isAccessible = true
|
||||
return field.get(target)
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
override fun loadEntry(deviceId: String, role: String): DeviceAuthEntry? = null
|
||||
|
||||
override fun saveToken(deviceId: String, role: String, token: String, scopes: List<String>) = Unit
|
||||
|
||||
override fun clearToken(deviceId: String, role: String) = Unit
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TalkSpeakClientTest {
|
||||
@Test
|
||||
fun buildsRequestFromDirective() {
|
||||
val request =
|
||||
TalkSpeakRequest.from(
|
||||
text = "Hello from talk mode.",
|
||||
directive =
|
||||
TalkDirective(
|
||||
voiceId = "voice-123",
|
||||
modelId = "model-abc",
|
||||
speed = 1.1,
|
||||
rateWpm = 190,
|
||||
stability = 0.5,
|
||||
similarity = 0.7,
|
||||
style = 0.2,
|
||||
speakerBoost = true,
|
||||
seed = 42,
|
||||
normalize = "auto",
|
||||
language = "en",
|
||||
outputFormat = "pcm_24000",
|
||||
latencyTier = 3,
|
||||
once = true,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Hello from talk mode.", request.text)
|
||||
assertEquals("voice-123", request.voiceId)
|
||||
assertEquals("model-abc", request.modelId)
|
||||
assertEquals(1.1, request.speed)
|
||||
assertEquals(190, request.rateWpm)
|
||||
assertEquals(0.5, request.stability)
|
||||
assertEquals(0.7, request.similarity)
|
||||
assertEquals(0.2, request.style)
|
||||
assertEquals(true, request.speakerBoost)
|
||||
assertEquals(42L, request.seed)
|
||||
assertEquals("auto", request.normalize)
|
||||
assertEquals("en", request.language)
|
||||
assertEquals("pcm_24000", request.outputFormat)
|
||||
assertEquals(3, request.latencyTier)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackOnlyForUnavailableReasons() = runTest {
|
||||
val client =
|
||||
TalkSpeakClient(
|
||||
requestDetailed = { _, _, _ ->
|
||||
GatewaySession.RpcResult(
|
||||
ok = false,
|
||||
payloadJson = null,
|
||||
error =
|
||||
GatewaySession.ErrorShape(
|
||||
code = "UNAVAILABLE",
|
||||
message = "talk unavailable",
|
||||
details =
|
||||
GatewayConnectErrorDetails(
|
||||
code = null,
|
||||
canRetryWithDeviceToken = false,
|
||||
recommendedNextStep = null,
|
||||
reason = "talk_unconfigured",
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result = client.synthesize(text = "Hello", directive = null)
|
||||
assertTrue(result is TalkSpeakResult.FallbackToLocal)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotFallBackForSynthesisFailure() = runTest {
|
||||
val client =
|
||||
TalkSpeakClient(
|
||||
requestDetailed = { _, _, _ ->
|
||||
GatewaySession.RpcResult(
|
||||
ok = false,
|
||||
payloadJson = null,
|
||||
error =
|
||||
GatewaySession.ErrorShape(
|
||||
code = "UNAVAILABLE",
|
||||
message = "provider failed",
|
||||
details =
|
||||
GatewayConnectErrorDetails(
|
||||
code = null,
|
||||
canRetryWithDeviceToken = false,
|
||||
recommendedNextStep = null,
|
||||
reason = "synthesis_failed",
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result = client.synthesize(text = "Hello", directive = null)
|
||||
assertTrue(result is TalkSpeakResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackWhenGatewayOmitsReason() = runTest {
|
||||
val client =
|
||||
TalkSpeakClient(
|
||||
requestDetailed = { _, _, _ ->
|
||||
GatewaySession.RpcResult(
|
||||
ok = false,
|
||||
payloadJson = null,
|
||||
error =
|
||||
GatewaySession.ErrorShape(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "unknown method: talk.speak",
|
||||
details = null,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result = client.synthesize(text = "Hello", directive = null)
|
||||
assertTrue(result is TalkSpeakResult.FallbackToLocal)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.4.5
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.5
|
||||
OPENCLAW_BUILD_VERSION = 2026040501
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.4.3
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.3
|
||||
OPENCLAW_BUILD_VERSION = 2026040301
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -92,54 +92,6 @@ If you need to force a specific build number:
|
||||
pnpm ios:beta -- --build-number 7
|
||||
```
|
||||
|
||||
### Maintainer Quick Release Checklist
|
||||
|
||||
Use this when a clone is missing local iOS release setup and you want the shortest path to a TestFlight upload.
|
||||
|
||||
1. Confirm Fastlane auth is set up:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios auth_check
|
||||
```
|
||||
|
||||
2. If auth is missing, bootstrap it once on this Mac:
|
||||
|
||||
```bash
|
||||
scripts/ios-asc-keychain-setup.sh \
|
||||
--key-path /absolute/path/to/AuthKey_XXXXXXXXXX.p8 \
|
||||
--issuer-id YOUR_ISSUER_ID \
|
||||
--write-env
|
||||
```
|
||||
|
||||
This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain.
|
||||
|
||||
3. Set the official/TestFlight relay URL for the build:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
4. Upload the beta:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
5. Expected behavior:
|
||||
- Fastlane reads `package.json.version`
|
||||
- resolves the next TestFlight build number for that short version
|
||||
- generates `apps/ios/build/BetaRelease.xcconfig`
|
||||
- archives `OpenClaw`
|
||||
- uploads the IPA to TestFlight
|
||||
|
||||
6. Expected outputs after a successful run:
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.ipa`
|
||||
- `apps/ios/build/beta/OpenClaw-<version>.app.dSYM.zip`
|
||||
- Fastlane log line like `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
|
||||
7. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
|
||||
|
||||
## APNs Expectations For Local/Manual Builds
|
||||
|
||||
- The app calls `registerForRemoteNotifications()` at launch.
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct ExecApprovalPromptDialogModifier: ViewModifier {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if let prompt = self.appModel.pendingExecApprovalPrompt {
|
||||
ZStack {
|
||||
Color.black.opacity(0.38)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ExecApprovalPromptCard(
|
||||
prompt: prompt,
|
||||
isResolving: self.appModel.pendingExecApprovalPromptResolving,
|
||||
errorText: self.appModel.pendingExecApprovalPromptErrorText,
|
||||
brighten: self.colorScheme == .light,
|
||||
onAllowOnce: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-once")
|
||||
}
|
||||
},
|
||||
onAllowAlways: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "allow-always")
|
||||
}
|
||||
},
|
||||
onDeny: {
|
||||
Task {
|
||||
await self.appModel.resolvePendingExecApprovalPrompt(decision: "deny")
|
||||
}
|
||||
},
|
||||
onCancel: {
|
||||
self.appModel.dismissPendingExecApprovalPrompt()
|
||||
})
|
||||
.padding(.horizontal, 20)
|
||||
.frame(maxWidth: 460)
|
||||
.transition(.scale(scale: 0.98).combined(with: .opacity))
|
||||
}
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.appModel.pendingExecApprovalPrompt?.id)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExecApprovalPromptCard: View {
|
||||
let prompt: NodeAppModel.ExecApprovalPrompt
|
||||
let isResolving: Bool
|
||||
let errorText: String?
|
||||
let brighten: Bool
|
||||
let onAllowOnce: () -> Void
|
||||
let onAllowAlways: () -> Void
|
||||
let onDeny: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Exec approval required")
|
||||
.font(.headline)
|
||||
Text("OpenClaw opened from a notification. Review this exec request before continuing.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(self.prompt.commandText)
|
||||
.font(.system(size: 15, weight: .regular, design: .monospaced))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.black.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let host = self.normalized(self.prompt.host) {
|
||||
ExecApprovalPromptMetadataRow(label: "Host", value: host)
|
||||
}
|
||||
if let nodeId = self.normalized(self.prompt.nodeId) {
|
||||
ExecApprovalPromptMetadataRow(label: "Node", value: nodeId)
|
||||
}
|
||||
if let agentId = self.normalized(self.prompt.agentId) {
|
||||
ExecApprovalPromptMetadataRow(label: "Agent", value: agentId)
|
||||
}
|
||||
if let expiresText = self.expiresText(self.prompt.expiresAtMs) {
|
||||
ExecApprovalPromptMetadataRow(label: "Expires", value: expiresText)
|
||||
}
|
||||
}
|
||||
|
||||
if let errorText = self.normalized(self.errorText) {
|
||||
Text(errorText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
if self.isResolving {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Resolving…")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
self.onAllowOnce()
|
||||
} label: {
|
||||
Text("Allow Once")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isResolving)
|
||||
|
||||
if self.prompt.allowsAllowAlways {
|
||||
Button {
|
||||
self.onAllowAlways()
|
||||
} label: {
|
||||
Text("Allow Always")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(role: .destructive) {
|
||||
self.onDeny()
|
||||
} label: {
|
||||
Text("Deny")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
|
||||
Button(role: .cancel) {
|
||||
self.onCancel()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.isResolving)
|
||||
}
|
||||
}
|
||||
.controlSize(.large)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.statusGlassCard(brighten: self.brighten, verticalPadding: 18, horizontalPadding: 18)
|
||||
}
|
||||
|
||||
private func normalized(_ value: String?) -> String? {
|
||||
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func expiresText(_ expiresAtMs: Int?) -> String? {
|
||||
guard let expiresAtMs else { return nil }
|
||||
let remainingSeconds = Int((Double(expiresAtMs) / 1000.0) - Date().timeIntervalSince1970)
|
||||
if remainingSeconds <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
if remainingSeconds < 60 {
|
||||
return "under a minute"
|
||||
}
|
||||
if remainingSeconds < 3600 {
|
||||
let minutes = Int(ceil(Double(remainingSeconds) / 60.0))
|
||||
return minutes == 1 ? "about 1 minute" : "about \(minutes) minutes"
|
||||
}
|
||||
let hours = Int(ceil(Double(remainingSeconds) / 3600.0))
|
||||
return hours == 1 ? "about 1 hour" : "about \(hours) hours"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExecApprovalPromptMetadataRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.value)
|
||||
.font(.footnote)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func execApprovalPromptDialog() -> some View {
|
||||
self.modifier(ExecApprovalPromptDialogModifier())
|
||||
}
|
||||
}
|
||||
@@ -61,35 +61,11 @@ final class NodeAppModel {
|
||||
let request: AgentDeepLink
|
||||
}
|
||||
|
||||
struct ExecApprovalPrompt: Identifiable, Equatable {
|
||||
let id: String
|
||||
let commandText: String
|
||||
let allowedDecisions: [String]
|
||||
let host: String?
|
||||
let nodeId: String?
|
||||
let agentId: String?
|
||||
let expiresAtMs: Int?
|
||||
|
||||
var allowsAllowAlways: Bool {
|
||||
self.allowedDecisions.contains("allow-always")
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalResolutionOutcome {
|
||||
case resolved
|
||||
case stale
|
||||
case unavailable
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let deepLinkLogger = Logger(subsystem: "ai.openclaw.ios", category: "DeepLink")
|
||||
private let pushWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "PushWake")
|
||||
private let pendingActionLogger = Logger(subsystem: "ai.openclaw.ios", category: "PendingAction")
|
||||
private let locationWakeLogger = Logger(subsystem: "ai.openclaw.ios", category: "LocationWake")
|
||||
private let watchReplyLogger = Logger(subsystem: "ai.openclaw.ios", category: "WatchReply")
|
||||
private let execApprovalNotificationLogger = Logger(
|
||||
subsystem: "ai.openclaw.ios",
|
||||
category: "ExecApprovalNotification")
|
||||
enum CameraHUDKind {
|
||||
case photo
|
||||
case recording
|
||||
@@ -122,10 +98,6 @@ final class NodeAppModel {
|
||||
var lastShareEventText: String = "No share events yet."
|
||||
var openChatRequestID: Int = 0
|
||||
private(set) var pendingAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private(set) var pendingExecApprovalPrompt: ExecApprovalPrompt?
|
||||
private(set) var pendingExecApprovalPromptResolving: Bool = false
|
||||
private(set) var pendingExecApprovalPromptErrorText: String?
|
||||
private var pendingExecApprovalPromptRequestGeneration: Int = 0
|
||||
private var queuedAgentDeepLinkPrompt: AgentDeepLinkPrompt?
|
||||
private var lastAgentDeepLinkPromptAt: Date = .distantPast
|
||||
@ObservationIgnored private var queuedAgentDeepLinkPromptTask: Task<Void, Never>?
|
||||
@@ -1844,7 +1816,7 @@ private extension NodeAppModel {
|
||||
return DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role) != nil
|
||||
}
|
||||
|
||||
nonisolated static func shouldStartOperatorGatewayLoop(
|
||||
static func shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
@@ -1865,7 +1837,7 @@ private extension NodeAppModel {
|
||||
return hasStoredOperatorToken
|
||||
}
|
||||
|
||||
nonisolated static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
static func clearingBootstrapToken(in config: GatewayConnectConfig?) -> GatewayConnectConfig? {
|
||||
guard let config else { return nil }
|
||||
let trimmedBootstrapToken = config.bootstrapToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
@@ -1906,36 +1878,6 @@ private extension NodeAppModel {
|
||||
GatewaySettingsStore.clearGatewayBootstrapToken(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
private func handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL,
|
||||
stableID: String,
|
||||
token: String?,
|
||||
password: String?,
|
||||
nodeOptions: GatewayConnectOptions,
|
||||
sessionBox: WebSocketSessionBox?) async
|
||||
{
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
stableID: stableID)
|
||||
{
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: token,
|
||||
bootstrapToken: nil,
|
||||
password: password,
|
||||
nodeOptions: nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
// QR bootstrap onboarding should surface the system notification permission
|
||||
// prompt immediately so visible APNs alerts work without a second manual step.
|
||||
_ = await self.requestNotificationAuthorizationIfNeeded()
|
||||
}
|
||||
|
||||
func refreshBackgroundReconnectSuppressionIfNeeded(source: String) {
|
||||
guard self.isBackgrounded else { return }
|
||||
guard !self.backgroundReconnectSuppressed else { return }
|
||||
@@ -1985,20 +1927,17 @@ private extension NodeAppModel {
|
||||
continue
|
||||
}
|
||||
|
||||
let reconnectAuth = self.currentGatewayReconnectAuth(
|
||||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
let effectiveClientId =
|
||||
GatewaySettingsStore.loadGatewayClientIdOverride(stableID: stableID) ?? nodeOptions.clientId
|
||||
let operatorOptions = self.makeOperatorConnectOptions(
|
||||
clientId: effectiveClientId,
|
||||
displayName: nodeOptions.clientDisplayName,
|
||||
includeApprovalScope: self.shouldRequestOperatorApprovalScope(
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password))
|
||||
displayName: nodeOptions.clientDisplayName)
|
||||
|
||||
do {
|
||||
let reconnectAuth = self.currentGatewayReconnectAuth(
|
||||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
try await self.operatorGateway.connect(
|
||||
url: url,
|
||||
token: reconnectAuth.token,
|
||||
@@ -2110,14 +2049,13 @@ private extension NodeAppModel {
|
||||
fallbackToken: token,
|
||||
fallbackBootstrapToken: bootstrapToken,
|
||||
fallbackPassword: password)
|
||||
let connectedOptions = currentOptions
|
||||
GatewayDiagnostics.log("connect attempt epochMs=\(epochMs) url=\(url.absoluteString)")
|
||||
try await self.nodeGateway.connect(
|
||||
url: url,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: reconnectAuth.bootstrapToken,
|
||||
password: reconnectAuth.password,
|
||||
connectOptions: connectedOptions,
|
||||
connectOptions: currentOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -2133,13 +2071,24 @@ private extension NodeAppModel {
|
||||
reconnectAuth.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty == false
|
||||
if usedBootstrapToken {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: reconnectAuth.token,
|
||||
password: reconnectAuth.password,
|
||||
nodeOptions: connectedOptions,
|
||||
sessionBox: sessionBox)
|
||||
await MainActor.run {
|
||||
self.clearPersistedGatewayBootstrapTokenIfNeeded()
|
||||
if self.operatorGatewayTask == nil && self.shouldStartOperatorGatewayLoop(
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: nil,
|
||||
password: reconnectAuth.password,
|
||||
stableID: stableID)
|
||||
{
|
||||
self.startOperatorGatewayLoop(
|
||||
url: url,
|
||||
stableID: stableID,
|
||||
token: reconnectAuth.token,
|
||||
bootstrapToken: nil,
|
||||
password: reconnectAuth.password,
|
||||
nodeOptions: currentOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
}
|
||||
}
|
||||
let relayData = await MainActor.run {
|
||||
(
|
||||
@@ -2297,47 +2246,10 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRequestOperatorApprovalScope(token: String?, password: String?) -> Bool {
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let storedOperatorScopes = DeviceAuthStore
|
||||
.loadToken(deviceId: identity.deviceId, role: "operator")?
|
||||
.scopes ?? []
|
||||
return Self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes)
|
||||
}
|
||||
|
||||
nonisolated static func shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedToken.isEmpty {
|
||||
return true
|
||||
}
|
||||
let trimmedPassword = password?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPassword.isEmpty {
|
||||
return true
|
||||
}
|
||||
return storedOperatorScopes.contains("operator.approvals")
|
||||
}
|
||||
|
||||
func makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
var scopes = ["operator.read", "operator.write", "operator.talk.secrets"]
|
||||
// Preserve reconnect compatibility for older paired operator tokens that were
|
||||
// approved before iOS requested operator.approvals by default.
|
||||
if includeApprovalScope {
|
||||
scopes.append("operator.approvals")
|
||||
}
|
||||
return GatewayConnectOptions(
|
||||
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
|
||||
GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: scopes,
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
@@ -2635,19 +2547,6 @@ extension NodeAppModel {
|
||||
+ "backgrounded=\(self.isBackgrounded) "
|
||||
+ "autoReconnect=\(self.gatewayAutoReconnectEnabled)"
|
||||
self.pushWakeLogger.info("\(receivedMessage, privacy: .public)")
|
||||
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: userInfo,
|
||||
notificationCenter: self.notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
}
|
||||
self.execApprovalNotificationLogger.info(
|
||||
"Handled exec approval cleanup push wakeId=\(wakeId, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
let result = await self.reconnectGatewaySessionsForSilentPushIfNeeded(wakeId: wakeId)
|
||||
let outcomeMessage =
|
||||
"Silent push outcome wakeId=\(wakeId) "
|
||||
@@ -2820,216 +2719,6 @@ extension NodeAppModel {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private struct ExecApprovalGetRequest: Encodable {
|
||||
let id: String
|
||||
}
|
||||
|
||||
private struct ExecApprovalResolveRequest: Encodable {
|
||||
let id: String
|
||||
let decision: String
|
||||
}
|
||||
|
||||
private struct ExecApprovalGetResponse: Decodable {
|
||||
var id: String
|
||||
var commandText: String
|
||||
var allowedDecisions: [String]
|
||||
var host: String?
|
||||
var nodeId: String?
|
||||
var agentId: String?
|
||||
var expiresAtMs: Int?
|
||||
}
|
||||
|
||||
func presentExecApprovalNotificationPrompt(_ prompt: ExecApprovalNotificationPrompt) async {
|
||||
let approvalId = prompt.approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !approvalId.isEmpty else { return }
|
||||
|
||||
self.pendingExecApprovalPromptRequestGeneration &+= 1
|
||||
let requestGeneration = self.pendingExecApprovalPromptRequestGeneration
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
|
||||
let fetchedPrompt = await self.fetchExecApprovalPrompt(approvalId: approvalId)
|
||||
guard self.pendingExecApprovalPromptRequestGeneration == requestGeneration else {
|
||||
return
|
||||
}
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
switch fetchedPrompt {
|
||||
case let .loaded(fetchedPrompt):
|
||||
self.presentFetchedExecApprovalPrompt(fetchedPrompt)
|
||||
case .stale:
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: approvalId,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
case let .failed(message):
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval prompt fetch failed id=\(approvalId, privacy: .public) reason=\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalPromptFetchOutcome {
|
||||
case loaded(ExecApprovalPrompt)
|
||||
case stale
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private func presentFetchedExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
self.pendingExecApprovalPrompt = prompt
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
private static func makeExecApprovalPrompt(from details: ExecApprovalGetResponse) -> ExecApprovalPrompt? {
|
||||
let approvalId = details.id.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let commandText = details.commandText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !approvalId.isEmpty, !commandText.isEmpty else { return nil }
|
||||
return ExecApprovalPrompt(
|
||||
id: approvalId,
|
||||
commandText: commandText,
|
||||
allowedDecisions: details.allowedDecisions.compactMap { decision in
|
||||
let trimmed = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
},
|
||||
host: details.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
nodeId: details.nodeId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
agentId: details.agentId?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
expiresAtMs: details.expiresAtMs)
|
||||
}
|
||||
|
||||
private func fetchExecApprovalPrompt(approvalId: String) async -> ExecApprovalPromptFetchOutcome {
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
guard connected else {
|
||||
return .failed(message: "operator_not_connected")
|
||||
}
|
||||
|
||||
do {
|
||||
let payloadJSON = try Self.encodePayload(ExecApprovalGetRequest(id: approvalId))
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "exec.approval.get",
|
||||
paramsJSON: payloadJSON,
|
||||
timeoutSeconds: 12)
|
||||
let details = try JSONDecoder().decode(ExecApprovalGetResponse.self, from: response)
|
||||
guard let prompt = Self.makeExecApprovalPrompt(from: details) else {
|
||||
return .failed(message: "invalid_prompt_payload")
|
||||
}
|
||||
return .loaded(prompt)
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
return .stale
|
||||
}
|
||||
return .failed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func dismissPendingExecApprovalPrompt() {
|
||||
self.pendingExecApprovalPrompt = nil
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
}
|
||||
|
||||
func dismissPendingExecApprovalPrompt(approvalId: String) {
|
||||
self.clearPendingExecApprovalPromptIfMatches(approvalId)
|
||||
}
|
||||
|
||||
func resolvePendingExecApprovalPrompt(decision: String) async {
|
||||
guard let prompt = self.pendingExecApprovalPrompt else { return }
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedDecision.isEmpty else { return }
|
||||
|
||||
self.pendingExecApprovalPromptResolving = true
|
||||
self.pendingExecApprovalPromptErrorText = nil
|
||||
let outcome = await self.resolveExecApprovalNotificationDecision(
|
||||
approvalId: prompt.id,
|
||||
decision: normalizedDecision)
|
||||
switch outcome {
|
||||
case .resolved, .stale, .unavailable:
|
||||
break
|
||||
case let .failed(message):
|
||||
self.pendingExecApprovalPromptResolving = false
|
||||
self.pendingExecApprovalPromptErrorText = message
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveExecApprovalNotificationDecision(
|
||||
approvalId: String,
|
||||
decision: String
|
||||
) async -> ExecApprovalResolutionOutcome {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedDecision = decision.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedApprovalID.isEmpty, !normalizedDecision.isEmpty else {
|
||||
return .failed(message: "Invalid approval request.")
|
||||
}
|
||||
|
||||
let connected = await self.ensureOperatorApprovalConnection(timeoutMs: 12_000)
|
||||
guard connected else {
|
||||
self.execApprovalNotificationLogger.error(
|
||||
"Exec approval action failed id=\(normalizedApprovalID, privacy: .public): operator not connected")
|
||||
return .failed(message: "OpenClaw couldn't connect to the gateway operator session.")
|
||||
}
|
||||
|
||||
do {
|
||||
let payloadJSON = try Self.encodePayload(
|
||||
ExecApprovalResolveRequest(id: normalizedApprovalID, decision: normalizedDecision))
|
||||
_ = try await self.operatorGateway.request(
|
||||
method: "exec.approval.resolve",
|
||||
paramsJSON: payloadJSON,
|
||||
timeoutSeconds: 12)
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .resolved
|
||||
} catch {
|
||||
if Self.isApprovalNotificationStaleError(error) {
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .stale
|
||||
}
|
||||
if Self.isApprovalNotificationUnavailableError(error) {
|
||||
await ExecApprovalNotificationBridge.removeNotifications(
|
||||
forApprovalID: normalizedApprovalID,
|
||||
notificationCenter: self.notificationCenter)
|
||||
self.clearPendingExecApprovalPromptIfMatches(normalizedApprovalID)
|
||||
return .unavailable
|
||||
}
|
||||
let logMessage =
|
||||
"Exec approval action failed id=\(normalizedApprovalID) error=\(error.localizedDescription)"
|
||||
self.execApprovalNotificationLogger.error("\(logMessage, privacy: .public)")
|
||||
return .failed(
|
||||
message: "OpenClaw couldn't resolve this approval right now. Try again.")
|
||||
}
|
||||
}
|
||||
|
||||
private func clearPendingExecApprovalPromptIfMatches(_ approvalId: String) {
|
||||
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard self.pendingExecApprovalPrompt?.id == normalizedApprovalID else { return }
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
}
|
||||
if gatewayError.detailsReason == "APPROVAL_NOT_FOUND" {
|
||||
return true
|
||||
}
|
||||
return gatewayError.message.lowercased().contains("unknown or expired approval id")
|
||||
}
|
||||
|
||||
nonisolated private static func isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
guard let gatewayError = error as? GatewayResponseError else { return false }
|
||||
if gatewayError.code != "INVALID_REQUEST" {
|
||||
return false
|
||||
}
|
||||
if gatewayError.detailsReason == "APPROVAL_ALLOW_ALWAYS_UNAVAILABLE" {
|
||||
return true
|
||||
}
|
||||
return gatewayError.message.lowercased().contains("allow-always is unavailable")
|
||||
}
|
||||
|
||||
private struct SilentPushWakeAttemptResult {
|
||||
var applied: Bool
|
||||
var reason: String
|
||||
@@ -3041,69 +2730,14 @@ extension NodeAppModel {
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await self.isGatewayConnected() {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
}
|
||||
return await self.isGatewayConnected()
|
||||
}
|
||||
|
||||
private func waitForOperatorConnection(timeoutMs: Int, pollMs: Int) async -> Bool {
|
||||
let clampedTimeoutMs = max(0, timeoutMs)
|
||||
let pollIntervalNs = UInt64(max(50, pollMs)) * 1_000_000
|
||||
let deadline = Date().addingTimeInterval(Double(clampedTimeoutMs) / 1000.0)
|
||||
while Date() < deadline {
|
||||
if Task.isCancelled {
|
||||
return false
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
}
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: pollIntervalNs)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return await self.isOperatorConnected()
|
||||
}
|
||||
|
||||
private func ensureOperatorReconnectLoopIfNeeded() {
|
||||
guard let cfg = self.activeGatewayConnectConfig else {
|
||||
return
|
||||
}
|
||||
guard self.operatorGatewayTask == nil else {
|
||||
return
|
||||
}
|
||||
let stableID = cfg.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let effectiveStableID = stableID.isEmpty ? cfg.url.absoluteString : stableID
|
||||
let sessionBox = cfg.tls.map { WebSocketSessionBox(session: GatewayTLSPinningSession(params: $0)) }
|
||||
self.startOperatorGatewayLoop(
|
||||
url: cfg.url,
|
||||
stableID: effectiveStableID,
|
||||
token: cfg.token,
|
||||
bootstrapToken: cfg.bootstrapToken,
|
||||
password: cfg.password,
|
||||
nodeOptions: cfg.nodeOptions,
|
||||
sessionBox: sessionBox)
|
||||
}
|
||||
|
||||
private func ensureOperatorApprovalConnection(timeoutMs: Int) async -> Bool {
|
||||
if await self.isOperatorConnected() {
|
||||
return true
|
||||
}
|
||||
self.ensureOperatorReconnectLoopIfNeeded()
|
||||
return await self.waitForOperatorConnection(timeoutMs: timeoutMs, pollMs: 250)
|
||||
}
|
||||
|
||||
private func reconnectGatewaySessionsForSilentPushIfNeeded(
|
||||
wakeId: String
|
||||
) async -> SilentPushWakeAttemptResult {
|
||||
@@ -3503,62 +3137,11 @@ extension NodeAppModel {
|
||||
await self.applyPendingForegroundNodeActions(mapped, trigger: "test")
|
||||
}
|
||||
|
||||
func _test_makeOperatorConnectOptions(
|
||||
clientId: String,
|
||||
displayName: String?,
|
||||
includeApprovalScope: Bool
|
||||
) -> GatewayConnectOptions {
|
||||
self.makeOperatorConnectOptions(
|
||||
clientId: clientId,
|
||||
displayName: displayName,
|
||||
includeApprovalScope: includeApprovalScope)
|
||||
}
|
||||
|
||||
func _test_presentExecApprovalPrompt(_ prompt: ExecApprovalPrompt) {
|
||||
self.presentFetchedExecApprovalPrompt(prompt)
|
||||
}
|
||||
|
||||
func _test_dismissPendingExecApprovalPrompt() {
|
||||
self.dismissPendingExecApprovalPrompt()
|
||||
}
|
||||
|
||||
func _test_pendingExecApprovalPrompt() -> ExecApprovalPrompt? {
|
||||
self.pendingExecApprovalPrompt
|
||||
}
|
||||
|
||||
nonisolated static func _test_isApprovalNotificationStaleError(_ error: Error) -> Bool {
|
||||
self.isApprovalNotificationStaleError(error)
|
||||
}
|
||||
|
||||
nonisolated static func _test_isApprovalNotificationUnavailableError(_ error: Error) -> Bool {
|
||||
self.isApprovalNotificationUnavailableError(error)
|
||||
}
|
||||
|
||||
static func _test_makeExecApprovalPrompt(
|
||||
id: String,
|
||||
commandText: String,
|
||||
allowedDecisions: [String],
|
||||
host: String?,
|
||||
nodeId: String?,
|
||||
agentId: String?,
|
||||
expiresAtMs: Int?
|
||||
) -> ExecApprovalPrompt? {
|
||||
self.makeExecApprovalPrompt(
|
||||
from: ExecApprovalGetResponse(
|
||||
id: id,
|
||||
commandText: commandText,
|
||||
allowedDecisions: allowedDecisions,
|
||||
host: host,
|
||||
nodeId: nodeId,
|
||||
agentId: agentId,
|
||||
expiresAtMs: expiresAtMs))
|
||||
}
|
||||
|
||||
static func _test_currentDeepLinkKey() -> String {
|
||||
self.expectedDeepLinkKey()
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldStartOperatorGatewayLoop(
|
||||
static func _test_shouldStartOperatorGatewayLoop(
|
||||
token: String?,
|
||||
bootstrapToken: String?,
|
||||
password: String?,
|
||||
@@ -3571,41 +3154,6 @@ extension NodeAppModel {
|
||||
hasStoredOperatorToken: hasStoredOperatorToken)
|
||||
}
|
||||
|
||||
nonisolated static func _test_shouldRequestOperatorApprovalScope(
|
||||
token: String?,
|
||||
password: String?,
|
||||
storedOperatorScopes: [String]
|
||||
) -> Bool {
|
||||
self.shouldRequestOperatorApprovalScope(
|
||||
token: token,
|
||||
password: password,
|
||||
storedOperatorScopes: storedOperatorScopes)
|
||||
}
|
||||
|
||||
nonisolated static func _test_clearingBootstrapToken(
|
||||
in config: GatewayConnectConfig?
|
||||
) -> GatewayConnectConfig? {
|
||||
self.clearingBootstrapToken(in: config)
|
||||
}
|
||||
|
||||
func _test_handleSuccessfulBootstrapGatewayOnboarding() async {
|
||||
await self.handleSuccessfulBootstrapGatewayOnboarding(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
stableID: "test-gateway",
|
||||
token: nil,
|
||||
password: nil,
|
||||
nodeOptions: GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios",
|
||||
clientMode: "node",
|
||||
clientDisplayName: nil),
|
||||
sessionBox: nil)
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
// swiftlint:enable type_body_length file_length
|
||||
|
||||
@@ -13,8 +13,6 @@ private struct PendingWatchPromptAction {
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private typealias PendingExecApprovalPrompt = ExecApprovalNotificationPrompt
|
||||
|
||||
@MainActor
|
||||
final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotificationCenterDelegate {
|
||||
private let logger = Logger(subsystem: "ai.openclaw.ios", category: "Push")
|
||||
@@ -23,7 +21,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
private var backgroundWakeTask: Task<Bool, Never>?
|
||||
private var pendingAPNsDeviceToken: Data?
|
||||
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
|
||||
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
|
||||
|
||||
weak var appModel: NodeAppModel? {
|
||||
didSet {
|
||||
@@ -47,15 +44,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.pendingExecApprovalPrompts.isEmpty {
|
||||
let pending = self.pendingExecApprovalPrompts
|
||||
self.pendingExecApprovalPrompts.removeAll()
|
||||
Task { @MainActor in
|
||||
for prompt in pending {
|
||||
await model.presentExecApprovalNotificationPrompt(prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,17 +80,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
{
|
||||
self.logger.info("APNs remote notification received keys=\(userInfo.keys.count, privacy: .public)")
|
||||
Task { @MainActor in
|
||||
let notificationCenter = LiveNotificationCenter()
|
||||
if await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: userInfo,
|
||||
notificationCenter: notificationCenter)
|
||||
{
|
||||
if let approvalId = ExecApprovalNotificationBridge.approvalID(from: userInfo) {
|
||||
self.appModel?.dismissPendingExecApprovalPrompt(approvalId: approvalId)
|
||||
}
|
||||
completionHandler(.newData)
|
||||
return
|
||||
}
|
||||
guard let appModel = self.appModel else {
|
||||
self.logger.info("APNs wake skipped: appModel unavailable")
|
||||
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_no_model")
|
||||
@@ -239,14 +216,6 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
sessionKey: sessionKey)
|
||||
}
|
||||
|
||||
private static func parseExecApprovalPrompt(
|
||||
from response: UNNotificationResponse) -> PendingExecApprovalPrompt?
|
||||
{
|
||||
ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: response.actionIdentifier,
|
||||
userInfo: response.notification.request.content.userInfo)
|
||||
}
|
||||
|
||||
private func routeWatchPromptAction(_ action: PendingWatchPromptAction) async {
|
||||
guard let appModel = self.appModel else {
|
||||
self.pendingWatchPromptActions.append(action)
|
||||
@@ -260,25 +229,13 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
_ = await appModel.handleBackgroundRefreshWake(trigger: "watch_prompt_action")
|
||||
}
|
||||
|
||||
private func routeExecApprovalPrompt(_ prompt: PendingExecApprovalPrompt) {
|
||||
guard let appModel = self.appModel else {
|
||||
self.pendingExecApprovalPrompts.append(prompt)
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
await appModel.presentExecApprovalNotificationPrompt(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void)
|
||||
{
|
||||
let userInfo = notification.request.content.userInfo
|
||||
if Self.isWatchPromptNotification(userInfo)
|
||||
|| ExecApprovalNotificationBridge.shouldPresentNotification(userInfo: userInfo)
|
||||
{
|
||||
if Self.isWatchPromptNotification(userInfo) {
|
||||
completionHandler([.banner, .list, .sound])
|
||||
return
|
||||
}
|
||||
@@ -290,29 +247,18 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void)
|
||||
{
|
||||
if let action = Self.parseWatchPromptAction(from: response) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
await self.routeWatchPromptAction(action)
|
||||
completionHandler()
|
||||
}
|
||||
guard let action = Self.parseWatchPromptAction(from: response) else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
if let prompt = Self.parseExecApprovalPrompt(from: response) {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
self.routeExecApprovalPrompt(prompt)
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
return
|
||||
await self.routeWatchPromptAction(action)
|
||||
completionHandler()
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct ExecApprovalNotificationPrompt: Sendable, Equatable {
|
||||
let approvalId: String
|
||||
}
|
||||
|
||||
enum ExecApprovalNotificationBridge {
|
||||
static let requestedKind = "exec.approval.requested"
|
||||
static let resolvedKind = "exec.approval.resolved"
|
||||
|
||||
private static let localRequestPrefix = "exec.approval."
|
||||
|
||||
static func shouldPresentNotification(userInfo: [AnyHashable: Any]) -> Bool {
|
||||
self.payloadKind(userInfo: userInfo) == self.requestedKind
|
||||
}
|
||||
|
||||
static func parsePrompt(
|
||||
actionIdentifier: String,
|
||||
userInfo: [AnyHashable: Any]
|
||||
) -> ExecApprovalNotificationPrompt?
|
||||
{
|
||||
guard actionIdentifier == UNNotificationDefaultActionIdentifier else { return nil }
|
||||
guard self.payloadKind(userInfo: userInfo) == self.requestedKind else { return nil }
|
||||
guard let approvalId = self.approvalID(from: userInfo) else { return nil }
|
||||
return ExecApprovalNotificationPrompt(approvalId: approvalId)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func handleResolvedPushIfNeeded(
|
||||
userInfo: [AnyHashable: Any],
|
||||
notificationCenter: NotificationCentering
|
||||
) async -> Bool
|
||||
{
|
||||
guard self.payloadKind(userInfo: userInfo) == self.resolvedKind,
|
||||
let approvalId = self.approvalID(from: userInfo)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
await self.removeNotifications(forApprovalID: approvalId, notificationCenter: notificationCenter)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeNotifications(
|
||||
forApprovalID approvalId: String,
|
||||
notificationCenter: NotificationCentering
|
||||
) async {
|
||||
let normalizedID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedID.isEmpty else { return }
|
||||
|
||||
await notificationCenter.removePendingNotificationRequests(
|
||||
withIdentifiers: [self.localRequestIdentifier(for: normalizedID)])
|
||||
|
||||
let delivered = await notificationCenter.deliveredNotifications()
|
||||
let identifiers = delivered.compactMap { snapshot -> String? in
|
||||
guard self.approvalID(from: snapshot.userInfo) == normalizedID else { return nil }
|
||||
return snapshot.identifier
|
||||
}
|
||||
await notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
static func approvalID(from userInfo: [AnyHashable: Any]) -> String? {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["approvalId"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func localRequestIdentifier(for approvalId: String) -> String {
|
||||
"\(self.localRequestPrefix)\(approvalId)"
|
||||
}
|
||||
|
||||
private static func payloadKind(userInfo: [AnyHashable: Any]) -> String {
|
||||
let raw = self.openClawPayload(userInfo: userInfo)?["kind"] as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "unknown" : trimmed
|
||||
}
|
||||
|
||||
private static func openClawPayload(userInfo: [AnyHashable: Any]) -> [String: Any]? {
|
||||
if let payload = userInfo["openclaw"] as? [String: Any] {
|
||||
return payload
|
||||
}
|
||||
if let payload = userInfo["openclaw"] as? [AnyHashable: Any] {
|
||||
return payload.reduce(into: [String: Any]()) { partialResult, pair in
|
||||
guard let key = pair.key as? String else { return }
|
||||
partialResult[key] = pair.value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,6 @@ struct RootCanvas: View {
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.deepLinkAgentPromptAlert()
|
||||
.execApprovalPromptDialog()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
struct NotificationSnapshot: @unchecked Sendable {
|
||||
let identifier: String
|
||||
let userInfo: [AnyHashable: Any]
|
||||
}
|
||||
|
||||
enum NotificationAuthorizationStatus: Sendable {
|
||||
case notDetermined
|
||||
case denied
|
||||
@@ -18,9 +13,6 @@ protocol NotificationCentering: Sendable {
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
|
||||
func add(_ request: UNNotificationRequest) async throws
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async
|
||||
func deliveredNotifications() async -> [NotificationSnapshot]
|
||||
}
|
||||
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
@@ -63,27 +55,4 @@ struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
|
||||
guard !identifiers.isEmpty else { return }
|
||||
self.center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.center.getDeliveredNotifications { notifications in
|
||||
continuation.resume(
|
||||
returning: notifications.map { notification in
|
||||
NotificationSnapshot(
|
||||
identifier: notification.request.identifier,
|
||||
userInfo: notification.request.content.userInfo)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import UserNotifications
|
||||
@testable import OpenClaw
|
||||
|
||||
private final class MockNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
var authorization: NotificationAuthorizationStatus = .authorized
|
||||
var addedRequests: [UNNotificationRequest] = []
|
||||
var pendingRemovedIdentifiers: [[String]] = []
|
||||
var deliveredRemovedIdentifiers: [[String]] = []
|
||||
var delivered: [NotificationSnapshot] = []
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
self.authorization
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
self.addedRequests.append(request)
|
||||
}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers identifiers: [String]) async {
|
||||
self.pendingRemovedIdentifiers.append(identifiers)
|
||||
}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers identifiers: [String]) async {
|
||||
self.deliveredRemovedIdentifiers.append(identifiers)
|
||||
}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
self.delivered
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct ExecApprovalNotificationBridgeTests {
|
||||
@Test func parsePromptMapsDefaultNotificationTap() {
|
||||
let prompt = ExecApprovalNotificationBridge.parsePrompt(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
])
|
||||
|
||||
#expect(prompt == ExecApprovalNotificationPrompt(approvalId: "approval-123"))
|
||||
}
|
||||
|
||||
@Test @MainActor func handleResolvedPushRemovesMatchingNotifications() async {
|
||||
let center = MockNotificationCenter()
|
||||
center.delivered = [
|
||||
NotificationSnapshot(
|
||||
identifier: "remote-approval-1",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
]),
|
||||
NotificationSnapshot(
|
||||
identifier: "remote-other",
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.requestedKind,
|
||||
"approvalId": "approval-999",
|
||||
],
|
||||
]),
|
||||
]
|
||||
|
||||
let handled = await ExecApprovalNotificationBridge.handleResolvedPushIfNeeded(
|
||||
userInfo: [
|
||||
"openclaw": [
|
||||
"kind": ExecApprovalNotificationBridge.resolvedKind,
|
||||
"approvalId": "approval-123",
|
||||
],
|
||||
],
|
||||
notificationCenter: center)
|
||||
|
||||
#expect(handled)
|
||||
#expect(center.pendingRemovedIdentifiers == [["exec.approval.approval-123"]])
|
||||
#expect(center.deliveredRemovedIdentifiers == [["remote-approval-1"]])
|
||||
}
|
||||
}
|
||||
@@ -70,52 +70,6 @@ import UIKit
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func operatorConnectOptionsOnlyRequestApprovalScopeWhenEnabled() {
|
||||
let appModel = NodeAppModel()
|
||||
let withoutApprovalScope = appModel._test_makeOperatorConnectOptions(
|
||||
clientId: "openclaw-ios",
|
||||
displayName: "OpenClaw iOS",
|
||||
includeApprovalScope: false)
|
||||
let withApprovalScope = appModel._test_makeOperatorConnectOptions(
|
||||
clientId: "openclaw-ios",
|
||||
displayName: "OpenClaw iOS",
|
||||
includeApprovalScope: true)
|
||||
|
||||
#expect(withoutApprovalScope.role == "operator")
|
||||
#expect(withoutApprovalScope.scopes.contains("operator.read"))
|
||||
#expect(withoutApprovalScope.scopes.contains("operator.write"))
|
||||
#expect(!withoutApprovalScope.scopes.contains("operator.approvals"))
|
||||
#expect(withoutApprovalScope.scopes.contains("operator.talk.secrets"))
|
||||
|
||||
#expect(withApprovalScope.scopes.contains("operator.approvals"))
|
||||
}
|
||||
|
||||
@Test func operatorApprovalScopeRequestsStayBackwardCompatible() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: nil,
|
||||
password: nil,
|
||||
storedOperatorScopes: ["operator.read", "operator.write", "operator.talk.secrets"])
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: nil,
|
||||
password: nil,
|
||||
storedOperatorScopes: [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.write",
|
||||
"operator.talk.secrets",
|
||||
])
|
||||
)
|
||||
#expect(
|
||||
NodeAppModel._test_shouldRequestOperatorApprovalScope(
|
||||
token: "shared-token",
|
||||
password: nil,
|
||||
storedOperatorScopes: [])
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
|
||||
defer {
|
||||
|
||||
@@ -2,7 +2,6 @@ import OpenClawKit
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
@testable import OpenClaw
|
||||
|
||||
private func makeAgentDeepLinkURL(
|
||||
@@ -69,36 +68,6 @@ private final class MockWatchMessagingService: @preconcurrency WatchMessagingSer
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockBootstrapNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
var status: NotificationAuthorizationStatus = .notDetermined
|
||||
var requestAuthorizationResult = false
|
||||
var requestAuthorizationCalls = 0
|
||||
|
||||
func authorizationStatus() async -> NotificationAuthorizationStatus {
|
||||
self.status
|
||||
}
|
||||
|
||||
func requestAuthorization(options _: UNAuthorizationOptions) async throws -> Bool {
|
||||
self.requestAuthorizationCalls += 1
|
||||
if self.requestAuthorizationResult {
|
||||
self.status = .authorized
|
||||
} else {
|
||||
self.status = .denied
|
||||
}
|
||||
return self.requestAuthorizationResult
|
||||
}
|
||||
|
||||
func add(_: UNNotificationRequest) async throws {}
|
||||
|
||||
func removePendingNotificationRequests(withIdentifiers _: [String]) async {}
|
||||
|
||||
func removeDeliveredNotifications(withIdentifiers _: [String]) async {}
|
||||
|
||||
func deliveredNotifications() async -> [NotificationSnapshot] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct NodeAppModelInvokeTests {
|
||||
@Test @MainActor func decodeParamsFailsWithoutJSON() {
|
||||
#expect(throws: Error.self) {
|
||||
@@ -127,79 +96,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(appModel.mainSessionKey == "agent:agent-123:main")
|
||||
}
|
||||
|
||||
@Test @MainActor func execApprovalPromptPresentationTracksLatestNotificationTap() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-1",
|
||||
commandText: "echo first",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: "main",
|
||||
expiresAtMs: 1)))
|
||||
|
||||
let firstPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(firstPrompt.id == "approval-1")
|
||||
#expect(firstPrompt.commandText == "echo first")
|
||||
#expect(firstPrompt.allowsAllowAlways == false)
|
||||
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-2",
|
||||
commandText: "echo second",
|
||||
allowedDecisions: ["allow-once", "allow-always", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: "node-2",
|
||||
agentId: nil,
|
||||
expiresAtMs: 2)))
|
||||
|
||||
let secondPrompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(secondPrompt.id == "approval-2")
|
||||
#expect(secondPrompt.commandText == "echo second")
|
||||
#expect(secondPrompt.allowsAllowAlways)
|
||||
|
||||
appModel._test_dismissPendingExecApprovalPrompt()
|
||||
#expect(appModel._test_pendingExecApprovalPrompt() == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func dismissPendingExecApprovalPromptByIdLeavesDifferentPromptVisible() throws {
|
||||
let appModel = NodeAppModel()
|
||||
appModel._test_presentExecApprovalPrompt(
|
||||
try #require(
|
||||
NodeAppModel._test_makeExecApprovalPrompt(
|
||||
id: "approval-active",
|
||||
commandText: "echo keep",
|
||||
allowedDecisions: ["allow-once", "deny"],
|
||||
host: "gateway",
|
||||
nodeId: nil,
|
||||
agentId: nil,
|
||||
expiresAtMs: 1)))
|
||||
|
||||
appModel.dismissPendingExecApprovalPrompt(approvalId: "approval-stale")
|
||||
|
||||
let prompt = try #require(appModel._test_pendingExecApprovalPrompt())
|
||||
#expect(prompt.id == "approval-active")
|
||||
}
|
||||
|
||||
@Test func approvalNotificationErrorClassificationPrefersStructuredDetails() {
|
||||
let staleError = GatewayResponseError(
|
||||
method: "exec.approval.get",
|
||||
code: "INVALID_REQUEST",
|
||||
message: "gateway error",
|
||||
details: ["reason": AnyCodable("APPROVAL_NOT_FOUND")])
|
||||
let unavailableError = GatewayResponseError(
|
||||
method: "exec.approval.resolve",
|
||||
code: "INVALID_REQUEST",
|
||||
message: "gateway error",
|
||||
details: ["reason": AnyCodable("APPROVAL_ALLOW_ALWAYS_UNAVAILABLE")])
|
||||
|
||||
#expect(NodeAppModel._test_isApprovalNotificationStaleError(staleError))
|
||||
#expect(NodeAppModel._test_isApprovalNotificationUnavailableError(unavailableError))
|
||||
}
|
||||
|
||||
@Test func operatorLoopWaitsForBootstrapHandoffBeforeUsingStoredToken() {
|
||||
#expect(
|
||||
!NodeAppModel._test_shouldStartOperatorGatewayLoop(
|
||||
@@ -231,15 +127,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
)
|
||||
}
|
||||
|
||||
@Test @MainActor func successfulBootstrapOnboardingRequestsNotificationAuthorization() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
|
||||
await appModel._test_handleSuccessfulBootstrapGatewayOnboarding()
|
||||
|
||||
#expect(center.requestAuthorizationCalls == 1)
|
||||
}
|
||||
|
||||
@Test func clearingBootstrapTokenStripsReconnectConfigEvenWithoutPersistence() {
|
||||
let config = GatewayConnectConfig(
|
||||
url: URL(string: "wss://gateway.example")!,
|
||||
@@ -258,7 +145,7 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
clientMode: "node",
|
||||
clientDisplayName: nil))
|
||||
|
||||
let cleared = NodeAppModel._test_clearingBootstrapToken(in: config)
|
||||
let cleared = NodeAppModel.clearingBootstrapToken(in: config)
|
||||
#expect(cleared?.bootstrapToken == nil)
|
||||
#expect(cleared?.url == config.url)
|
||||
#expect(cleared?.stableID == config.stableID)
|
||||
|
||||
@@ -86,43 +86,6 @@ cd apps/ios
|
||||
fastlane ios beta
|
||||
```
|
||||
|
||||
Maintainer recovery path for a fresh clone on the same Mac:
|
||||
|
||||
1. Reuse the existing Keychain-backed ASC key on that machine.
|
||||
2. Restore or recreate `apps/ios/fastlane/.env` so it contains the non-secret variables:
|
||||
|
||||
```bash
|
||||
ASC_KEY_ID=YOUR_KEY_ID
|
||||
ASC_ISSUER_ID=YOUR_ISSUER_ID
|
||||
ASC_KEYCHAIN_SERVICE=openclaw-asc-key
|
||||
ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||
```
|
||||
|
||||
3. Re-run auth validation:
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
fastlane ios auth_check
|
||||
```
|
||||
|
||||
4. Set the official/TestFlight relay URL before release:
|
||||
|
||||
```bash
|
||||
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
|
||||
```
|
||||
|
||||
5. Upload:
|
||||
|
||||
```bash
|
||||
pnpm ios:beta
|
||||
```
|
||||
|
||||
Quick verification after upload:
|
||||
|
||||
- confirm `apps/ios/build/beta/OpenClaw-<version>.ipa` exists
|
||||
- confirm Fastlane prints `Uploaded iOS beta: version=<version> short=<short> build=<build>`
|
||||
- remember that TestFlight processing can take a few minutes after the upload succeeds
|
||||
|
||||
Versioning rules:
|
||||
|
||||
- Root `package.json.version` is the single source of truth for iOS
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "fb90e7b1977f43661ac91681d16da11f9ddd85630407ef170eaada0a6ee39972",
|
||||
"originHash" : "1c9c9d251b760ed3234ecff741a88eb4bf42315ad6f50ac7392b187cf226c16c",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
@@ -24,7 +24,7 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||
"state" : {
|
||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
||||
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.5</string>
|
||||
<string>2026.4.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026040501</string>
|
||||
<string>2026040301</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -43,8 +43,8 @@ enum WideAreaGatewayDiscovery {
|
||||
guard let statusJson = context.tailscaleStatus(),
|
||||
!collectTailnetIPv4s(statusJson: statusJson).isEmpty,
|
||||
let discovery = loadWideAreaPtrRecords(
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
else { return [] }
|
||||
|
||||
let domainTrimmed = discovery.domainTrimmed
|
||||
|
||||
@@ -2019,7 +2019,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let ratewpm: Int?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
@@ -2027,7 +2026,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
public let latencytier: Int?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
@@ -2035,22 +2033,19 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
ratewpm: Int?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?,
|
||||
latencytier: Int?)
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.ratewpm = ratewpm
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
@@ -2058,7 +2053,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
self.latencytier = latencytier
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2067,7 +2061,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case ratewpm = "rateWpm"
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
@@ -2075,7 +2068,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
case latencytier = "latencyTier"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -542,77 +542,6 @@ public actor GatewayChannelActor {
|
||||
authSource: authSource)
|
||||
}
|
||||
|
||||
private func shouldPersistBootstrapHandoffTokens() -> Bool {
|
||||
guard self.lastAuthSource == .bootstrapToken else { return false }
|
||||
let scheme = self.url.scheme?.lowercased()
|
||||
if scheme == "wss" {
|
||||
return true
|
||||
}
|
||||
if let host = self.url.host, LoopbackHost.isLoopback(host) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func filteredBootstrapHandoffScopes(role: String, scopes: [String]) -> [String]? {
|
||||
let normalizedRole = role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch normalizedRole {
|
||||
case "node":
|
||||
return []
|
||||
case "operator":
|
||||
let allowedOperatorScopes: Set<String> = [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
]
|
||||
return Array(Set(scopes.filter { allowedOperatorScopes.contains($0) })).sorted()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func persistBootstrapHandoffToken(
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String]
|
||||
) {
|
||||
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
|
||||
return
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: filteredScopes)
|
||||
}
|
||||
|
||||
private func persistIssuedDeviceToken(
|
||||
authSource: GatewayAuthSource,
|
||||
deviceId: String,
|
||||
role: String,
|
||||
token: String,
|
||||
scopes: [String]
|
||||
) {
|
||||
if authSource == .bootstrapToken {
|
||||
guard self.shouldPersistBootstrapHandoffTokens() else {
|
||||
return
|
||||
}
|
||||
self.persistBootstrapHandoffToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes)
|
||||
return
|
||||
}
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: deviceId,
|
||||
role: role,
|
||||
token: token,
|
||||
scopes: scopes)
|
||||
}
|
||||
|
||||
private func handleConnectResponse(
|
||||
_ res: ResponseFrame,
|
||||
identity: DeviceIdentity?,
|
||||
@@ -643,37 +572,18 @@ public actor GatewayChannelActor {
|
||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||
self.tickIntervalMs = Double(tick)
|
||||
}
|
||||
if let auth = ok.auth, let identity {
|
||||
if let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistIssuedDeviceToken(
|
||||
authSource: self.lastAuthSource,
|
||||
if let auth = ok.auth,
|
||||
let deviceToken = auth["deviceToken"]?.value as? String {
|
||||
let authRole = auth["role"]?.value as? String ?? role
|
||||
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
if let identity {
|
||||
_ = DeviceAuthStore.storeToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
if self.shouldPersistBootstrapHandoffTokens(),
|
||||
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
|
||||
{
|
||||
for entry in tokenEntries {
|
||||
guard let rawEntry = entry.value as? [String: ProtoAnyCodable],
|
||||
let deviceToken = rawEntry["deviceToken"]?.value as? String,
|
||||
let authRole = rawEntry["role"]?.value as? String
|
||||
else {
|
||||
continue
|
||||
}
|
||||
let scopes = (rawEntry["scopes"]?.value as? [ProtoAnyCodable])?
|
||||
.compactMap { $0.value as? String } ?? []
|
||||
self.persistBootstrapHandoffToken(
|
||||
deviceId: identity.deviceId,
|
||||
role: authRole,
|
||||
token: deviceToken,
|
||||
scopes: scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.lastTick = Date()
|
||||
self.tickTask?.cancel()
|
||||
|
||||
@@ -127,12 +127,6 @@ public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||
self.details = details ?? [:]
|
||||
}
|
||||
|
||||
public var detailsReason: String? {
|
||||
let raw = self.details["reason"]?.value as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
if self.code == "GATEWAY_ERROR" { return "\(self.method): \(self.message)" }
|
||||
return "\(self.method): [\(self.code)] \(self.message)"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,7 +130,44 @@ public enum ToolDisplayRegistry {
|
||||
"messageId",
|
||||
],
|
||||
actions: nil),
|
||||
tools: nil)
|
||||
tools: [
|
||||
"bash": ToolDisplaySpec(
|
||||
emoji: "🛠️",
|
||||
title: "Bash",
|
||||
label: nil,
|
||||
detailKeys: ["command"],
|
||||
actions: nil),
|
||||
"read": ToolDisplaySpec(
|
||||
emoji: "📖",
|
||||
title: "Read",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"write": ToolDisplaySpec(
|
||||
emoji: "✍️",
|
||||
title: "Write",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"edit": ToolDisplaySpec(
|
||||
emoji: "📝",
|
||||
title: "Edit",
|
||||
label: nil,
|
||||
detailKeys: ["path"],
|
||||
actions: nil),
|
||||
"attach": ToolDisplaySpec(
|
||||
emoji: "📎",
|
||||
title: "Attach",
|
||||
label: nil,
|
||||
detailKeys: ["path", "url", "fileName"],
|
||||
actions: nil),
|
||||
"process": ToolDisplaySpec(
|
||||
emoji: "🧰",
|
||||
title: "Process",
|
||||
label: nil,
|
||||
detailKeys: ["sessionId"],
|
||||
actions: nil),
|
||||
])
|
||||
}
|
||||
|
||||
private static func titleFromName(_ name: String) -> String {
|
||||
|
||||
@@ -2019,7 +2019,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let ratewpm: Int?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
@@ -2027,7 +2026,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
public let latencytier: Int?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
@@ -2035,22 +2033,19 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
ratewpm: Int?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?,
|
||||
latencytier: Int?)
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.ratewpm = ratewpm
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
@@ -2058,7 +2053,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
self.latencytier = latencytier
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2067,7 +2061,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case ratewpm = "rateWpm"
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
@@ -2075,7 +2068,6 @@ public struct TalkSpeakParams: Codable, Sendable {
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
case latencytier = "latencyTier"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ private extension NSLock {
|
||||
|
||||
private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private var _state: URLSessionTask.State = .suspended
|
||||
private var connectRequestId: String?
|
||||
private var connectAuth: [String: Any]?
|
||||
@@ -21,10 +20,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
private var pendingReceiveHandler:
|
||||
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
|
||||
|
||||
init(helloAuth: [String: Any]? = nil) {
|
||||
self.helloAuth = helloAuth
|
||||
}
|
||||
|
||||
var state: URLSessionTask.State {
|
||||
get { self.lock.withLock { self._state } }
|
||||
set { self.lock.withLock { self._state = newValue } }
|
||||
@@ -84,11 +79,11 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
for _ in 0..<50 {
|
||||
let id = self.lock.withLock { self.connectRequestId }
|
||||
if let id {
|
||||
return .data(Self.connectOkData(id: id, auth: self.helloAuth))
|
||||
return .data(Self.connectOkData(id: id))
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
return .data(Self.connectOkData(id: "connect", auth: self.helloAuth))
|
||||
return .data(Self.connectOkData(id: "connect"))
|
||||
}
|
||||
|
||||
func receive(
|
||||
@@ -115,8 +110,8 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
return (try? JSONSerialization.data(withJSONObject: frame)) ?? Data()
|
||||
}
|
||||
|
||||
private static func connectOkData(id: String, auth: [String: Any]? = nil) -> Data {
|
||||
var payload: [String: Any] = [
|
||||
private static func connectOkData(id: String) -> Data {
|
||||
let payload: [String: Any] = [
|
||||
"type": "hello-ok",
|
||||
"protocol": 2,
|
||||
"server": [
|
||||
@@ -142,9 +137,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
"tickIntervalMs": 30_000,
|
||||
],
|
||||
]
|
||||
if let auth {
|
||||
payload["auth"] = auth
|
||||
}
|
||||
let frame: [String: Any] = [
|
||||
"type": "res",
|
||||
"id": id,
|
||||
@@ -157,14 +149,9 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
|
||||
|
||||
private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private let helloAuth: [String: Any]?
|
||||
private var tasks: [FakeGatewayWebSocketTask] = []
|
||||
private var makeCount = 0
|
||||
|
||||
init(helloAuth: [String: Any]? = nil) {
|
||||
self.helloAuth = helloAuth
|
||||
}
|
||||
|
||||
func snapshotMakeCount() -> Int {
|
||||
self.lock.withLock { self.makeCount }
|
||||
}
|
||||
@@ -177,7 +164,7 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
|
||||
_ = url
|
||||
return self.lock.withLock {
|
||||
self.makeCount += 1
|
||||
let task = FakeGatewayWebSocketTask(helloAuth: self.helloAuth)
|
||||
let task = FakeGatewayWebSocketTask()
|
||||
self.tasks.append(task)
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
@@ -190,7 +177,6 @@ private actor SeqGapProbe {
|
||||
func value() -> Bool { self.saw }
|
||||
}
|
||||
|
||||
@Suite(.serialized)
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
|
||||
@@ -248,210 +234,6 @@ struct GatewayNodeSessionTests {
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "node-device-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"issuedAtMs": 1000,
|
||||
"deviceTokens": [
|
||||
[
|
||||
"deviceToken": "operator-device-token",
|
||||
"role": "operator",
|
||||
"scopes": [
|
||||
"node.exec",
|
||||
"operator.admin",
|
||||
"operator.approvals",
|
||||
"operator.pairing",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
],
|
||||
"issuedAtMs": 1001,
|
||||
],
|
||||
],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
|
||||
let operatorEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator"))
|
||||
#expect(nodeEntry.token == "node-device-token")
|
||||
#expect(nodeEntry.scopes == [])
|
||||
#expect(operatorEntry.token == "operator-device-token")
|
||||
#expect(operatorEntry.scopes == [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
"operator.talk.secrets",
|
||||
"operator.write",
|
||||
])
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "server-node-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"deviceTokens": [
|
||||
[
|
||||
"deviceToken": "server-operator-token",
|
||||
"role": "operator",
|
||||
"scopes": ["operator.admin"],
|
||||
],
|
||||
],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "wss://example.invalid")!,
|
||||
token: "shared-token",
|
||||
bootstrapToken: nil,
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
let nodeEntry = try #require(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node"))
|
||||
#expect(nodeEntry.token == "server-node-token")
|
||||
#expect(nodeEntry.scopes == [])
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
|
||||
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
|
||||
defer {
|
||||
if let previousStateDir {
|
||||
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
|
||||
} else {
|
||||
unsetenv("OPENCLAW_STATE_DIR")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let session = FakeGatewayWebSocketSession(helloAuth: [
|
||||
"deviceToken": "untrusted-node-token",
|
||||
"role": "node",
|
||||
"scopes": [],
|
||||
"deviceTokens": [
|
||||
[
|
||||
"deviceToken": "untrusted-operator-token",
|
||||
"role": "operator",
|
||||
"scopes": [
|
||||
"operator.approvals",
|
||||
"operator.read",
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
let gateway = GatewayNodeSession()
|
||||
let options = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "openclaw-ios-test",
|
||||
clientMode: "node",
|
||||
clientDisplayName: "iOS Test",
|
||||
includeDeviceIdentity: true)
|
||||
|
||||
try await gateway.connect(
|
||||
url: URL(string: "ws://example.invalid")!,
|
||||
token: nil,
|
||||
bootstrapToken: "fresh-bootstrap-token",
|
||||
password: nil,
|
||||
connectOptions: options,
|
||||
sessionBox: WebSocketSessionBox(session: session),
|
||||
onConnected: {},
|
||||
onDisconnected: { _ in },
|
||||
onInvoke: { req in
|
||||
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
})
|
||||
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "node") == nil)
|
||||
#expect(DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: "operator") == nil)
|
||||
|
||||
await gateway.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
|
||||
let normalized = canonicalizeCanvasHostUrl(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import XCTest
|
||||
@testable import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
final class TalkSystemSpeechSynthesizerTests: XCTestCase {
|
||||
func testWatchdogTimeoutDefaultsToLatinProfile() {
|
||||
let timeout = TalkSystemSpeechSynthesizer.watchdogTimeoutSeconds(
|
||||
|
||||
@@ -9,8 +9,8 @@ import Testing
|
||||
}
|
||||
|
||||
@Test func resolvesKnownToolFromConfig() {
|
||||
let summary = ToolDisplayRegistry.resolve(name: "exec", args: nil)
|
||||
let summary = ToolDisplayRegistry.resolve(name: "bash", args: nil)
|
||||
#expect(summary.emoji == "🛠️")
|
||||
#expect(summary.title == "Exec")
|
||||
#expect(summary.title == "Bash")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
# Generated Docs Artifacts
|
||||
|
||||
SHA-256 hash files are the tracked drift-detection artifacts. The full JSON
|
||||
baselines are generated locally (gitignored) for inspection only.
|
||||
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
|
||||
|
||||
**Tracked (committed to git):**
|
||||
|
||||
- `config-baseline.sha256` — hashes of config baseline JSON artifacts.
|
||||
- `plugin-sdk-api-baseline.sha256` — hashes of Plugin SDK API baseline artifacts.
|
||||
|
||||
**Local only (gitignored):**
|
||||
|
||||
- `config-baseline.json`, `config-baseline.core.json`, `config-baseline.channel.json`, `config-baseline.plugin.json`
|
||||
- `plugin-sdk-api-baseline.json`, `plugin-sdk-api-baseline.jsonl`
|
||||
|
||||
Do not edit any of these files by hand.
|
||||
|
||||
- Regenerate config baseline: `pnpm config:docs:gen`
|
||||
- Validate config baseline: `pnpm config:docs:check`
|
||||
- Regenerate Plugin SDK API baseline: `pnpm plugin-sdk:api:gen`
|
||||
- Validate Plugin SDK API baseline: `pnpm plugin-sdk:api:check`
|
||||
- Do not edit `config-baseline.json` by hand.
|
||||
- Do not edit `config-baseline.core.json` by hand.
|
||||
- Do not edit `config-baseline.channel.json` by hand.
|
||||
- Do not edit `config-baseline.plugin.json` by hand.
|
||||
- Do not edit `plugin-sdk-api-baseline.json` by hand.
|
||||
- Do not edit `plugin-sdk-api-baseline.jsonl` by hand.
|
||||
- Regenerate config baseline artifacts with `pnpm config:docs:gen`.
|
||||
- Validate config baseline artifacts in CI or locally with `pnpm config:docs:check`.
|
||||
- Regenerate Plugin SDK API baseline artifacts with `pnpm plugin-sdk:api:gen`.
|
||||
- Validate Plugin SDK API baseline artifacts in CI or locally with `pnpm plugin-sdk:api:check`.
|
||||
|
||||
31578
docs/.generated/config-baseline.channel.json
Normal file
31578
docs/.generated/config-baseline.channel.json
Normal file
File diff suppressed because it is too large
Load Diff
28035
docs/.generated/config-baseline.core.json
Normal file
28035
docs/.generated/config-baseline.core.json
Normal file
File diff suppressed because it is too large
Load Diff
73617
docs/.generated/config-baseline.json
Normal file
73617
docs/.generated/config-baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
14013
docs/.generated/config-baseline.plugin.json
Normal file
14013
docs/.generated/config-baseline.plugin.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +0,0 @@
|
||||
0135fa04d71f209a54b076f41a3f6cb9795c9169fa631364fb3561eb5ff89891 config-baseline.json
|
||||
0e93c22a45545e13c74647f4945e9d8540d359640ed8c364b0f2514c9dc7a66c config-baseline.core.json
|
||||
ae67508350baf891b902348d55fada6c17e9c053adf53aaf3a8b92cd364ef3f1 config-baseline.channel.json
|
||||
d972a11d0f86080a722bddfe48990dd1b8fa16eb8e157e83f49bd46a5941c512 config-baseline.plugin.json
|
||||
7081
docs/.generated/plugin-sdk-api-baseline.json
Normal file
7081
docs/.generated/plugin-sdk-api-baseline.json
Normal file
File diff suppressed because one or more lines are too long
782
docs/.generated/plugin-sdk-api-baseline.jsonl
Normal file
782
docs/.generated/plugin-sdk-api-baseline.jsonl
Normal file
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
||||
97509287d728c8f5d1736f7ea07521451ada4b9d7ef56555dbe860a89e1b6e08 plugin-sdk-api-baseline.json
|
||||
a22b3d427953cc8394b28c87ef7a992d2eb4f2c9f6a76fa58b33079e2306661b plugin-sdk-api-baseline.jsonl
|
||||
@@ -1,42 +1,11 @@
|
||||
# OpenClaw docs i18n assets
|
||||
|
||||
This folder stores translation config for the source docs repo.
|
||||
This folder stores **generated** and **config** files for documentation translations.
|
||||
|
||||
Generated locale trees and live translation memory now live in the publish repo:
|
||||
## Files
|
||||
|
||||
- repo: `openclaw/docs`
|
||||
- local checkout: `~/Projects/openclaw-docs`
|
||||
|
||||
## Source of truth
|
||||
|
||||
- English docs are authored in `openclaw/openclaw`.
|
||||
- The source docs tree lives under `docs/`.
|
||||
- The source repo no longer keeps committed generated locale trees such as `docs/zh-CN/**`, `docs/ja-JP/**`, `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, or `docs/pl/**`.
|
||||
|
||||
## End-to-end flow
|
||||
|
||||
1. Edit English docs in `openclaw/openclaw`.
|
||||
2. Push to `main`.
|
||||
3. `openclaw/openclaw/.github/workflows/docs-sync-publish.yml` mirrors the docs tree into `openclaw/docs`.
|
||||
4. The sync script rewrites the publish `docs/docs.json` so the generated locale picker blocks exist there even though they are no longer committed in the source repo.
|
||||
5. `openclaw/docs/.github/workflows/translate-zh-cn.yml` refreshes `docs/zh-CN/**` once a day, on demand, and after source-repo release dispatches.
|
||||
6. `openclaw/docs/.github/workflows/translate-ja-jp.yml` does the same for `docs/ja-JP/**`.
|
||||
7. `openclaw/docs/.github/workflows/translate-es.yml`, `translate-pt-br.yml`, `translate-ko.yml`, `translate-de.yml`, `translate-fr.yml`, `translate-ar.yml`, `translate-it.yml`, `translate-tr.yml`, `translate-uk.yml`, `translate-id.yml`, and `translate-pl.yml` do the same for `docs/es/**`, `docs/pt-BR/**`, `docs/ko/**`, `docs/de/**`, `docs/fr/**`, `docs/ar/**`, `docs/it/**`, `docs/tr/**`, `docs/uk/**`, `docs/id/**`, and `docs/pl/**`.
|
||||
|
||||
## Why the split exists
|
||||
|
||||
- Keep generated locale output out of the main product repo.
|
||||
- Keep Mintlify on a single published docs tree.
|
||||
- Preserve the built-in language switcher by letting the publish repo own generated locale trees.
|
||||
|
||||
## Files in this folder
|
||||
|
||||
- `glossary.<lang>.json` — preferred term mappings used as prompt guidance.
|
||||
- `zh-Hans-navigation.json` — curated zh-Hans Mintlify locale navigation reinserted into the publish repo during sync.
|
||||
- `ar-navigation.json`, `de-navigation.json`, `es-navigation.json`, `fr-navigation.json`, `id-navigation.json`, `it-navigation.json`, `ja-navigation.json`, `ko-navigation.json`, `pl-navigation.json`, `pt-BR-navigation.json`, `tr-navigation.json` — starter locale metadata kept alongside the source repo, but the publish sync now clones the full English nav tree for these locales so translated pages are visible in Mintlify without hand-maintaining per-locale nav JSON.
|
||||
- `<lang>.tm.jsonl` — translation memory keyed by workflow + model + text hash.
|
||||
|
||||
In this repo, generated locale TM files such as `docs/.i18n/zh-CN.tm.jsonl`, `docs/.i18n/ja-JP.tm.jsonl`, `docs/.i18n/es.tm.jsonl`, `docs/.i18n/pt-BR.tm.jsonl`, `docs/.i18n/ko.tm.jsonl`, `docs/.i18n/de.tm.jsonl`, `docs/.i18n/fr.tm.jsonl`, `docs/.i18n/ar.tm.jsonl`, `docs/.i18n/it.tm.jsonl`, `docs/.i18n/tr.tm.jsonl`, `docs/.i18n/uk.tm.jsonl`, `docs/.i18n/id.tm.jsonl`, and `docs/.i18n/pl.tm.jsonl` are intentionally no longer committed.
|
||||
- `glossary.<lang>.json` — preferred term mappings (used in prompt guidance).
|
||||
- `<lang>.tm.jsonl` — translation memory (cache) keyed by workflow + model + text hash.
|
||||
|
||||
## Glossary format
|
||||
|
||||
@@ -45,7 +14,9 @@ In this repo, generated locale TM files such as `docs/.i18n/zh-CN.tm.jsonl`, `do
|
||||
```json
|
||||
{
|
||||
"source": "troubleshooting",
|
||||
"target": "故障排除"
|
||||
"target": "故障排除",
|
||||
"ignore_case": true,
|
||||
"whole_word": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -54,19 +25,7 @@ Fields:
|
||||
- `source`: English (or source) phrase to prefer.
|
||||
- `target`: preferred translation output.
|
||||
|
||||
## Translation mechanics
|
||||
## Notes
|
||||
|
||||
- `scripts/docs-i18n` still owns translation generation.
|
||||
- Doc mode writes `x-i18n.source_hash` into each translated page.
|
||||
- Each publish workflow precomputes a pending file list by comparing the current English source hash to the stored locale `x-i18n.source_hash`.
|
||||
- If the pending count is `0`, the expensive translation step is skipped entirely.
|
||||
- If there are pending files, the workflow translates only those files.
|
||||
- The publish workflow retries transient model-format failures, but unchanged files stay skipped because the same hash check runs on each retry.
|
||||
- The source repo also dispatches zh-CN, ja-JP, es, pt-BR, ko, de, fr, ar, it, tr, uk, id, and pl refreshes after published GitHub releases so release docs can catch up without waiting for the daily cron.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Sync metadata is written to `.openclaw-sync/source.json` in the publish repo.
|
||||
- Source repo secret: `OPENCLAW_DOCS_SYNC_TOKEN`
|
||||
- Publish repo secret: `OPENCLAW_DOCS_I18N_OPENAI_API_KEY`
|
||||
- If locale output looks stale, check the matching `Translate <locale>` workflow in `openclaw/docs` first.
|
||||
- Glossary entries are passed to the model as **prompt guidance** (no deterministic rewrites).
|
||||
- The translation memory is updated by `scripts/docs-i18n`.
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "ar",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "ابدأ",
|
||||
"groups": [
|
||||
{
|
||||
"group": "نظرة عامة",
|
||||
"pages": ["ar/index"]
|
||||
},
|
||||
{
|
||||
"group": "الخطوات الأولى",
|
||||
"pages": ["ar/start/getting-started", "ar/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "de",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Loslegen",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Überblick",
|
||||
"pages": ["de/index"]
|
||||
},
|
||||
{
|
||||
"group": "Erste Schritte",
|
||||
"pages": ["de/start/getting-started", "de/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "es",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Comenzar",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Resumen",
|
||||
"pages": ["es/index"]
|
||||
},
|
||||
{
|
||||
"group": "Primeros pasos",
|
||||
"pages": ["es/start/getting-started", "es/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "fr",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Commencer",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Vue d'ensemble",
|
||||
"pages": ["fr/index"]
|
||||
},
|
||||
{
|
||||
"group": "Premiers pas",
|
||||
"pages": ["fr/start/getting-started", "fr/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -1,5 +0,0 @@
|
||||
[
|
||||
{ "source": "CLI", "target": "CLI" },
|
||||
{ "source": "Mintlify", "target": "Mintlify" },
|
||||
{ "source": "OpenClaw", "target": "OpenClaw" }
|
||||
]
|
||||
@@ -47,38 +47,6 @@
|
||||
"source": "Quick Start",
|
||||
"target": "快速开始"
|
||||
},
|
||||
{
|
||||
"source": "Chutes",
|
||||
"target": "Chutes"
|
||||
},
|
||||
{
|
||||
"source": "Qwen",
|
||||
"target": "Qwen"
|
||||
},
|
||||
{
|
||||
"source": "Feishu",
|
||||
"target": "Feishu"
|
||||
},
|
||||
{
|
||||
"source": "Mattermost",
|
||||
"target": "Mattermost"
|
||||
},
|
||||
{
|
||||
"source": "BytePlus (International)",
|
||||
"target": "BytePlus(国际版)"
|
||||
},
|
||||
{
|
||||
"source": "Anthropic (API + Claude CLI)",
|
||||
"target": "Anthropic(API + Claude CLI)"
|
||||
},
|
||||
{
|
||||
"source": "Moonshot AI",
|
||||
"target": "Moonshot AI"
|
||||
},
|
||||
{
|
||||
"source": "Additional bundled variants",
|
||||
"target": "其他内置变体"
|
||||
},
|
||||
{
|
||||
"source": "Diffs",
|
||||
"target": "Diffs"
|
||||
@@ -175,10 +143,6 @@
|
||||
"source": "Network model",
|
||||
"target": "网络模型"
|
||||
},
|
||||
{
|
||||
"source": "Bridge protocol (legacy nodes, historical)",
|
||||
"target": "Bridge protocol(旧版节点,历史参考)"
|
||||
},
|
||||
{
|
||||
"source": "Doctor",
|
||||
"target": "Doctor"
|
||||
@@ -187,10 +151,6 @@
|
||||
"source": "Polls",
|
||||
"target": "投票"
|
||||
},
|
||||
{
|
||||
"source": "QQ Bot",
|
||||
"target": "QQ Bot"
|
||||
},
|
||||
{
|
||||
"source": "Release Policy",
|
||||
"target": "发布策略"
|
||||
@@ -239,14 +199,6 @@
|
||||
"source": "CLI",
|
||||
"target": "CLI"
|
||||
},
|
||||
{
|
||||
"source": "/cli/gateway",
|
||||
"target": "/cli/gateway"
|
||||
},
|
||||
{
|
||||
"source": "/gateway#multiple-gateways-same-host",
|
||||
"target": "/gateway#multiple-gateways-same-host"
|
||||
},
|
||||
{
|
||||
"source": "install sanity",
|
||||
"target": "安装完整性检查"
|
||||
@@ -263,10 +215,6 @@
|
||||
"source": "FAQ",
|
||||
"target": "常见问题"
|
||||
},
|
||||
{
|
||||
"source": "Ollama Web Search",
|
||||
"target": "Ollama Web 搜索"
|
||||
},
|
||||
{
|
||||
"source": "onboarding",
|
||||
"target": "新手引导"
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "id",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Mulai",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Ringkasan",
|
||||
"pages": ["id/index"]
|
||||
},
|
||||
{
|
||||
"group": "Langkah pertama",
|
||||
"pages": ["id/start/getting-started", "id/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "it",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "Inizia",
|
||||
"groups": [
|
||||
{
|
||||
"group": "Panoramica",
|
||||
"pages": ["it/index"]
|
||||
},
|
||||
{
|
||||
"group": "Primi passi",
|
||||
"pages": ["it/start/getting-started", "it/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docs/.i18n/ja-JP.tm.jsonl
Normal file
0
docs/.i18n/ja-JP.tm.jsonl
Normal file
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "ja",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "はじめに",
|
||||
"groups": [
|
||||
{
|
||||
"group": "概要",
|
||||
"pages": ["ja-JP/index"]
|
||||
},
|
||||
{
|
||||
"group": "初回セットアップ",
|
||||
"pages": ["ja-JP/start/getting-started", "ja-JP/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"language": "ko",
|
||||
"tabs": [
|
||||
{
|
||||
"tab": "시작하기",
|
||||
"groups": [
|
||||
{
|
||||
"group": "개요",
|
||||
"pages": ["ko/index"]
|
||||
},
|
||||
{
|
||||
"group": "첫 단계",
|
||||
"pages": ["ko/start/getting-started", "ko/start/wizard"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user