Merge branch 'main' into docs/formal-security-models-repo-link
This commit is contained in:
commit
312d9cfa0e
@ -107,7 +107,7 @@ Before any substantive review or prep work, **always rebase the PR branch onto c
|
||||
|
||||
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
|
||||
- During `prepare-pr`, use concise, action-oriented subjects **without** PR numbers or thanks; reserve `(#<PR>) thanks @<pr-author>` for the final merge/squash commit.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
|
||||
|
||||
@ -19,6 +19,7 @@ Merge a prepared PR only after deterministic validation.
|
||||
- Never use `gh pr merge --auto` in this flow.
|
||||
- Never run `git push` directly.
|
||||
- Require `--match-head-commit` during merge.
|
||||
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
|
||||
|
||||
## Execution Contract
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ scripts/pr-prepare init <PR>
|
||||
- `.local/review.json` is mandatory.
|
||||
- Resolve all `BLOCKER` and `IMPORTANT` items.
|
||||
|
||||
3. Commit with required subject format and validate it.
|
||||
3. Commit scoped changes with concise subjects (no PR number/thanks; those belong on the final merge/squash commit).
|
||||
|
||||
4. Run gates via wrapper.
|
||||
|
||||
@ -76,21 +76,12 @@ jq -r '.docs' .local/review.json
|
||||
|
||||
4. Commit scoped changes
|
||||
|
||||
Required commit subject format:
|
||||
|
||||
- `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`
|
||||
Use concise, action-oriented subject lines without PR numbers/thanks. The final merge/squash commit is the only place we include PR numbers and contributor thanks.
|
||||
|
||||
Use explicit file list:
|
||||
|
||||
```sh
|
||||
source .local/pr-meta.env
|
||||
scripts/committer "fix: <summary> (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" <file1> <file2> ...
|
||||
```
|
||||
|
||||
Validate commit subject:
|
||||
|
||||
```sh
|
||||
scripts/pr-prepare validate-commit <PR>
|
||||
scripts/committer "fix: <summary>" <file1> <file2> ...
|
||||
```
|
||||
|
||||
5. Run gates
|
||||
|
||||
@ -18,6 +18,7 @@ Perform a read-only review and produce both human and machine-readable outputs.
|
||||
|
||||
- Never push, merge, or modify code intended to keep.
|
||||
- Work only in `.worktrees/pr-<PR>`.
|
||||
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
|
||||
|
||||
## Execution Contract
|
||||
|
||||
|
||||
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
34
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -1,34 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a problem or unexpected behavior in Clawdbot.
|
||||
title: "[Bug]: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
What went wrong?
|
||||
|
||||
## Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected behavior
|
||||
|
||||
What did you expect to happen?
|
||||
|
||||
## Actual behavior
|
||||
|
||||
What actually happened?
|
||||
|
||||
## Environment
|
||||
|
||||
- Clawdbot version:
|
||||
- OS:
|
||||
- Install method (pnpm/npx/docker/etc):
|
||||
|
||||
## Logs or screenshots
|
||||
|
||||
Paste relevant logs or add screenshots (redact secrets).
|
||||
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
95
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@ -0,0 +1,95 @@
|
||||
name: Bug report
|
||||
description: Report a defect or unexpected behavior in OpenClaw.
|
||||
title: "[Bug]: "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for filing this report. Keep it concise, reproducible, and evidence-based.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-sentence statement of what is broken.
|
||||
placeholder: After upgrading to 2026.2.13, Telegram thread replies fail with "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the shortest deterministic repro path.
|
||||
placeholder: |
|
||||
1. Configure channel X.
|
||||
2. Send message Y.
|
||||
3. Run command Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What should happen if the bug does not exist.
|
||||
placeholder: Agent posts a reply in the same thread.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What happened instead, including user-visible errors.
|
||||
placeholder: No reply is posted; gateway logs "reply target not found".
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OpenClaw version
|
||||
description: Exact version/build tested.
|
||||
placeholder: 2026.2.13
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: OS and version where this occurs.
|
||||
placeholder: macOS 15.4 / Ubuntu 24.04 / Windows 11
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: install_method
|
||||
attributes:
|
||||
label: Install method
|
||||
description: How OpenClaw was installed or launched.
|
||||
placeholder: npm global / pnpm dev / docker / mac app
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, and evidence
|
||||
description: Include redacted logs/screenshots/recordings that prove the behavior.
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact and severity
|
||||
description: |
|
||||
Explain who is affected, how severe it is, how often it happens, and the practical consequence.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, data risk, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (missed messages, failed onboarding, extra cost, etc.)
|
||||
placeholder: |
|
||||
Affected: Telegram group users on 2026.2.13
|
||||
Severity: High (blocks replies)
|
||||
Frequency: 100% repro
|
||||
Consequence: Agents cannot respond in threads
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any context that helps triage but does not fit above.
|
||||
placeholder: Regression started after upgrade from 2026.2.12; temporary workaround is restarting gateway every 30m.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or improvement for Clawdbot.
|
||||
title: "[Feature]: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Describe the problem you are trying to solve or the opportunity you see.
|
||||
|
||||
## Proposed solution
|
||||
|
||||
What would you like Clawdbot to do?
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Any other approaches you have considered?
|
||||
|
||||
## Additional context
|
||||
|
||||
Links, screenshots, or related issues.
|
||||
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
70
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@ -0,0 +1,70 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or product improvement.
|
||||
title: "[Feature]: "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Help us evaluate this request with concrete use cases and tradeoffs.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: One-line statement of the requested capability.
|
||||
placeholder: Add per-channel default response prefix.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem to solve
|
||||
description: What user pain this solves and why current behavior is insufficient.
|
||||
placeholder: Teams cannot distinguish agent personas in mixed channels, causing misrouted follow-ups.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposed_solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Desired behavior/API/UX with as much specificity as possible.
|
||||
placeholder: Support channels.<channel>.responsePrefix with default fallback and account-level override.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Other approaches considered and why they are weaker.
|
||||
placeholder: Manual prefixing in prompts is inconsistent and hard to enforce.
|
||||
- type: textarea
|
||||
id: impact
|
||||
attributes:
|
||||
label: Impact
|
||||
description: |
|
||||
Explain who is affected, severity/urgency, how often this pain occurs, and practical consequences.
|
||||
Include:
|
||||
- Affected users/systems/channels
|
||||
- Severity (annoying, blocks workflow, etc.)
|
||||
- Frequency (always/intermittent/edge case)
|
||||
- Consequence (delays, errors, extra manual work, etc.)
|
||||
placeholder: |
|
||||
Affected: Multi-team shared channels
|
||||
Severity: Medium
|
||||
Frequency: Daily
|
||||
Consequence: +20 minutes/day/operator and delayed alerts
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: evidence
|
||||
attributes:
|
||||
label: Evidence/examples
|
||||
description: Prior art, links, screenshots, logs, or metrics.
|
||||
placeholder: Comparable behavior in X, sample config, and screenshot of current limitation.
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Extra context, constraints, or references not covered above.
|
||||
placeholder: Must remain backward-compatible with existing config keys.
|
||||
108
.github/pull_request_template.md
vendored
Normal file
108
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,108 @@
|
||||
## Summary
|
||||
|
||||
Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
- Problem:
|
||||
- Why it matters:
|
||||
- What changed:
|
||||
- What did NOT change (scope boundary):
|
||||
|
||||
## Change Type (select all)
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] Feature
|
||||
- [ ] Refactor
|
||||
- [ ] Docs
|
||||
- [ ] Security hardening
|
||||
- [ ] Chore/infra
|
||||
|
||||
## Scope (select all touched areas)
|
||||
|
||||
- [ ] Gateway / orchestration
|
||||
- [ ] Skills / tool execution
|
||||
- [ ] Auth / tokens
|
||||
- [ ] Memory / storage
|
||||
- [ ] Integrations
|
||||
- [ ] API / contracts
|
||||
- [ ] UI / DX
|
||||
- [ ] CI/CD / infra
|
||||
|
||||
## Linked Issue/PR
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## User-visible / Behavior Changes
|
||||
|
||||
List user-visible changes (including defaults/config).
|
||||
If none, write `None`.
|
||||
|
||||
## Security Impact (required)
|
||||
|
||||
- New permissions/capabilities? (`Yes/No`)
|
||||
- Secrets/tokens handling changed? (`Yes/No`)
|
||||
- New/changed network calls? (`Yes/No`)
|
||||
- Command/tool execution surface changed? (`Yes/No`)
|
||||
- Data access scope changed? (`Yes/No`)
|
||||
- If any `Yes`, explain risk + mitigation:
|
||||
|
||||
## Repro + Verification
|
||||
|
||||
### Environment
|
||||
|
||||
- OS:
|
||||
- Runtime/container:
|
||||
- Model/provider:
|
||||
- Integration/channel (if any):
|
||||
- Relevant config (redacted):
|
||||
|
||||
### Steps
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected
|
||||
|
||||
-
|
||||
|
||||
### Actual
|
||||
|
||||
-
|
||||
|
||||
## Evidence
|
||||
|
||||
Attach at least one:
|
||||
|
||||
- [ ] Failing test/log before + passing after
|
||||
- [ ] Trace/log snippets
|
||||
- [ ] Screenshot/recording
|
||||
- [ ] Perf numbers (if relevant)
|
||||
|
||||
## Human Verification (required)
|
||||
|
||||
What you personally verified (not just CI), and how:
|
||||
|
||||
- Verified scenarios:
|
||||
- Edge cases checked:
|
||||
- What you did **not** verify:
|
||||
|
||||
## Compatibility / Migration
|
||||
|
||||
- Backward compatible? (`Yes/No`)
|
||||
- Config/env changes? (`Yes/No`)
|
||||
- Migration needed? (`Yes/No`)
|
||||
- If yes, exact upgrade steps:
|
||||
|
||||
## Failure Recovery (if this breaks)
|
||||
|
||||
- How to disable/revert this change quickly:
|
||||
- Files/config to restore:
|
||||
- Known bad symptoms reviewers should watch for:
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
List only real risks for this PR. Add/remove entries as needed. If none, write `None`.
|
||||
|
||||
- Risk:
|
||||
- Mitigation:
|
||||
49
.github/workflows/auto-response.yml
vendored
49
.github/workflows/auto-response.yml
vendored
@ -89,7 +89,8 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTriggerLabel) {
|
||||
const isLabelEvent = context.payload.action === "labeled";
|
||||
if (!hasTriggerLabel && !isLabelEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -130,15 +131,19 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const dirtyLabel = "dirty";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated commits). Please recreate the PR from a clean branch.";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: "Closing this PR because it has more than 20 labels, which usually means the branch is too noisy. Please recreate the PR from a clean branch.",
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
@ -148,6 +153,42 @@ jobs:
|
||||
});
|
||||
return;
|
||||
}
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@ -6,14 +6,14 @@ on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
docs_changed: ${{ steps.check.outputs.docs_changed }}
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
changed-scope:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
run_node: ${{ steps.scope.outputs.run_node }}
|
||||
run_macos: ${{ steps.scope.outputs.run_macos }}
|
||||
@ -204,6 +204,14 @@ jobs:
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Configure Node test resources
|
||||
if: matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: |
|
||||
# `pnpm test` runs `scripts/test-parallel.mjs`, which spawns multiple Node processes.
|
||||
# Default heap limits have been too low on Linux CI (V8 OOM near 4GB).
|
||||
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@ -664,7 +672,8 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
# setup-android's sdkmanager currently crashes on JDK 21 in CI.
|
||||
java-version: 17
|
||||
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@ -13,6 +13,10 @@ on:
|
||||
- ".agents/**"
|
||||
- "skills/**"
|
||||
|
||||
concurrency:
|
||||
group: docker-release-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
1
.github/workflows/formal-conformance.yml
vendored
1
.github/workflows/formal-conformance.yml
vendored
@ -108,6 +108,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR (informational)
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
||||
28
.github/workflows/install-smoke.yml
vendored
28
.github/workflows/install-smoke.yml
vendored
@ -7,8 +7,8 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: install-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
@ -33,19 +33,17 @@ jobs:
|
||||
- name: Checkout CLI
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
check-latest: true
|
||||
|
||||
- name: Setup pnpm + cache store
|
||||
uses: ./.github/actions/setup-pnpm-store-cache
|
||||
with:
|
||||
pnpm-version: "10.23.0"
|
||||
cache-key-suffix: "node22"
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
56
.github/workflows/sandbox-common-smoke.yml
vendored
Normal file
56
.github/workflows/sandbox-common-smoke.yml
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
name: Sandbox Common Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
|
||||
concurrency:
|
||||
group: sandbox-common-smoke-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Build minimal sandbox base (USER sandbox)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker build -t openclaw-sandbox-smoke-base:bookworm-slim - <<'EOF'
|
||||
FROM debian:bookworm-slim
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
EOF
|
||||
|
||||
- name: Build sandbox-common image (root for installs, sandbox at runtime)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
|
||||
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
|
||||
PACKAGES="ca-certificates" \
|
||||
INSTALL_PNPM=0 \
|
||||
INSTALL_BUN=0 \
|
||||
INSTALL_BREW=0 \
|
||||
FINAL_USER=sandbox \
|
||||
scripts/sandbox-common-setup.sh
|
||||
|
||||
u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
|
||||
test "$u" = "sandbox"
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 500
|
||||
operations-per-run: 10000
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
|
||||
4
.github/workflows/workflow-sanity.yml
vendored
4
.github/workflows/workflow-sanity.yml
vendored
@ -6,8 +6,8 @@ on:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -27,6 +27,8 @@ apps/android/.cxx/
|
||||
*.bun-build
|
||||
apps/macos/.build/
|
||||
apps/shared/MoltbotKit/.build/
|
||||
apps/shared/OpenClawKit/.build/
|
||||
apps/shared/OpenClawKit/Package.resolved
|
||||
**/ModuleCache/
|
||||
bin/
|
||||
bin/clawdbot-mac
|
||||
@ -82,4 +84,5 @@ USER.md
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
local/
|
||||
/local/
|
||||
package-lock.json
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"src/auto-reply/reply/export-html/",
|
||||
"Swabble/",
|
||||
"vendor/",
|
||||
],
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"eslint-plugin-unicorn/prefer-array-find": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-new": "off",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-unmodified-loop-condition": "off",
|
||||
"oxc/no-accumulating-spread": "off",
|
||||
"oxc/no-async-endpoint-handlers": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
@ -27,8 +29,9 @@
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"pnpm-lock.yaml",
|
||||
"skills/",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"Swabble/",
|
||||
"vendor/"
|
||||
|
||||
@ -42,8 +42,9 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11. Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
11. Commit via committer (final merge commit only includes PR # + thanks):
|
||||
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
12. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
|
||||
58
AGENTS.md
58
AGENTS.md
@ -52,6 +52,7 @@
|
||||
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
@ -69,6 +70,10 @@
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
@ -99,8 +104,8 @@
|
||||
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Read this when submitting a PR: `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr))
|
||||
- Read this when submitting an issue: `docs/help/submitting-an-issue.md` ([Submitting an Issue](https://docs.openclaw.ai/help/submitting-an-issue))
|
||||
- PR submission template (canonical): `.github/pull_request_template.md`
|
||||
- Issue submission templates (canonical): `.github/ISSUE_TEMPLATE/`
|
||||
|
||||
## Shorthand Commands
|
||||
|
||||
@ -118,6 +123,19 @@
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
- Fetch: `gh api /repos/openclaw/openclaw/security-advisories/<GHSA>`
|
||||
- Latest npm: `npm view openclaw version --userconfig "$(mktemp)"`
|
||||
- Private fork PRs must be closed:
|
||||
`fork=$(gh api /repos/openclaw/openclaw/security-advisories/<GHSA> | jq -r .private_fork.full_name)`
|
||||
`gh pr list -R "$fork" --state open` (must be empty)
|
||||
- Description newline footgun: write Markdown via heredoc to `/tmp/ghsa.desc.md` (no `"\\n"` strings)
|
||||
- Build patch JSON via jq: `jq -n --rawfile desc /tmp/ghsa.desc.md '{summary,severity,description:$desc,vulnerabilities:[...]}' > /tmp/ghsa.patch.json`
|
||||
- Patch + publish: `gh api -X PATCH /repos/openclaw/openclaw/security-advisories/<GHSA> --input /tmp/ghsa.patch.json` (publish = include `"state":"published"`; no `/publish` endpoint)
|
||||
- If publish fails (HTTP 422): missing `severity`/`description`/`vulnerabilities[]`, or private fork has open PRs
|
||||
- Verify: re-fetch; ensure `state=published`, `published_at` set; `jq -r .description | rg '\\\\n'` returns nothing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Rebrand/migration issues or legacy config/service warnings: run `openclaw doctor` (see `docs/gateway/doctor.md`).
|
||||
@ -181,3 +199,39 @@
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Plugin Release Fast Path (no core `openclaw` publish)
|
||||
|
||||
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
|
||||
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
|
||||
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
|
||||
- `eval "$(op signin --account my.1password.com)"`
|
||||
- 1Password helpers:
|
||||
- password used by `npm login`:
|
||||
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
|
||||
- OTP:
|
||||
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
|
||||
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
|
||||
- compare local plugin `version` to `npm view <name> version`
|
||||
- only run `npm publish --access public --otp="<otp>"` when versions differ
|
||||
- skip if package is missing on npm or version already matches.
|
||||
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
|
||||
- Post-check for each release:
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.16`
|
||||
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
- When cutting a mac release with beta GitHub prerelease:
|
||||
- Tag `vYYYY.M.D-beta.N` from the release commit (example: `v2026.2.15-beta.1`).
|
||||
- Create prerelease with title `openclaw YYYY.M.D-beta.N`.
|
||||
- Use release notes from `CHANGELOG.md` version section (`Changes` + `Fixes`, no title duplicate).
|
||||
- Attach at least `OpenClaw-YYYY.M.D.zip` and `OpenClaw-YYYY.M.D.dSYM.zip`; include `.dmg` if available.
|
||||
|
||||
- Keep top version entries in `CHANGELOG.md` sorted by impact:
|
||||
- `### Changes` first.
|
||||
- `### Fixes` deduped and ranked with user-facing fixes first.
|
||||
- Before tagging/publishing, run:
|
||||
- `node --import tsx scripts/release-check.ts`
|
||||
- `pnpm release:check`
|
||||
- `pnpm test:install:smoke` or `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` for non-root smoke path.
|
||||
|
||||
451
CHANGELOG.md
451
CHANGELOG.md
@ -2,45 +2,413 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.13 (Unreleased)
|
||||
## 2026.2.16 (Unreleased)
|
||||
|
||||
### Changes
|
||||
|
||||
- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
|
||||
- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
|
||||
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
|
||||
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
|
||||
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
|
||||
- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow.
|
||||
- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
|
||||
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
|
||||
- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow.
|
||||
- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
|
||||
- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
|
||||
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
|
||||
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
|
||||
- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
|
||||
- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
|
||||
- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
|
||||
- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source.
|
||||
- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete.
|
||||
- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505)
|
||||
- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin.
|
||||
- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin.
|
||||
- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507)
|
||||
- Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931.
|
||||
- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
|
||||
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
||||
- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u.
|
||||
- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
|
||||
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
||||
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
||||
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
|
||||
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
|
||||
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
|
||||
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
|
||||
- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman.
|
||||
- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494)
|
||||
- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight.
|
||||
- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270)
|
||||
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
|
||||
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
|
||||
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
|
||||
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
|
||||
- Telegram: ignore `<media:...>` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang.
|
||||
- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise.
|
||||
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
|
||||
- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679)
|
||||
- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT.
|
||||
- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
|
||||
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
|
||||
- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502)
|
||||
- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544)
|
||||
- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation.
|
||||
- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931.
|
||||
- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437)
|
||||
- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior.
|
||||
- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836)
|
||||
- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim.
|
||||
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
|
||||
- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel.
|
||||
- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615)
|
||||
- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
|
||||
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
|
||||
- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow.
|
||||
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
|
||||
- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538)
|
||||
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
|
||||
- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow.
|
||||
- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777.
|
||||
- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602)
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
|
||||
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
|
||||
- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz.
|
||||
- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x.
|
||||
- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
|
||||
- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
|
||||
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||
- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
|
||||
- Gateway/Security: redact sensitive session/path details from `status` responses for non-admin clients; full details remain available to `operator.admin`. (#8590) Thanks @fr33d3m0n.
|
||||
- Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (`allowInsecureAuth` / `dangerouslyDisableDeviceAuth`) when device identity is unavailable, preventing false `missing scope` failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.
|
||||
- LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.
|
||||
- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
|
||||
- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
|
||||
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
|
||||
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
|
||||
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
|
||||
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
|
||||
- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv.
|
||||
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
|
||||
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
|
||||
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
|
||||
- Agents/Context: apply configured model `contextWindow` overrides after provider discovery so `lookupContextTokens()` honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.
|
||||
- Agents/Context: derive `lookupContextTokens()` from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.
|
||||
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
|
||||
- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
|
||||
- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
|
||||
- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan.
|
||||
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
|
||||
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
|
||||
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
|
||||
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
- TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.
|
||||
- CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.
|
||||
|
||||
## 2026.2.14
|
||||
|
||||
### Changes
|
||||
|
||||
- Telegram: add poll sending via `openclaw message poll` (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.
|
||||
- Slack/Discord: add `dmPolicy` + `allowFrom` config aliases for DM access control; legacy `dm.policy` + `dm.allowFrom` keys remain supported and `openclaw doctor --fix` can migrate them.
|
||||
- Discord: allow exec approval prompts to target channels or both DM+channel via `channels.discord.execApprovals.target`. (#16051) Thanks @leonnardo.
|
||||
- Sandbox: add `sandbox.browser.binds` to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.
|
||||
- Discord: add debug logging for message routing decisions to improve `--debug` tracing. (#16202) Thanks @jayleekr.
|
||||
- Agents: add optional `messages.suppressToolErrors` config to hide non-mutating tool-failure warnings from user-facing chat while still surfacing mutating failures. (#16620) Thanks @vai-oro.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Sessions/Telegram: restrict session tool targeting by default to the current session tree (`tools.sessions.visibility`, default `tree`) with sandbox clamping, and pass configured per-account Telegram webhook secrets in webhook mode when no explicit override is provided. Thanks @aether-ai-agent.
|
||||
- CLI/Plugins: ensure `openclaw message send` exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.
|
||||
- CLI/Plugins: run registered plugin `gateway_stop` hooks before `openclaw message` exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.
|
||||
- WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.
|
||||
- Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.
|
||||
- LINE: return 200 OK for Developers Console "Verify" requests (`{"events":[]}`) without `X-Line-Signature`, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.
|
||||
- Cron: deliver text-only output directly when `delivery.to` is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.
|
||||
- Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.
|
||||
- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr.
|
||||
- Media understanding: treat binary `application/vnd.*`/zip/octet-stream attachments as non-text (while keeping vendor `+json`/`+xml` text-eligible) so Office/ZIP files are not inlined into prompt body text. (#16513) Thanks @rmramsey32.
|
||||
- Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.
|
||||
- Auto-reply/Block streaming: strip leading whitespace from streamed block replies so messages starting with blank lines no longer deliver visible leading empty lines. (#16422) Thanks @mcinteerj.
|
||||
- Auto-reply/Queue: keep queued followups and overflow summaries when drain attempts fail, then retry delivery instead of dropping messages on transient errors. (#16771) Thanks @mmhzlrj.
|
||||
- Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)
|
||||
- Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit `workspaceDir`. (#16722)
|
||||
- BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.
|
||||
- CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command.
|
||||
- CLI/Dashboard: when `gateway.bind=lan`, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.
|
||||
- TUI/Gateway: resolve local gateway target URL from `gateway.bind` mode (tailnet/lan) instead of hardcoded localhost so `openclaw tui` connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.
|
||||
- TUI: honor explicit `--session <key>` in `openclaw tui` even when `session.scope` is `global`, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.
|
||||
- TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.
|
||||
- TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.
|
||||
- TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.
|
||||
- TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.
|
||||
- TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.
|
||||
- TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.
|
||||
- TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.
|
||||
- TUI/Hooks: pass explicit reset reason (`new` vs `reset`) through `sessions.reset` and emit internal command hooks for gateway-triggered resets so `/new` hook workflows fire in TUI/webchat.
|
||||
- Gateway/Agent: route bare `/new` and `/reset` through `sessions.reset` before running the fresh-session greeting prompt, so reset commands clear the current session in-place instead of falling through to normal agent runs. (#16732) Thanks @kdotndot and @vignesh07.
|
||||
- Cron: prevent `cron list`/`cron status` from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.
|
||||
- Cron: repair missing/corrupt `nextRunAtMs` for the updated job without globally recomputing unrelated due jobs during `cron update`. (#15750)
|
||||
- Cron: treat persisted jobs with missing `enabled` as enabled by default across update/list/timer due-path checks, and add regression coverage for missing-`enabled` store records. (#15433) Thanks @eternauta1337.
|
||||
- Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale `runningAtMs` markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.
|
||||
- Heartbeat/Cron: treat cron-tagged queued system events as cron reminders even on interval wakes, so isolated cron announce summaries no longer run under the default heartbeat prompt. (#14947) Thanks @archedark-ada and @vignesh07.
|
||||
- Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as `guild=dm`. Thanks @thewilloftheshadow.
|
||||
- Discord: treat empty per-guild `channels: {}` config maps as no channel allowlist (not deny-all), so `groupPolicy: "open"` guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.
|
||||
- Models/CLI: guard `models status` string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.
|
||||
- Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.
|
||||
- Gateway/Config: make `config.patch` merge object arrays by `id` (for example `agents.list`) instead of replacing the whole array, so partial agent updates do not silently delete unrelated agents. (#6766) Thanks @lightclient.
|
||||
- Webchat/Prompts: stop injecting direct-chat `conversation_label` into inbound untrusted metadata context blocks, preventing internal label noise from leaking into visible chat replies. (#16556) Thanks @nberardi.
|
||||
- Auto-reply/Prompts: include trusted inbound `message_id`, `chat_id`, `reply_to_id`, and optional `message_id_full` metadata fields so action tools (for example reactions) can target the triggering message without relying on user text. (#17662) Thanks @MaikiMolto.
|
||||
- Gateway/Sessions: abort active embedded runs and clear queued session work before `sessions.reset`, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.
|
||||
- Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.
|
||||
- Agents: add a safety timeout around embedded `session.compact()` to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.
|
||||
- Agents/Tools: make required-parameter validation errors list missing fields and instruct: "Supply correct parameters before retrying," reducing repeated invalid tool-call loops (for example `read({})`). (#14729)
|
||||
- Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including `session_status` model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.
|
||||
- Agents/Process/Bootstrap: preserve unbounded `process log` offset-only pagination (default tail applies only when both `offset` and `limit` are omitted) and enforce strict `bootstrapTotalMaxChars` budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.
|
||||
- Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing `BOOTSTRAP.md` once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.
|
||||
- Agents/Workspace: create `BOOTSTRAP.md` when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.
|
||||
- Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.
|
||||
- Agents: treat empty-stream provider failures (`request ended without sending any chunks`) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.
|
||||
- Agents: treat `read` tool `file_path` arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.
|
||||
- Agents/Transcript: drop malformed tool-call blocks with blank required fields (`id`/`name` or missing `input`/`arguments`) during session transcript repair to prevent persistent tool-call corruption on future turns. (#15485) Thanks @mike-zachariades.
|
||||
- Tools/Write/Edit: normalize structured text-block arguments for `content`/`oldText`/`newText` before filesystem edits, preventing JSON-like file corruption and false “exact text not found” misses from block-form params. (#16778) Thanks @danielpipernz.
|
||||
- Ollama/Agents: avoid forcing `<final>` tag enforcement for Ollama models, which could suppress all output as `(no output)`. (#16191) Thanks @Glucksberg.
|
||||
- Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.
|
||||
- Agents/Process: supervise PTY/child process lifecycles with explicit ownership, cancellation, timeouts, and deterministic cleanup, preventing Codex/Pi PTY sessions from dying or stalling on resume. (#14257) Thanks @onutc.
|
||||
- Skills: watch `SKILL.md` only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.
|
||||
- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks.
|
||||
- Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.
|
||||
- Memory/Builtin: keep `memory status` dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.
|
||||
- Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological `qmd` command output.
|
||||
- Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.
|
||||
- Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.
|
||||
- Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier.
|
||||
- Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads.
|
||||
- Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.
|
||||
- Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`.
|
||||
- Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.
|
||||
- Memory/QMD: avoid multi-collection `query` ranking corruption by running one `qmd query -c <collection>` per managed collection and merging by best score (also used for `search`/`vsearch` fallback-to-query). (#16740) Thanks @volarian-vai.
|
||||
- Memory/QMD: rebind managed collections when existing collection metadata drifts (including sessions name-only listings), preventing non-default agents from reusing another agent's `sessions` collection path. (#17194) Thanks @jonathanadams96.
|
||||
- Memory/QMD: make `openclaw memory index` verify and print the active QMD index file path/size, and fail when QMD leaves a missing or zero-byte index artifact after an update. (#16775) Thanks @Shunamxiao.
|
||||
- Memory/QMD: detect null-byte `ENOTDIR` update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.
|
||||
- Memory/QMD/Security: add `rawKeyPrefix` support for QMD scope rules and preserve legacy `keyPrefix: "agent:..."` matching, preventing scoped deny bypass when operators match agent-prefixed session keys.
|
||||
- Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.
|
||||
- Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.
|
||||
- Security/Memory-LanceDB: require explicit `autoCapture: true` opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.
|
||||
- Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.
|
||||
- Gateway/Memory: clean up `agentRunSeq` tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.
|
||||
- Auto-reply/Memory: bound `ABORT_MEMORY` growth by evicting oldest entries and deleting reset (`false`) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.
|
||||
- Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.
|
||||
- Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.
|
||||
- Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.
|
||||
- Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.
|
||||
- Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko.
|
||||
- Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.
|
||||
- Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739)
|
||||
- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-<agentId>`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420.
|
||||
- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras.
|
||||
- Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.
|
||||
- Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.
|
||||
- Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.
|
||||
- Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.
|
||||
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
|
||||
- Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.
|
||||
- Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.
|
||||
- Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.
|
||||
- Security/Hooks: restrict hook transform modules to `~/.openclaw/hooks/transforms` (prevents path traversal/escape module loads via config). Config note: `hooks.transformsDir` must now be within that directory. Thanks @akhmittra.
|
||||
- Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).
|
||||
- Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.
|
||||
- Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.
|
||||
- Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.
|
||||
- Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.
|
||||
- Security/Slack: compute command authorization for DM slash commands even when `dmPolicy=open`, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.
|
||||
- Security/Pairing: scope pairing allowlist writes/reads to channel accounts (for example `telegram:yy`), and propagate account-aware pairing approvals so multi-account channels do not share a single per-channel pairing allowFrom store. (#17631) Thanks @crazytan.
|
||||
- Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.
|
||||
- Security/Google Chat: deprecate `users/<email>` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.
|
||||
- Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.
|
||||
- Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject `@username` principals), auto-resolve `@username` to IDs in `openclaw doctor --fix` (when possible), and warn in `openclaw security audit` when legacy configs contain usernames. Thanks @vincentkoc.
|
||||
- Telegram/Security: reject Telegram webhook startup when `webhookSecret` is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.
|
||||
- Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).
|
||||
- Telegram: set webhook callback timeout handling to `onTimeout: "return"` (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.
|
||||
- Signal: preserve case-sensitive `group:` target IDs during normalization so mixed-case group IDs no longer fail with `Group not found`. (#16748) Thanks @repfigit.
|
||||
- Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.
|
||||
- Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.
|
||||
- Security/Agents: enforce workspace-root path bounds for `apply_patch` in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.
|
||||
- Security/Agents: enforce symlink-escape checks for `apply_patch` delete hunks under `workspaceOnly`, while still allowing deleting the symlink itself. Thanks @p80n-sec.
|
||||
- Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.
|
||||
- macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins.
|
||||
- Scripts/Security: validate GitHub logins and avoid shell invocation in `scripts/update-clawtributors.ts` to prevent command injection via malicious commit records. Thanks @scanleale.
|
||||
- Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).
|
||||
- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks @p80n-sec.
|
||||
- Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
|
||||
- Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.
|
||||
- Security/Gateway: stop returning raw resolved config values in `skills.status` requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.
|
||||
- Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.
|
||||
- Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.
|
||||
- Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.
|
||||
- Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.
|
||||
- Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.
|
||||
- Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.
|
||||
- Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: `channels.tlon.allowPrivateNetwork`). Thanks @p80n-sec.
|
||||
- Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without `telnyx.publicKey` are now rejected unless `skipSignatureVerification` is enabled. Thanks @p80n-sec.
|
||||
- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.
|
||||
- Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Install: add optional Podman-based setup: `setup-podman.sh` for one-time host setup (openclaw user, image, launch script, systemd quadlet), `run-openclaw-podman.sh launch` / `launch setup`; systemd Quadlet unit for openclaw user service; docs for rootless container, openclaw user (subuid/subgid), and quadlet (troubleshooting). (#16273) Thanks @DarwinsBuddy.
|
||||
- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
|
||||
- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
|
||||
- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
|
||||
- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21.
|
||||
- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
|
||||
- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
|
||||
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
|
||||
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
|
||||
- Security/Plugins/Hooks: harden npm-based installs by restricting specs to registry packages only, passing `--ignore-scripts` to `npm pack`, and cleaning up temp install directories.
|
||||
- Security/Sessions: preserve inter-session input provenance for routed prompts so delegated/internal sessions are not treated as direct external user instructions. Thanks @anbecker.
|
||||
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
||||
- Agents: strip leading empty lines from `sanitizeUserFacingText` output and normalize whitespace-only outputs to empty text. (#16158) Thanks @mcinteerj.
|
||||
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
|
||||
- Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
|
||||
- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189)
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
|
||||
- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.
|
||||
- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk.
|
||||
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
|
||||
- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599)
|
||||
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
|
||||
- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago.
|
||||
- Memory/LanceDB: add configurable `captureMaxChars` for auto-capture while keeping the legacy 500-char default. (#16641) Thanks @ciberponk.
|
||||
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
|
||||
- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
|
||||
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
|
||||
- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
|
||||
- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
|
||||
- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
|
||||
- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
|
||||
- Agents/Codex: allow `gpt-5.3-codex-spark` in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.
|
||||
- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
|
||||
- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
|
||||
- Models/Codex: resolve configured `openai-codex/gpt-5.3-codex-spark` through forward-compat fallback during `models list`, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.
|
||||
- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
|
||||
- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
|
||||
- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly (including Docker TTY installs that would otherwise hang). (#12972) Thanks @vincentkoc.
|
||||
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
|
||||
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
|
||||
- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
|
||||
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
|
||||
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
|
||||
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
|
||||
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
|
||||
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
|
||||
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
|
||||
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
|
||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
|
||||
- Gateway/Sessions: cache derived title + last-message transcript reads to speed up repeated sessions list refreshes.
|
||||
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
|
||||
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
||||
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
|
||||
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
|
||||
- Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent.
|
||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||
- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
|
||||
- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
|
||||
- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
|
||||
- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
|
||||
- Security/Browser: require auth for the sandbox browser bridge server (protects `/profiles`, `/tabs`, CDP URLs, and other control endpoints). Thanks @jackhax.
|
||||
- Security: bind local helper servers to loopback and fail closed on non-loopback OAuth callback hosts (reduces localhost/LAN attack surface).
|
||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
|
||||
- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
|
||||
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
|
||||
- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
|
||||
- Security/Gateway: bind node `system.run` approval overrides to gateway exec-approval records (runId-bound), preventing approval-bypass via `node.invoke` param injection. Thanks @222n5.
|
||||
- Agents/Nodes: harden node exec approval decision handling in the `nodes` tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.
|
||||
- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
|
||||
- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
|
||||
- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
|
||||
- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
|
||||
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
|
||||
- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998)
|
||||
- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
|
||||
- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
|
||||
- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
|
||||
- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
|
||||
- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
|
||||
- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
|
||||
- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale.
|
||||
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
|
||||
- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
|
||||
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
|
||||
- Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.
|
||||
- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise.
|
||||
- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
|
||||
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
|
||||
- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic.
|
||||
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
||||
- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c.
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
@ -60,6 +428,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
|
||||
- Sessions: guard `withSessionStoreLock` against undefined `storePath` to prevent `path.dirname` crash. (#14717)
|
||||
- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
|
||||
- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
|
||||
- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
|
||||
@ -77,11 +446,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
|
||||
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
|
||||
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
|
||||
- Gateway/Control UI: keep partial assistant output visible when runs are aborted, and persist aborted partials to session transcripts for follow-up context.
|
||||
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
|
||||
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
|
||||
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
|
||||
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
|
||||
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
|
||||
- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber.
|
||||
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
|
||||
- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug.
|
||||
@ -94,20 +465,24 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
|
||||
- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
|
||||
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
|
||||
- Slack: honor `limit` for `emoji-list` actions across core and extension adapters, with capped emoji-list responses in the Slack action handler. (#4293) Thanks @mcaxtr.
|
||||
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
|
||||
- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999.
|
||||
- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
|
||||
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
|
||||
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.
|
||||
- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
|
||||
- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow.
|
||||
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
|
||||
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
|
||||
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
|
||||
- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07.
|
||||
- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
|
||||
- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
|
||||
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
|
||||
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
|
||||
- Voice Call: pass Twilio stream auth token via `<Parameter>` instead of query string. (#14029) Thanks @mcwigglesmcgee.
|
||||
- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl.
|
||||
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
|
||||
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
|
||||
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
|
||||
@ -133,6 +508,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
|
||||
- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
|
||||
- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
|
||||
- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238.
|
||||
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
|
||||
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
@ -144,6 +520,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
|
||||
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
|
||||
- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302)
|
||||
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
||||
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
|
||||
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
|
||||
@ -207,6 +584,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
|
||||
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
|
||||
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
|
||||
- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
|
||||
@ -218,6 +596,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
|
||||
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
|
||||
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
|
||||
- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123.
|
||||
- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
@ -281,6 +663,18 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua.
|
||||
- Update: remove dead restore control-ui step that failed on gitignored dist/ output.
|
||||
- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras.
|
||||
- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke.
|
||||
- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70.
|
||||
- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672)
|
||||
- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
|
||||
- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
|
||||
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
|
||||
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
|
||||
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
|
||||
- Exec approvals: ensure two-phase approval registration/decision flow works reliably by validating `twoPhase` requests and exposing `waitDecision` as an approvals-scoped gateway method. (#3357, fixes #2402) Thanks @ramin-shirali.
|
||||
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
||||
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
|
||||
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
|
||||
@ -351,11 +745,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
|
||||
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect. Thanks @simecek.
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security/Gateway: require `operator.approvals` for in-chat `/approve` when invoked from gateway clients. Thanks @yueyueL.
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
|
||||
@ -391,7 +787,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Security: guard remote media fetches with SSRF protections (block private/localhost, DNS pinning).
|
||||
- Updates: clean stale global install rename dirs and extend gateway update timeouts to avoid npm ENOTEMPTY failures.
|
||||
- Plugins: validate plugin/hook install paths and reject traversal-like names.
|
||||
- Security/Plugins/Hooks: validate install paths and reject traversal-like names (prevents path traversal outside the state dir). Thanks @logicx24.
|
||||
- Telegram: add download timeouts for file fetches. (#6914) Thanks @hclsys.
|
||||
- Telegram: enforce thread specs for DM vs forum sends. (#6833) Thanks @obviyus.
|
||||
- Streaming: flush block streaming on paragraph boundaries for newline chunking. (#7014)
|
||||
@ -1651,6 +2047,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Tests/Agents: add regression coverage for workspace tool path resolution and bash cwd defaults.
|
||||
- iOS/Android: enable stricter concurrency/lint checks; fix Swift 6 strict concurrency issues + Android lint errors (ExifInterface, obsolete SDK check). (#662) — thanks @KristijanJovanovski.
|
||||
- Auth: read Codex CLI keychain tokens on macOS before falling back to `~/.codex/auth.json`, preventing stale refresh tokens from breaking gateway live tests.
|
||||
- Security/Exec approvals: reject shell command substitution (`$()` and backticks) inside double quotes to prevent exec allowlist bypass when exec allowlist mode is explicitly enabled (the default configuration does not use this mode). Thanks @simecek.
|
||||
- iOS/macOS: share `AsyncTimeout`, require explicit `bridgeStableID` on connect, and harden tool display defaults (avoids missing-resource label fallbacks).
|
||||
- Telegram: serialize media-group processing to avoid missed albums under load.
|
||||
- Signal: handle `dataMessage.reaction` events (signal-cli SSE) to avoid broken attachment errors. (#637) — thanks @neist.
|
||||
|
||||
@ -13,24 +13,33 @@ Welcome to the lobster tank! 🦞
|
||||
- **Peter Steinberger** - Benevolent Dictator
|
||||
- GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)
|
||||
|
||||
- **Shadow** - Discord + Slack subsystem
|
||||
- **Shadow** - Discord subsystem, Discord admin
|
||||
- GitHub: [@thewilloftheshadow](https://github.com/thewilloftheshadow) · X: [@4shad0wed](https://x.com/4shad0wed)
|
||||
|
||||
- **Vignesh** - Memory (QMD), formal modeling, TUI, and Lobster
|
||||
- **Vignesh** - Memory (QMD), formal modeling, TUI, IRC, and Lobster
|
||||
- GitHub: [@vignesh07](https://github.com/vignesh07) · X: [@\_vgnsh](https://x.com/_vgnsh)
|
||||
|
||||
- **Jos** - Telegram, API, Nix mode
|
||||
- GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)
|
||||
|
||||
- **Ayaan Zaidi** - Telegram subsystem, iOS app
|
||||
- GitHub: [@obviyus](https://github.com/obviyus) · X: [@0bviyus](https://x.com/0bviyus)
|
||||
|
||||
- **Tyler Yust** - Agents/subagents, cron, BlueBubbles, macOS app
|
||||
- GitHub: [@tyler6204](https://github.com/tyler6204) · X: [@tyleryust](https://x.com/tyleryust)
|
||||
|
||||
- **Mariano Belinky** - iOS app, Security
|
||||
- GitHub: [@mbelinky](https://github.com/mbelinky) · X: [@belimad](https://x.com/belimad)
|
||||
|
||||
- **Seb Slight** - Docs, Agent Reliability, Runtime Hardening
|
||||
- GitHub: [@sebslight](https://github.com/sebslight) · X: [@sebslig](https://x.com/sebslig)
|
||||
|
||||
- **Christoph Nakazawa** - JS Infra
|
||||
- GitHub: [@cpojer](https://github.com/cpojer) · X: [@cnakazawa](https://x.com/cnakazawa)
|
||||
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Maximilian Nussbaumer** - DevOps, CI, Code Sanity
|
||||
- GitHub: [@quotentiroler](https://github.com/quotentiroler) · X: [@quotentiroler](https://x.com/quotentiroler)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@ -23,6 +23,19 @@ COPY scripts ./scripts
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after pnpm install so playwright-core is available in node_modules.
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
|
||||
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
|
||||
45
Dockerfile.sandbox-common
Normal file
45
Dockerfile.sandbox-common
Normal file
@ -0,0 +1,45 @@
|
||||
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
USER root
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
|
||||
ARG INSTALL_PNPM=1
|
||||
ARG INSTALL_BUN=1
|
||||
ARG BUN_INSTALL_DIR=/opt/bun
|
||||
ARG INSTALL_BREW=1
|
||||
ARG BREW_INSTALL_DIR=/home/linuxbrew/.linuxbrew
|
||||
ARG FINAL_USER=sandbox
|
||||
|
||||
ENV BUN_INSTALL=${BUN_INSTALL_DIR}
|
||||
ENV HOMEBREW_PREFIX=${BREW_INSTALL_DIR}
|
||||
ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
|
||||
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
|
||||
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
|
||||
curl -fsSL https://bun.sh/install | bash; \
|
||||
ln -sf "${BUN_INSTALL_DIR}/bin/bun" /usr/local/bin/bun; \
|
||||
fi
|
||||
|
||||
RUN if [ "${INSTALL_BREW}" = "1" ]; then \
|
||||
if ! id -u linuxbrew >/dev/null 2>&1; then useradd -m -s /bin/bash linuxbrew; fi; \
|
||||
mkdir -p "${BREW_INSTALL_DIR}"; \
|
||||
chown -R linuxbrew:linuxbrew "$(dirname "${BREW_INSTALL_DIR}")"; \
|
||||
su - linuxbrew -c "NONINTERACTIVE=1 CI=1 /bin/bash -c '$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)'"; \
|
||||
if [ ! -e "${BREW_INSTALL_DIR}/Library" ]; then ln -s "${BREW_INSTALL_DIR}/Homebrew/Library" "${BREW_INSTALL_DIR}/Library"; fi; \
|
||||
if [ ! -x "${BREW_INSTALL_DIR}/bin/brew" ]; then echo \"brew install failed\"; exit 1; fi; \
|
||||
ln -sf "${BREW_INSTALL_DIR}/bin/brew" /usr/local/bin/brew; \
|
||||
fi
|
||||
|
||||
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
|
||||
USER ${FINAL_USER}
|
||||
|
||||
@ -112,9 +112,9 @@ Full security guide: [Security](https://docs.openclaw.ai/gateway/security)
|
||||
|
||||
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
|
||||
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dmPolicy="pairing"` / `channels.slack.dmPolicy="pairing"`; legacy: `channels.discord.dm.policy`, `channels.slack.dm.policy`): unknown senders receive a short pairing code and the bot does not process their message.
|
||||
- Approve with: `openclaw pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
|
||||
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.allowFrom` / `channels.slack.allowFrom`; legacy: `channels.discord.dm.allowFrom`, `channels.slack.dm.allowFrom`).
|
||||
|
||||
Run `openclaw doctor` to surface risky/misconfigured DM policies.
|
||||
|
||||
@ -267,6 +267,7 @@ ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/mesh <goal>` — auto-plan + run a multi-step workflow (`/mesh plan|run|status|retry` available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
@ -303,6 +304,7 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
- Install: [OpenClaw for Android](https://github.com/irtiq7/OpenClaw-Android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@ -360,7 +362,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
|
||||
### [Discord](https://docs.openclaw.ai/channels/discord)
|
||||
|
||||
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.dm.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -546,4 +548,5 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a>
|
||||
<a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4" width="48" height="48" alt="Akash Kobal" title="Akash Kobal"/></a>
|
||||
</p>
|
||||
|
||||
19
SECURITY.md
19
SECURITY.md
@ -39,6 +39,10 @@ Reports without reproduction steps, demonstrated impact, and remediation advice
|
||||
OpenClaw is a labor of love. There is no bug bounty program and no budget for paid reports. Please still disclose responsibly so we can fix issues quickly.
|
||||
The best way to help the project right now is by sending PRs.
|
||||
|
||||
## Maintainers: GHSA Updates via CLI
|
||||
|
||||
When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (or newer). Without it, some fields (notably CVSS) may not persist even if the request returns 200.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Public Internet Exposure
|
||||
@ -51,9 +55,22 @@ For threat model + hardening guidance (including `openclaw security audit --deep
|
||||
|
||||
- `https://docs.openclaw.ai/gateway/security`
|
||||
|
||||
### Tool filesystem hardening
|
||||
|
||||
- `tools.exec.applyPatch.workspaceOnly: true` (recommended): keeps `apply_patch` writes/deletes within the configured workspace directory.
|
||||
- `tools.fs.workspaceOnly: true` (optional): restricts `read`/`write`/`edit`/`apply_patch` paths to the workspace directory.
|
||||
- Avoid setting `tools.exec.applyPatch.workspaceOnly: false` unless you fully trust who can trigger tool execution.
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
|
||||
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
|
||||
|
||||
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
|
||||
- Config: `gateway.bind="loopback"` (default).
|
||||
- CLI: `openclaw gateway run --bind loopback`.
|
||||
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
|
||||
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
|
||||
- The Gateway HTTP surface includes the canvas host (`/__openclaw__/canvas/`, `/__openclaw__/a2ui/`). Treat canvas content as sensitive/untrusted and avoid exposing it beyond loopback unless you understand the risk.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
|
||||
461
appcast.xml
461
appcast.xml
@ -3,206 +3,311 @@
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.12</title>
|
||||
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
|
||||
<title>2026.2.14</title>
|
||||
<pubDate>Sun, 15 Feb 2026 04:24:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9500</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.12</sparkle:shortVersionString>
|
||||
<sparkle:version>202602140</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.14</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.12</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.14</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: add <code>openclaw logs --local-time</code> to display log timestamps in local timezone. (#13818) Thanks @xialonglee.</li>
|
||||
<li>Telegram: render blockquotes as native <code><blockquote></code> tags instead of stripping them. (#14608)</li>
|
||||
<li>Config: avoid redacting <code>maxTokens</code>-like fields during config snapshot redaction, preventing round-trip validation failures in <code>/config</code>. (#14006) Thanks @constansino.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li>Hooks: <code>POST /hooks/agent</code> now rejects payload <code>sessionKey</code> overrides by default. To keep fixed hook context, set <code>hooks.defaultSessionKey</code> (recommended with <code>hooks.allowedSessionKeyPrefixes: ["hook:"]</code>). If you need legacy behavior, explicitly set <code>hooks.allowRequestSessionKey: true</code>. Thanks @alpernae for reporting.</li>
|
||||
<li>Telegram: add poll sending via <code>openclaw message poll</code> (duration seconds, silent delivery, anonymity controls). (#16209) Thanks @robbyczgw-cla.</li>
|
||||
<li>Slack/Discord: add <code>dmPolicy</code> + <code>allowFrom</code> config aliases for DM access control; legacy <code>dm.policy</code> + <code>dm.allowFrom</code> keys remain supported and <code>openclaw doctor --fix</code> can migrate them.</li>
|
||||
<li>Discord: allow exec approval prompts to target channels or both DM+channel via <code>channels.discord.execApprovals.target</code>. (#16051) Thanks @leonnardo.</li>
|
||||
<li>Sandbox: add <code>sandbox.browser.binds</code> to configure browser-container bind mounts separately from exec containers. (#16230) Thanks @seheepeak.</li>
|
||||
<li>Discord: add debug logging for message routing decisions to improve <code>--debug</code> tracing. (#16202) Thanks @jayleekr.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gateway/OpenResponses: harden URL-based <code>input_file</code>/<code>input_image</code> handling with explicit SSRF deny policy, hostname allowlists (<code>files.urlAllowlist</code> / <code>images.urlAllowlist</code>), per-request URL input caps (<code>maxUrlParts</code>), blocked-fetch audit logging, and regression coverage/docs updates.</li>
|
||||
<li>Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.</li>
|
||||
<li>Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.</li>
|
||||
<li>Security/Audit: add hook session-routing hardening checks (<code>hooks.defaultSessionKey</code>, <code>hooks.allowRequestSessionKey</code>, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.</li>
|
||||
<li>Security/Sandbox: confine mirrored skill sync destinations to the sandbox <code>skills/</code> root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.</li>
|
||||
<li>Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip <code>toolResult.details</code> from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.</li>
|
||||
<li>Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (<code>429</code> + <code>Retry-After</code>). Thanks @akhmittra.</li>
|
||||
<li>Security/Browser: require auth for loopback browser control HTTP routes, auto-generate <code>gateway.auth.token</code> when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.</li>
|
||||
<li>Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.</li>
|
||||
<li>Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.</li>
|
||||
<li>Logging/CLI: use local timezone timestamps for console prefixing, and include <code>±HH:MM</code> offsets when using <code>openclaw logs --local-time</code> to avoid ambiguity. (#14771) Thanks @0xRaini.</li>
|
||||
<li>Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.</li>
|
||||
<li>Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.</li>
|
||||
<li>Gateway: prevent <code>undefined</code>/missing token in auth config. (#13809) Thanks @asklee-klawd.</li>
|
||||
<li>Gateway: handle async <code>EPIPE</code> on stdout/stderr during shutdown. (#13414) Thanks @keshav55.</li>
|
||||
<li>Gateway/Control UI: resolve missing dashboard assets when <code>openclaw</code> is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.</li>
|
||||
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
|
||||
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
|
||||
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
|
||||
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
|
||||
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
|
||||
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
|
||||
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
|
||||
<li>Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after <code>requests-in-flight</code> skips. (#14901) Thanks @joeykrug.</li>
|
||||
<li>Cron: honor stored session model overrides for isolated-agent runs while preserving <code>hooks.gmail.model</code> precedence for Gmail hook sessions. (#14983) Thanks @shtse8.</li>
|
||||
<li>Logging/Browser: fall back to <code>os.tmpdir()/openclaw</code> for default log, browser trace, and browser download temp paths when <code>/tmp/openclaw</code> is unavailable.</li>
|
||||
<li>WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.</li>
|
||||
<li>WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.</li>
|
||||
<li>WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.</li>
|
||||
<li>Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.</li>
|
||||
<li>Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.</li>
|
||||
<li>BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.</li>
|
||||
<li>Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.</li>
|
||||
<li>Slack: detect control commands when channel messages start with bot mention prefixes (for example, <code>@Bot /new</code>). (#14142) Thanks @beefiker.</li>
|
||||
<li>Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.</li>
|
||||
<li>Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.</li>
|
||||
<li>Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.</li>
|
||||
<li>Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.</li>
|
||||
<li>Signal: render mention placeholders as <code>@uuid</code>/<code>@phone</code> so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.</li>
|
||||
<li>Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.</li>
|
||||
<li>Onboarding/Providers: add Z.AI endpoint-specific auth choices (<code>zai-coding-global</code>, <code>zai-coding-cn</code>, <code>zai-global</code>, <code>zai-cn</code>) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.</li>
|
||||
<li>Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include <code>minimax-m2.5</code> in modern model filtering. (#14865) Thanks @adao-max.</li>
|
||||
<li>Ollama: use configured <code>models.providers.ollama.baseUrl</code> for model discovery and normalize <code>/v1</code> endpoints to the native Ollama API root. (#14131) Thanks @shtse8.</li>
|
||||
<li>Voice Call: pass Twilio stream auth token via <code><Parameter></code> instead of query string. (#14029) Thanks @mcwigglesmcgee.</li>
|
||||
<li>Feishu: pass <code>Buffer</code> directly to the Feishu SDK upload APIs instead of <code>Readable.from(...)</code> to avoid form-data upload failures. (#10345) Thanks @youngerstyle.</li>
|
||||
<li>Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.</li>
|
||||
<li>Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.</li>
|
||||
<li>Feishu DocX: preserve top-level converted block order using <code>firstLevelBlockIds</code> when writing/appending documents. (#13994) Thanks @Cynosure159.</li>
|
||||
<li>Feishu plugin packaging: remove <code>workspace:*</code> <code>openclaw</code> dependency from <code>extensions/feishu</code> and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.</li>
|
||||
<li>CLI/Wizard: exit with code 1 when <code>configure</code>, <code>agents add</code>, or interactive <code>onboard</code> wizards are canceled, so <code>set -e</code> automation stops correctly. (#14156) Thanks @0xRaini.</li>
|
||||
<li>Media: strip <code>MEDIA:</code> lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.</li>
|
||||
<li>Config/Cron: exclude <code>maxTokens</code> from config redaction and honor <code>deleteAfterRun</code> on skipped cron jobs. (#13342) Thanks @niceysam.</li>
|
||||
<li>Config: ignore <code>meta</code> field changes in config file watcher. (#13460) Thanks @brandonwise.</li>
|
||||
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
|
||||
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
|
||||
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
|
||||
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
|
||||
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
|
||||
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
|
||||
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
|
||||
<li>Daemon: suppress <code>EPIPE</code> error when restarting LaunchAgent. (#14343) Thanks @0xRaini.</li>
|
||||
<li>Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.</li>
|
||||
<li>Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.</li>
|
||||
<li>Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.</li>
|
||||
<li>Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.</li>
|
||||
<li>Agents: keep followup-runner session <code>totalTokens</code> aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.</li>
|
||||
<li>Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.</li>
|
||||
<li>Hooks/Tools: dispatch <code>before_tool_call</code> and <code>after_tool_call</code> hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.</li>
|
||||
<li>Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.</li>
|
||||
<li>Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.</li>
|
||||
<li>Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.</li>
|
||||
<li>CLI/Plugins: ensure <code>openclaw message send</code> exits after successful delivery across plugin-backed channels so one-shot sends do not hang. (#16491) Thanks @yinghaosang.</li>
|
||||
<li>CLI/Plugins: run registered plugin <code>gateway_stop</code> hooks before <code>openclaw message</code> exits (success and failure paths), so plugin-backed channels can clean up one-shot CLI resources. (#16580) Thanks @gumadeiras.</li>
|
||||
<li>WhatsApp: honor per-account <code>dmPolicy</code> overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr.</li>
|
||||
<li>Telegram: when <code>channels.telegram.commands.native</code> is <code>false</code>, exclude plugin commands from <code>setMyCommands</code> menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg.</li>
|
||||
<li>LINE: return 200 OK for Developers Console "Verify" requests (<code>{"events":[]}</code>) without <code>X-Line-Signature</code>, while still requiring signatures for real deliveries. (#16582) Thanks @arosstale.</li>
|
||||
<li>Cron: deliver text-only output directly when <code>delivery.to</code> is set so cron recipients get full output instead of summaries. (#16360) Thanks @thewilloftheshadow.</li>
|
||||
<li>Cron/Slack: preserve agent identity (name and icon) when cron jobs deliver outbound messages. (#16242) Thanks @robbyczgw-cla.</li>
|
||||
<li>Media: accept <code>MEDIA:</code>-prefixed paths (lenient whitespace) when loading outbound media to prevent <code>ENOENT</code> for tool-returned local media paths. (#13107) Thanks @mcaxtr.</li>
|
||||
<li>Agents: deliver tool result media (screenshots, images, audio) to channels regardless of verbose level. (#11735) Thanks @strelov1.</li>
|
||||
<li>Agents/Image tool: allow workspace-local image paths by including the active workspace directory in local media allowlists, and trust sandbox-validated paths in image loaders to prevent false "not under an allowed directory" rejections. (#15541)</li>
|
||||
<li>Agents/Image tool: propagate the effective workspace root into tool wiring so workspace-local image paths are accepted by default when running without an explicit <code>workspaceDir</code>. (#16722)</li>
|
||||
<li>BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x.</li>
|
||||
<li>CLI: fix lazy core command registration so top-level maintenance commands (<code>doctor</code>, <code>dashboard</code>, <code>reset</code>, <code>uninstall</code>) resolve correctly instead of exposing a non-functional <code>maintenance</code> placeholder command.</li>
|
||||
<li>CLI/Dashboard: when <code>gateway.bind=lan</code>, generate localhost dashboard URLs to satisfy browser secure-context requirements while preserving non-LAN bind behavior. (#16434) Thanks @BinHPdev.</li>
|
||||
<li>TUI/Gateway: resolve local gateway target URL from <code>gateway.bind</code> mode (tailnet/lan) instead of hardcoded localhost so <code>openclaw tui</code> connects when gateway is non-loopback. (#16299) Thanks @cortexuvula.</li>
|
||||
<li>TUI: honor explicit <code>--session <key></code> in <code>openclaw tui</code> even when <code>session.scope</code> is <code>global</code>, so named sessions no longer collapse into shared global history. (#16575) Thanks @cinqu.</li>
|
||||
<li>TUI: use available terminal width for session name display in searchable select lists. (#16238) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: refactor searchable select list description layout and add regression coverage for ANSI-highlight width bounds.</li>
|
||||
<li>TUI: preserve in-flight streaming replies when a different run finalizes concurrently (avoid clearing active run or reloading history mid-stream). (#10704) Thanks @axschr73.</li>
|
||||
<li>TUI: keep pre-tool streamed text visible when later tool-boundary deltas temporarily omit earlier text blocks. (#6958) Thanks @KrisKind75.</li>
|
||||
<li>TUI: sanitize ANSI/control-heavy history text, redact binary-like lines, and split pathological long unbroken tokens before rendering to prevent startup crashes on binary attachment history. (#13007) Thanks @wilkinspoe.</li>
|
||||
<li>TUI: harden render-time sanitizer for narrow terminals by chunking moderately long unbroken tokens and adding fast-path sanitization guards to reduce overhead on normal text. (#5355) Thanks @tingxueren.</li>
|
||||
<li>TUI: render assistant body text in terminal default foreground (instead of fixed light ANSI color) so contrast remains readable on light themes such as Solarized Light. (#16750) Thanks @paymog.</li>
|
||||
<li>TUI/Hooks: pass explicit reset reason (<code>new</code> vs <code>reset</code>) through <code>sessions.reset</code> and emit internal command hooks for gateway-triggered resets so <code>/new</code> hook workflows fire in TUI/webchat.</li>
|
||||
<li>Cron: prevent <code>cron list</code>/<code>cron status</code> from silently skipping past-due recurring jobs by using maintenance recompute semantics. (#16156) Thanks @zerone0x.</li>
|
||||
<li>Cron: repair missing/corrupt <code>nextRunAtMs</code> for the updated job without globally recomputing unrelated due jobs during <code>cron update</code>. (#15750)</li>
|
||||
<li>Cron: skip missed-job replay on startup for jobs interrupted mid-run (stale <code>runningAtMs</code> markers), preventing restart loops for self-restarting jobs such as update tasks. (#16694) Thanks @sbmilburn.</li>
|
||||
<li>Discord: prefer gateway guild id when logging inbound messages so cached-miss guilds do not appear as <code>guild=dm</code>. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: treat empty per-guild <code>channels: {}</code> config maps as no channel allowlist (not deny-all), so <code>groupPolicy: "open"</code> guilds without explicit channel entries continue to receive messages. (#16714) Thanks @xqliu.</li>
|
||||
<li>Models/CLI: guard <code>models status</code> string trimming paths to prevent crashes from malformed non-string config values. (#16395) Thanks @BinHPdev.</li>
|
||||
<li>Gateway/Subagents: preserve queued announce items and summary state on delivery errors, retry failed announce drains, and avoid dropping unsent announcements on timeout/failure. (#16729) Thanks @Clawdette-Workspace.</li>
|
||||
<li>Gateway/Sessions: abort active embedded runs and clear queued session work before <code>sessions.reset</code>, returning unavailable if the run does not stop in time. (#16576) Thanks @Grynn.</li>
|
||||
<li>Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: add a safety timeout around embedded <code>session.compact()</code> to ensure stalled compaction runs settle and release blocked session lanes. (#16331) Thanks @BinHPdev.</li>
|
||||
<li>Agents: keep unresolved mutating tool failures visible until the same action retry succeeds, scope mutation-error surfacing to mutating calls (including <code>session_status</code> model changes), and dedupe duplicate failure warnings in outbound replies. (#16131) Thanks @Swader.</li>
|
||||
<li>Agents/Process/Bootstrap: preserve unbounded <code>process log</code> offset-only pagination (default tail applies only when both <code>offset</code> and <code>limit</code> are omitted) and enforce strict <code>bootstrapTotalMaxChars</code> budgeting across injected bootstrap content (including markers), skipping additional injection when remaining budget is too small. (#16539) Thanks @CharlieGreenman.</li>
|
||||
<li>Agents/Workspace: persist bootstrap onboarding state so partially initialized workspaces recover missing <code>BOOTSTRAP.md</code> once, while completed onboarding keeps BOOTSTRAP deleted even if runtime files are later recreated. Thanks @gumadeiras.</li>
|
||||
<li>Agents/Workspace: create <code>BOOTSTRAP.md</code> when core workspace files are seeded in partially initialized workspaces, while keeping BOOTSTRAP one-shot after onboarding deletion. (#16457) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: classify external timeout aborts during compaction the same as internal timeouts, preventing unnecessary auth-profile rotation and preserving compaction-timeout snapshot fallback behavior. (#9855) Thanks @mverrilli.</li>
|
||||
<li>Agents: treat empty-stream provider failures (<code>request ended without sending any chunks</code>) as timeout-class failover signals, enabling auth-profile rotation/fallback and showing a friendly timeout message instead of raw provider errors. (#10210) Thanks @zenchantlive.</li>
|
||||
<li>Agents: treat <code>read</code> tool <code>file_path</code> arguments as valid in tool-start diagnostics to avoid false “read tool called without path” warnings when alias parameters are used. (#16717) Thanks @Stache73.</li>
|
||||
<li>Ollama/Agents: avoid forcing <code><final></code> tag enforcement for Ollama models, which could suppress all output as <code>(no output)</code>. (#16191) Thanks @Glucksberg.</li>
|
||||
<li>Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238.</li>
|
||||
<li>Skills: watch <code>SKILL.md</code> only when refreshing skills snapshot to avoid file-descriptor exhaustion in large data trees. (#11325) Thanks @household-bard.</li>
|
||||
<li>Memory/QMD: make <code>memory status</code> read-only by skipping QMD boot update/embed side effects for status-only manager checks.</li>
|
||||
<li>Memory/QMD: keep original QMD failures when builtin fallback initialization fails (for example missing embedding API keys), instead of replacing them with fallback init errors.</li>
|
||||
<li>Memory/Builtin: keep <code>memory status</code> dirty reporting stable across invocations by deriving status-only manager dirty state from persisted index metadata instead of process-start defaults. (#10863) Thanks @BarryYangi.</li>
|
||||
<li>Memory/QMD: cap QMD command output buffering to prevent memory exhaustion from pathological <code>qmd</code> command output.</li>
|
||||
<li>Memory/QMD: parse qmd scope keys once per request to avoid repeated parsing in scope checks.</li>
|
||||
<li>Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency.</li>
|
||||
<li>Memory/QMD: pass result limits to <code>search</code>/<code>vsearch</code> commands so QMD can cap results earlier.</li>
|
||||
<li>Memory/QMD: avoid reading full markdown files when a <code>from/lines</code> window is requested in QMD reads.</li>
|
||||
<li>Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn.</li>
|
||||
<li>Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy <code>stdout</code>.</li>
|
||||
<li>Memory/QMD: treat prefixed <code>no results found</code> marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui.</li>
|
||||
<li>Memory/QMD: avoid multi-collection <code>query</code> ranking corruption by running one <code>qmd query -c <collection></code> per managed collection and merging by best score (also used for <code>search</code>/<code>vsearch</code> fallback-to-query). (#16740) Thanks @volarian-vai.</li>
|
||||
<li>Memory/QMD: detect null-byte <code>ENOTDIR</code> update failures, rebuild managed collections once, and retry update to self-heal corrupted collection metadata. (#12919) Thanks @jorgejhms.</li>
|
||||
<li>Memory/QMD/Security: add <code>rawKeyPrefix</code> support for QMD scope rules and preserve legacy <code>keyPrefix: "agent:..."</code> matching, preventing scoped deny bypass when operators match agent-prefixed session keys.</li>
|
||||
<li>Memory/Builtin: narrow memory watcher targets to markdown globs and ignore dependency/venv directories to reduce file-descriptor pressure during memory sync startup. (#11721) Thanks @rex05ai.</li>
|
||||
<li>Security/Memory-LanceDB: treat recalled memories as untrusted context (escape injected memory text + explicit non-instruction framing), skip likely prompt-injection payloads during auto-capture, and restrict auto-capture to user messages to reduce memory-poisoning risk. (#12524) Thanks @davidschmid24.</li>
|
||||
<li>Security/Memory-LanceDB: require explicit <code>autoCapture: true</code> opt-in (default is now disabled) to prevent automatic PII capture unless operators intentionally enable it. (#12552) Thanks @fr33d3m0n.</li>
|
||||
<li>Diagnostics/Memory: prune stale diagnostic session state entries and cap tracked session states to prevent unbounded in-memory growth on long-running gateways. (#5136) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Gateway/Memory: clean up <code>agentRunSeq</code> tracking on run completion/abort and enforce maintenance-time cap pruning to prevent unbounded sequence-map growth over long uptimes. (#6036) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Auto-reply/Memory: bound <code>ABORT_MEMORY</code> growth by evicting oldest entries and deleting reset (<code>false</code>) flags so abort state tracking cannot grow unbounded over long uptimes. (#6629) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Slack/Memory: bound thread-starter cache growth with TTL + max-size pruning to prevent long-running Slack gateways from accumulating unbounded thread cache state. (#5258) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Outbound/Memory: bound directory cache growth with max-size eviction and proactive TTL pruning to prevent long-running gateways from accumulating unbounded directory entries. (#5140) Thanks @coygeek and @vignesh07.</li>
|
||||
<li>Skills/Memory: remove disconnected nodes from remote-skills cache to prevent stale node metadata from accumulating over long uptimes. (#6760) Thanks @coygeek.</li>
|
||||
<li>Sandbox/Tools: make sandbox file tools bind-mount aware (including absolute container paths) and enforce read-only bind semantics for writes. (#16379) Thanks @tasaankaeris.</li>
|
||||
<li>Media/Security: allow local media reads from OpenClaw state <code>workspace/</code> and <code>sandboxes/</code> roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji.</li>
|
||||
<li>Media/Security: harden local media allowlist bypasses by requiring an explicit <code>readFile</code> override when callers mark paths as validated, and reject filesystem-root <code>localRoots</code> entries. (#16739)</li>
|
||||
<li>Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files.</li>
|
||||
<li>Security/BlueBubbles: require explicit <code>mediaLocalRoots</code> allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky.</li>
|
||||
<li>Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password.</li>
|
||||
<li>Security/BlueBubbles: harden BlueBubbles webhook auth behind reverse proxies by only accepting passwordless webhooks for direct localhost loopback requests (forwarded/proxied requests now require a password). Thanks @simecek.</li>
|
||||
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
|
||||
<li>Security/Zalo: reject ambiguous shared-path webhook routing when multiple webhook targets match the same secret.</li>
|
||||
<li>Security/Nostr: require loopback source and block cross-origin profile mutation/import attempts. Thanks @vincentkoc.</li>
|
||||
<li>Security/Signal: harden signal-cli archive extraction during install to prevent path traversal outside the install root.</li>
|
||||
<li>Security/Hooks: restrict hook transform modules to <code>~/.openclaw/hooks/transforms</code> (prevents path traversal/escape module loads via config). Config note: <code>hooks.transformsDir</code> must now be within that directory. Thanks @akhmittra.</li>
|
||||
<li>Security/Hooks: ignore hook package manifest entries that point outside the package directory (prevents out-of-tree handler loads during hook discovery).</li>
|
||||
<li>Security/Archive: enforce archive extraction entry/size limits to prevent resource exhaustion from high-expansion ZIP/TAR archives. Thanks @vincentkoc.</li>
|
||||
<li>Security/Media: reject oversized base64-backed input media before decoding to avoid large allocations. Thanks @vincentkoc.</li>
|
||||
<li>Security/Media: stream and bound URL-backed input media fetches to prevent memory exhaustion from oversized responses. Thanks @vincentkoc.</li>
|
||||
<li>Security/Skills: harden archive extraction for download-installed skills to prevent path traversal outside the target directory. Thanks @markmusson.</li>
|
||||
<li>Security/Slack: compute command authorization for DM slash commands even when <code>dmPolicy=open</code>, preventing unauthorized users from running privileged commands via DM. Thanks @christos-eth.</li>
|
||||
<li>Security/iMessage: keep DM pairing-store identities out of group allowlist authorization (prevents cross-context command authorization). Thanks @vincentkoc.</li>
|
||||
<li>Security/Google Chat: deprecate <code>users/<email></code> allowlists (treat <code>users/...</code> as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc.</li>
|
||||
<li>Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Security: require numeric Telegram sender IDs for allowlist authorization (reject <code>@username</code> principals), auto-resolve <code>@username</code> to IDs in <code>openclaw doctor --fix</code> (when possible), and warn in <code>openclaw security audit</code> when legacy configs contain usernames. Thanks @vincentkoc.</li>
|
||||
<li>Telegram/Security: reject Telegram webhook startup when <code>webhookSecret</code> is missing or empty (prevents unauthenticated webhook request forgery). Thanks @yueyueL.</li>
|
||||
<li>Security/Windows: avoid shell invocation when spawning child processes to prevent cmd.exe metacharacter injection via untrusted CLI arguments (e.g. agent prompt text).</li>
|
||||
<li>Telegram: set webhook callback timeout handling to <code>onTimeout: "return"</code> (10s) so long-running update processing no longer emits webhook 500s and retry storms. (#16763) Thanks @chansearrington.</li>
|
||||
<li>Signal: preserve case-sensitive <code>group:</code> target IDs during normalization so mixed-case group IDs no longer fail with <code>Group not found</code>. (#16748) Thanks @repfigit.</li>
|
||||
<li>Feishu/Security: harden media URL fetching against SSRF and local file disclosure. (#16285) Thanks @mbelinky.</li>
|
||||
<li>Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent.</li>
|
||||
<li>Security/Agents: enforce workspace-root path bounds for <code>apply_patch</code> in non-sandbox mode to block traversal and symlink escape writes. Thanks @p80n-sec.</li>
|
||||
<li>Security/Agents: enforce symlink-escape checks for <code>apply_patch</code> delete hunks under <code>workspaceOnly</code>, while still allowing deleting the symlink itself. Thanks @p80n-sec.</li>
|
||||
<li>Security/Agents (macOS): prevent shell injection when writing Claude CLI keychain credentials. (#15924) Thanks @aether-ai-agent.</li>
|
||||
<li>macOS: hard-limit unkeyed <code>openclaw://agent</code> deep links and ignore <code>deliver</code> / <code>to</code> / <code>channel</code> unless a valid unattended key is provided. Thanks @Cillian-Collins.</li>
|
||||
<li>Scripts/Security: validate GitHub logins and avoid shell invocation in <code>scripts/update-clawtributors.ts</code> to prevent command injection via malicious commit records. Thanks @scanleale.</li>
|
||||
<li>Security: fix Chutes manual OAuth login state validation by requiring the full redirect URL (reject code-only pastes) (thanks @aether-ai-agent).</li>
|
||||
<li>Security/Gateway: harden tool-supplied <code>gatewayUrl</code> overrides by restricting them to loopback or the configured <code>gateway.remote.url</code>. Thanks @p80n-sec.</li>
|
||||
<li>Security/Gateway: block <code>system.execApprovals.*</code> via <code>node.invoke</code> (use <code>exec.approvals.node.*</code> instead). Thanks @christos-eth.</li>
|
||||
<li>Security/Gateway: reject oversized base64 chat attachments before decoding to avoid large allocations. Thanks @vincentkoc.</li>
|
||||
<li>Security/Gateway: stop returning raw resolved config values in <code>skills.status</code> requirement checks (prevents operator.read clients from reading secrets). Thanks @simecek.</li>
|
||||
<li>Security/Net: fix SSRF guard bypass via full-form IPv4-mapped IPv6 literals (blocks loopback/private/metadata access). Thanks @yueyueL.</li>
|
||||
<li>Security/Browser: harden browser control file upload + download helpers to prevent path traversal / local file disclosure. Thanks @1seal.</li>
|
||||
<li>Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc.</li>
|
||||
<li>Security/Node Host: enforce <code>system.run</code> rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth.</li>
|
||||
<li>Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth.</li>
|
||||
<li>Security/Exec: harden PATH handling by disabling project-local <code>node_modules/.bin</code> bootstrapping by default, disallowing node-host <code>PATH</code> overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra.</li>
|
||||
<li>Security/Tlon: harden Urbit URL fetching against SSRF by blocking private/internal hosts by default (opt-in: <code>channels.tlon.allowPrivateNetwork</code>). Thanks @p80n-sec.</li>
|
||||
<li>Security/Voice Call (Telnyx): require webhook signature verification when receiving inbound events; configs without <code>telnyx.publicKey</code> are now rejected unless <code>skipSignatureVerification</code> is enabled. Thanks @p80n-sec.</li>
|
||||
<li>Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec.</li>
|
||||
<li>Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.12/OpenClaw-2026.2.12.zip" length="22877692" type="application/octet-stream" sparkle:edSignature="TGylTM4/7Lab+qp1nuPeOAmEVV1WkafXUPub8ws0z/0mYfbVygRuiev+u3zdPjQWhLnGYTgRgKVyW+kB2+Q2BQ=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.14/OpenClaw-2026.2.14.zip" length="22914034" type="application/octet-stream" sparkle:edSignature="lR3nuq46/akMIN8RFDpMkTE0VOVoDVG53Xts589LryMGEtUvJxRQDtHBXfx7ZvToTq6CFKG+L5Kq/4rUspMoAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.9</title>
|
||||
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
|
||||
<title>2026.2.15</title>
|
||||
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9194</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.9</sparkle:shortVersionString>
|
||||
<sparkle:version>202602150</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.9</h2>
|
||||
<h3>Added</h3>
|
||||
<ul>
|
||||
<li>iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.</li>
|
||||
<li>Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.</li>
|
||||
<li>Plugins: device pairing + phone control plugins (Telegram <code>/pair</code>, iOS/Android node controls). (#11755) Thanks @mbelinky.</li>
|
||||
<li>Tools: add Grok (xAI) as a <code>web_search</code> provider. (#12419) Thanks @tmchow.</li>
|
||||
<li>Gateway: add agent management RPC methods for the web UI (<code>agents.create</code>, <code>agents.update</code>, <code>agents.delete</code>). (#11045) Thanks @advaitpaliwal.</li>
|
||||
<li>Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.</li>
|
||||
<li>Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.</li>
|
||||
<li>Paths: add <code>OPENCLAW_HOME</code> for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.</li>
|
||||
<li>Telegram: recover proactive sends when stale topic thread IDs are used by retrying without <code>message_thread_id</code>. (#11620)</li>
|
||||
<li>Telegram: render markdown spoilers with <code><tg-spoiler></code> HTML tags. (#11543) Thanks @ezhikkk.</li>
|
||||
<li>Telegram: truncate command registration to 100 entries to avoid <code>BOT_COMMANDS_TOO_MUCH</code> failures on startup. (#12356) Thanks @arosstale.</li>
|
||||
<li>Telegram: match DM <code>allowFrom</code> against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.</li>
|
||||
<li>Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).</li>
|
||||
<li>Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.</li>
|
||||
<li>Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.</li>
|
||||
<li>Tools/web_search: include provider-specific settings in the web search cache key, and pass <code>inlineCitations</code> for Grok. (#12419) Thanks @tmchow.</li>
|
||||
<li>Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.</li>
|
||||
<li>Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov.</li>
|
||||
<li>Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking.</li>
|
||||
<li>Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session <code>parentId</code> chain so agents can remember again. (#12283) Thanks @Takhoffman.</li>
|
||||
<li>Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.</li>
|
||||
<li>Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.</li>
|
||||
<li>Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.</li>
|
||||
<li>Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.</li>
|
||||
<li>Cron tool: recover flat params when LLM omits the <code>job</code> wrapper for add requests. (#12124) Thanks @tyler6204.</li>
|
||||
<li>Gateway/CLI: when <code>gateway.bind=lan</code>, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.</li>
|
||||
<li>Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.</li>
|
||||
<li>Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.</li>
|
||||
<li>Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.</li>
|
||||
<li>Config: clamp <code>maxTokens</code> to <code>contextWindow</code> to prevent invalid model configs. (#5516) Thanks @lailoo.</li>
|
||||
<li>Thinking: allow xhigh for <code>github-copilot/gpt-5.2-codex</code> and <code>github-copilot/gpt-5.2</code>. (#11646) Thanks @LatencyTDH.</li>
|
||||
<li>Discord: support forum/media thread-create starter messages, wire <code>message thread create --message</code>, and harden routing. (#10062) Thanks @jarvis89757.</li>
|
||||
<li>Paths: structurally resolve <code>OPENCLAW_HOME</code>-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.</li>
|
||||
<li>Memory: set Voyage embeddings <code>input_type</code> for improved retrieval. (#10818) Thanks @mcinteerj.</li>
|
||||
<li>Memory/QMD: reuse default model cache across agents instead of re-downloading per agent. (#12114) Thanks @tyler6204.</li>
|
||||
<li>Media understanding: recognize <code>.caf</code> audio attachments for transcription. (#10982) Thanks @succ985.</li>
|
||||
<li>State dir: honor <code>OPENCLAW_STATE_DIR</code> for default device identity and canvas storage paths. (#4824) Thanks @kossoy.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8900</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.3</h2>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: remove last <code>@ts-nocheck</code> from <code>bot-handlers.ts</code>, use Grammy types directly, deduplicate <code>StickerMetadata</code>. Zero <code>@ts-nocheck</code> remaining in <code>src/telegram/</code>. (#9206)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot-message.ts</code>, type deps via <code>Omit<BuildTelegramMessageContextParams></code>, widen <code>allMedia</code> to <code>TelegramMediaRef[]</code>. (#9180)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot.ts</code>, fix duplicate <code>bot.catch</code> error handler (Grammy overrides), remove dead reaction <code>message_thread_id</code> routing, harden sticker cache guard. (#9077)</li>
|
||||
<li>Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.</li>
|
||||
<li>Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.</li>
|
||||
<li>Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.</li>
|
||||
<li>Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.</li>
|
||||
<li>Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.</li>
|
||||
<li>Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.</li>
|
||||
<li>Cron: default isolated jobs to announce delivery; accept ISO 8601 <code>schedule.at</code> in tool inputs.</li>
|
||||
<li>Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and <code>atMs</code> inputs.</li>
|
||||
<li>Cron: delete one-shot jobs after success by default; add <code>--keep-after-run</code> for CLI.</li>
|
||||
<li>Cron: suppress messaging tools during announce delivery so summaries post consistently.</li>
|
||||
<li>Cron: avoid duplicate deliveries when isolated runs send messages directly.</li>
|
||||
<li>Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.</li>
|
||||
<li>Plugins: expose <code>llm_input</code> and <code>llm_output</code> hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.</li>
|
||||
<li>Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set <code>agents.defaults.subagents.maxSpawnDepth: 2</code> to allow sub-agents to spawn their own children. Includes <code>maxChildrenPerAgent</code> limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.</li>
|
||||
<li>Cron/Gateway: add finished-run webhook delivery toggle (<code>notify</code>) and dedicated webhook auth token support (<code>cron.webhookToken</code>) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.</li>
|
||||
<li>Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.</li>
|
||||
<li>TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.</li>
|
||||
<li>Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.</li>
|
||||
<li>Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.</li>
|
||||
<li>Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.</li>
|
||||
<li>Web UI: resolve header logo path when <code>gateway.controlUi.basePath</code> is set. (#7178) Thanks @Yeom-JinHo.</li>
|
||||
<li>Web UI: apply button styling to the new-messages indicator.</li>
|
||||
<li>Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.</li>
|
||||
<li>Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.</li>
|
||||
<li>Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.</li>
|
||||
<li>Security: gate <code>whatsapp_login</code> tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.</li>
|
||||
<li>Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.</li>
|
||||
<li>Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.</li>
|
||||
<li>Cron: accept epoch timestamps and 0ms durations in CLI <code>--at</code> parsing.</li>
|
||||
<li>Cron: reload store data when the store file is recreated or mtime changes.</li>
|
||||
<li>Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.</li>
|
||||
<li>Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.</li>
|
||||
<li>macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.</li>
|
||||
<li>Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.</li>
|
||||
<li>Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.</li>
|
||||
<li>Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.</li>
|
||||
<li>Gateway/Security: redact sensitive session/path details from <code>status</code> responses for non-admin clients; full details remain available to <code>operator.admin</code>. (#8590) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Control UI: preserve requested operator scopes for Control UI bypass modes (<code>allowInsecureAuth</code> / <code>dangerouslyDisableDeviceAuth</code>) when device identity is unavailable, preventing false <code>missing scope</code> failures on authenticated LAN/HTTP operator sessions. (#17682) Thanks @leafbird.</li>
|
||||
<li>LINE/Security: fail closed on webhook startup when channel token or channel secret is missing, and treat LINE accounts as configured only when both are present. (#17587) Thanks @davidahmann.</li>
|
||||
<li>Skills/Security: restrict <code>download</code> installer <code>targetDir</code> to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.</li>
|
||||
<li>Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.</li>
|
||||
<li>Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.</li>
|
||||
<li>Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving <code>passwordFile</code> path exemptions, preventing accidental redaction of non-secret config values like <code>maxTokens</code> and IRC password-file paths. (#16042) Thanks @akramcodez.</li>
|
||||
<li>Dev tooling: harden git <code>pre-commit</code> hook against option injection from malicious filenames (for example <code>--force</code>), preventing accidental staging of ignored files. Thanks @mrthankyou.</li>
|
||||
<li>Gateway/Agent: reject malformed <code>agent:</code>-prefixed session keys (for example, <code>agent:main</code>) in <code>agent</code> and <code>agent.identity.get</code> instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.</li>
|
||||
<li>Gateway/Chat: harden <code>chat.send</code> inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.</li>
|
||||
<li>Gateway/Send: return an actionable error when <code>send</code> targets internal-only <code>webchat</code>, guiding callers to use <code>chat.send</code> or a deliverable channel. (#15703) Thanks @rodrigouroz.</li>
|
||||
<li>Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing <code>script-src 'self'</code>. Thanks @Adam55A-code.</li>
|
||||
<li>Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.</li>
|
||||
<li>Agents/Sandbox: clarify system prompt path guidance so sandbox <code>bash/exec</code> uses container paths (for example <code>/workspace</code>) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.</li>
|
||||
<li>Agents/Context: apply configured model <code>contextWindow</code> overrides after provider discovery so <code>lookupContextTokens()</code> honors operator config values (including discovery-failure paths). (#17404) Thanks @michaelbship and @vignesh07.</li>
|
||||
<li>Agents/Context: derive <code>lookupContextTokens()</code> from auth-available model metadata and keep the smallest discovered context window for duplicate model ids, preventing cross-provider cache collisions from overestimating session context limits. (#17586) Thanks @githabideri and @vignesh07.</li>
|
||||
<li>Agents/OpenAI: force <code>store=true</code> for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.</li>
|
||||
<li>Memory/FTS: make <code>buildFtsQuery</code> Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.</li>
|
||||
<li>Auto-reply/Compaction: resolve <code>memory/YYYY-MM-DD.md</code> placeholders with timezone-aware runtime dates and append a <code>Current time:</code> line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.</li>
|
||||
<li>Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.</li>
|
||||
<li>Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.</li>
|
||||
<li>Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.</li>
|
||||
<li>Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.</li>
|
||||
<li>Subagents/Models: preserve <code>agents.defaults.model.fallbacks</code> when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.</li>
|
||||
<li>Telegram: omit <code>message_thread_id</code> for DM sends/draft previews and keep forum-topic handling (<code>id=1</code> general omitted, non-general kept), preventing DM failures with <code>400 Bad Request: message thread not found</code>. (#10942) Thanks @garnetlyx.</li>
|
||||
<li>Telegram: replace inbound <code><media:audio></code> placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.</li>
|
||||
<li>Telegram: retry inbound media <code>getFile</code> calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.</li>
|
||||
<li>Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.</li>
|
||||
<li>Discord: preserve channel session continuity when runtime payloads omit <code>message.channelId</code> by falling back to event/raw <code>channel_id</code> values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as <code>sessionKey=unknown</code>. (#17622) Thanks @shakkernerd.</li>
|
||||
<li>Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with <code>_2</code> suffixes. (#17365) Thanks @seewhyme.</li>
|
||||
<li>Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.</li>
|
||||
<li>Web UI/Agents: hide <code>BOOTSTRAP.md</code> in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.</li>
|
||||
<li>Auto-reply/WhatsApp/TUI/Web: when a final assistant message is <code>NO_REPLY</code> and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show <code>NO_REPLY</code> placeholders. (#7010) Thanks @Morrowind-Xie.</li>
|
||||
<li>Cron: infer <code>payload.kind="agentTurn"</code> for model-only <code>cron.update</code> payload patches, so partial agent-turn updates do not fail validation when <code>kind</code> is omitted. (#15664) Thanks @rodrigouroz.</li>
|
||||
<li>TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.</li>
|
||||
<li>TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.</li>
|
||||
<li>TUI: suppress false <code>(no output)</code> placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.</li>
|
||||
<li>TUI: preserve copy-sensitive long tokens (URLs/paths/file-like identifiers) during wrapping and overflow sanitization so wrapped output no longer inserts spaces that corrupt copy/paste values. (#17515, #17466, #17505) Thanks @abe238, @trevorpan, and @JasonCry.</li>
|
||||
<li>CLI/Build: make legacy daemon CLI compatibility shim generation tolerant of minimal tsdown daemon export sets, while preserving restart/register compatibility aliases and surfacing explicit errors for unavailable legacy daemon commands. Thanks @vignesh07.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.15/OpenClaw-2026.2.15.zip" length="22896513" type="application/octet-stream" sparkle:edSignature="MLGsd2NeHXFRH1Or0bFQnAjqfuuJDuhl1mvKFIqTQcRvwbeyvOyyLXrqSbmaOgJR3wBQBKLs6jYQ9dQ/3R8RCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.13</title>
|
||||
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9846</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.13</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.13</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.</li>
|
||||
<li>Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.</li>
|
||||
<li>Slack/Plugins: add thread-ownership outbound gating via <code>message_sending</code> hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.</li>
|
||||
<li>Agents: add synthetic catalog support for <code>hf:zai-org/GLM-5</code>. (#15867) Thanks @battman21.</li>
|
||||
<li>Skills: remove duplicate <code>local-places</code> Google Places skill/proxy and keep <code>goplaces</code> as the single supported Google Places path.</li>
|
||||
<li>Agents: add pre-prompt context diagnostics (<code>messages</code>, <code>systemPromptChars</code>, <code>promptChars</code>, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.</li>
|
||||
<li>Auto-reply/Threading: auto-inject implicit reply threading so <code>replyToMode</code> works without requiring model-emitted <code>[[reply_to_current]]</code>, while preserving <code>replyToMode: "off"</code> behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under <code>replyToMode: "first"</code>. (#14976) Thanks @Diaspar4u.</li>
|
||||
<li>Outbound/Threading: pass <code>replyTo</code> and <code>threadId</code> from <code>message send</code> tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.</li>
|
||||
<li>Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.</li>
|
||||
<li>Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.</li>
|
||||
<li>Web UI: add <code>img</code> to DOMPurify allowed tags and <code>src</code>/<code>alt</code> to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.</li>
|
||||
<li>Telegram/Matrix: treat MP3 and M4A (including <code>audio/mp4</code>) as voice-compatible for <code>asVoice</code> routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.</li>
|
||||
<li>WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending <code>"file"</code>. (#15594) Thanks @TsekaLuk.</li>
|
||||
<li>Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.</li>
|
||||
<li>Telegram: scope skill commands to the resolved agent for default accounts so <code>setMyCommands</code> no longer triggers <code>BOT_COMMANDS_TOO_MUCH</code> when multiple agents are configured. (#15599)</li>
|
||||
<li>Discord: avoid misrouting numeric guild allowlist entries to <code>/channels/<guildId></code> by prefixing guild-only inputs with <code>guild:</code> during resolution. (#12326) Thanks @headswim.</li>
|
||||
<li>MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (<code>29:...</code>, <code>8:orgid:...</code>) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.</li>
|
||||
<li>Media: classify <code>text/*</code> MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.</li>
|
||||
<li>Inbound/Web UI: preserve literal <code>\n</code> sequences when normalizing inbound text so Windows paths like <code>C:\\Work\\nxxx\\README.md</code> are not corrupted. (#11547) Thanks @mcaxtr.</li>
|
||||
<li>TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.</li>
|
||||
<li>Providers/MiniMax: switch implicit MiniMax API-key provider from <code>openai-completions</code> to <code>anthropic-messages</code> with the correct Anthropic-compatible base URL, fixing <code>invalid role: developer (2013)</code> errors on MiniMax M2.5. (#15275) Thanks @lailoo.</li>
|
||||
<li>Ollama/Agents: use resolved model/provider base URLs for native <code>/api/chat</code> streaming (including aliased providers), normalize <code>/v1</code> endpoints, and forward abort + <code>maxTokens</code> stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.</li>
|
||||
<li>OpenAI Codex/Spark: implement end-to-end <code>gpt-5.3-codex-spark</code> support across fallback/thinking/model resolution and <code>models list</code> forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.</li>
|
||||
<li>Agents/Codex: allow <code>gpt-5.3-codex-spark</code> in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.</li>
|
||||
<li>Models/Codex: resolve configured <code>openai-codex/gpt-5.3-codex-spark</code> through forward-compat fallback during <code>models list</code>, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.</li>
|
||||
<li>OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into <code>pi</code> <code>auth.json</code> so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.</li>
|
||||
<li>Auth/OpenAI Codex: share OAuth login handling across onboarding and <code>models auth login --provider openai-codex</code>, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.</li>
|
||||
<li>Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.</li>
|
||||
<li>Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (<code>tokenProvider=huggingface</code> with <code>authChoice=apiKey</code>) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.</li>
|
||||
<li>Onboarding/CLI: restore terminal state without resuming paused <code>stdin</code>, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.</li>
|
||||
<li>Signal/Install: auto-install <code>signal-cli</code> via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary <code>Exec format error</code> failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.</li>
|
||||
<li>macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.</li>
|
||||
<li>Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.</li>
|
||||
<li>Discord/Agents: apply channel/group <code>historyLimit</code> during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.</li>
|
||||
<li>Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.</li>
|
||||
<li>Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.</li>
|
||||
<li>Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.</li>
|
||||
<li>Heartbeat: allow explicit wake (<code>wake</code>) and hook wake (<code>hook:*</code>) reasons to run even when <code>HEARTBEAT.md</code> is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.</li>
|
||||
<li>Auto-reply/Heartbeat: strip sentence-ending <code>HEARTBEAT_OK</code> tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.</li>
|
||||
<li>Agents/Heartbeat: stop auto-creating <code>HEARTBEAT.md</code> during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with <code>Session file path must be within sessions directory</code>. (#15141) Thanks @Goldenmonstew.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.</li>
|
||||
<li>Sessions: archive previous transcript files on <code>/new</code> and <code>/reset</code> session resets (including gateway <code>sessions.reset</code>) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.</li>
|
||||
<li>Status/Sessions: stop clamping derived <code>totalTokens</code> to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.</li>
|
||||
<li>CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid <code>source <(openclaw completion ...)</code> corruption. (#15481) Thanks @arosstale.</li>
|
||||
<li>CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.</li>
|
||||
<li>Security/Gateway + ACP: block high-risk tools (<code>sessions_spawn</code>, <code>sessions_send</code>, <code>gateway</code>, <code>whatsapp_login</code>) from HTTP <code>/tools/invoke</code> by default with <code>gateway.tools.{allow,deny}</code> overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting <code>allow_always</code>/<code>reject_always</code>. (#15390) Thanks @aether-ai-agent.</li>
|
||||
<li>Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.</li>
|
||||
<li>Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.</li>
|
||||
<li>Security/Browser: constrain <code>POST /trace/stop</code>, <code>POST /wait/download</code>, and <code>POST /download</code> output paths to OpenClaw temp roots and reject traversal/escape paths.</li>
|
||||
<li>Security/Canvas: serve A2UI assets via the shared safe-open path (<code>openFileWithinRoot</code>) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.</li>
|
||||
<li>Security/WhatsApp: enforce <code>0o600</code> on <code>creds.json</code> and <code>creds.json.bak</code> on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.</li>
|
||||
<li>Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective <code>gateway.nodes.denyCommands</code> entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.</li>
|
||||
<li>Security/Audit: distinguish external webhooks (<code>hooks.enabled</code>) from internal hooks (<code>hooks.internal.enabled</code>) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.</li>
|
||||
<li>Security/Onboarding: clarify multi-user DM isolation remediation with explicit <code>openclaw config set session.dmScope ...</code> commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.</li>
|
||||
<li>Agents/Nodes: harden node exec approval decision handling in the <code>nodes</code> tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.</li>
|
||||
<li>Android/Nodes: harden <code>app.update</code> by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.</li>
|
||||
<li>Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.</li>
|
||||
<li>Exec/Allowlist: allow multiline heredoc bodies (<code><<</code>, <code><<-</code>) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.</li>
|
||||
<li>Config: preserve <code>${VAR}</code> env references when writing config files so <code>openclaw config set/apply/patch</code> does not persist secrets to disk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving <code>${VAR}</code> refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.</li>
|
||||
<li>Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.</li>
|
||||
<li>Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.</li>
|
||||
<li>Config: accept <code>$schema</code> key in config file so JSON Schema editor tooling works without validation errors. (#14998)</li>
|
||||
<li>Gateway/Tools Invoke: sanitize <code>/tools/invoke</code> execution failures while preserving <code>400</code> for tool input errors and returning <code>500</code> for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.</li>
|
||||
<li>Gateway/Hooks: preserve <code>408</code> for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.</li>
|
||||
<li>Plugins/Hooks: fire <code>before_tool_call</code> hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.</li>
|
||||
<li>Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.</li>
|
||||
<li>Agents/Image tool: cap image-analysis completion <code>maxTokens</code> by model capability (<code>min(4096, model.maxTokens)</code>) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.</li>
|
||||
<li>Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent <code>tools.exec</code> overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.</li>
|
||||
<li>Gateway/Agents: stop injecting a phantom <code>main</code> agent into gateway agent listings when <code>agents.list</code> explicitly excludes it. (#11450) Thanks @arosstale.</li>
|
||||
<li>Process/Exec: avoid shell execution for <code>.exe</code> commands on Windows so env overrides work reliably in <code>runCommandWithTimeout</code>. Thanks @thewilloftheshadow.</li>
|
||||
<li>Daemon/Windows: preserve literal backslashes in <code>gateway.cmd</code> command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.</li>
|
||||
<li>Sandbox: pass configured <code>sandbox.docker.env</code> variables to sandbox containers at <code>docker create</code> time. (#15138) Thanks @stevebot-alive.</li>
|
||||
<li>Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.</li>
|
||||
<li>Cron: add regression coverage for announce-mode isolated jobs so runs that already report <code>delivered: true</code> do not enqueue duplicate main-session relays, including delivery configs where <code>mode</code> is omitted and defaults to announce. (#15737) Thanks @brandonwise.</li>
|
||||
<li>Cron: honor <code>deleteAfterRun</code> in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.</li>
|
||||
<li>Web tools/web_fetch: prefer <code>text/markdown</code> responses for Cloudflare Markdown for Agents, add <code>cf-markdown</code> extraction for markdown bodies, and redact fetched URLs in <code>x-markdown-tokens</code> debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.</li>
|
||||
<li>Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.</li>
|
||||
<li>Memory: switch default local embedding model to the QAT <code>embeddinggemma-300m-qat-Q8_0</code> variant for better quality at the same footprint. (#15429) Thanks @azade-c.</li>
|
||||
<li>Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@ -21,8 +21,8 @@ android {
|
||||
applicationId = "ai.openclaw.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202602130
|
||||
versionName = "2026.2.13"
|
||||
versionCode = 202602160
|
||||
versionName = "2026.2.16"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
@ -63,7 +63,11 @@ android {
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf("IconLauncherShape")
|
||||
disable += setOf(
|
||||
"GradleDependency",
|
||||
"IconLauncherShape",
|
||||
"NewerVersionAvailable",
|
||||
)
|
||||
warningsAsErrors = true
|
||||
}
|
||||
|
||||
@ -121,6 +125,7 @@ dependencies {
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
|
||||
@ -25,6 +25,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val statusText: StateFlow<String> = runtime.statusText
|
||||
val serverName: StateFlow<String?> = runtime.serverName
|
||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
||||
@ -145,6 +146,14 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.disconnect()
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
runtime.acceptGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
runtime.declineGatewayTrustPrompt()
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import ai.openclaw.android.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.android.gateway.GatewayDiscovery
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.gateway.probeGatewayTlsFingerprint
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
@ -166,12 +167,20 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private lateinit var gatewayEventHandler: GatewayEventHandler
|
||||
|
||||
data class GatewayTrustPrompt(
|
||||
val endpoint: GatewayEndpoint,
|
||||
val fingerprintSha256: String,
|
||||
)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
|
||||
private val _pendingGatewayTrust = MutableStateFlow<GatewayTrustPrompt?>(null)
|
||||
val pendingGatewayTrust: StateFlow<GatewayTrustPrompt?> = _pendingGatewayTrust.asStateFlow()
|
||||
|
||||
private val _mainSessionKey = MutableStateFlow("main")
|
||||
val mainSessionKey: StateFlow<String> = _mainSessionKey.asStateFlow()
|
||||
|
||||
@ -405,8 +414,11 @@ class NodeRuntime(context: Context) {
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Persist the last discovered gateway (best-effort UX parity with iOS).
|
||||
prefs.setLastDiscoveredStableId(list.last().stableId)
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
@ -416,6 +428,12 @@ class NodeRuntime(context: Context) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
@ -425,6 +443,11 @@ class NodeRuntime(context: Context) {
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
}
|
||||
@ -520,17 +543,42 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
fun connect(endpoint: GatewayEndpoint) {
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
if (tls?.required == true && tls.expectedFingerprint.isNullOrBlank()) {
|
||||
// First-time TLS: capture fingerprint, ask user to verify out-of-band, then store and connect.
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
scope.launch {
|
||||
val fp = probeGatewayTlsFingerprint(endpoint.host, endpoint.port) ?: run {
|
||||
_statusText.value = "Failed: can't read TLS fingerprint"
|
||||
return@launch
|
||||
}
|
||||
_pendingGatewayTrust.value = GatewayTrustPrompt(endpoint = endpoint, fingerprintSha256 = fp)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
||||
}
|
||||
|
||||
fun acceptGatewayTrustPrompt() {
|
||||
val prompt = _pendingGatewayTrust.value ?: return
|
||||
_pendingGatewayTrust.value = null
|
||||
prefs.saveGatewayTlsFingerprint(prompt.endpoint.stableId, prompt.fingerprintSha256)
|
||||
connect(prompt.endpoint)
|
||||
}
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
_pendingGatewayTrust.value = null
|
||||
_statusText.value = "Offline"
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) ==
|
||||
@ -550,6 +598,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
fun disconnect() {
|
||||
connectedEndpoint = null
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
package ai.openclaw.android.gateway
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.InetSocketAddress
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Locale
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLParameters
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.SNIHostName
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
@ -59,13 +67,74 @@ fun buildGatewayTlsConfig(
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustManager), SecureRandom())
|
||||
val verifier =
|
||||
if (expected != null || params.allowTOFU) {
|
||||
// When pinning, we intentionally ignore hostname mismatch (service discovery often yields IPs).
|
||||
HostnameVerifier { _, _ -> true }
|
||||
} else {
|
||||
HttpsURLConnection.getDefaultHostnameVerifier()
|
||||
}
|
||||
return GatewayTlsConfig(
|
||||
sslSocketFactory = context.socketFactory,
|
||||
trustManager = trustManager,
|
||||
hostnameVerifier = HostnameVerifier { _, _ -> true },
|
||||
hostnameVerifier = verifier,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun probeGatewayTlsFingerprint(
|
||||
host: String,
|
||||
port: Int,
|
||||
timeoutMs: Int = 3_000,
|
||||
): String? {
|
||||
val trimmedHost = host.trim()
|
||||
if (trimmedHost.isEmpty()) return null
|
||||
if (port !in 1..65535) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
val trustAll =
|
||||
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
|
||||
object : X509TrustManager {
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
@SuppressLint("TrustAllX509TrustManager")
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
|
||||
val context = SSLContext.getInstance("TLS")
|
||||
context.init(null, arrayOf(trustAll), SecureRandom())
|
||||
|
||||
val socket = (context.socketFactory.createSocket() as SSLSocket)
|
||||
try {
|
||||
socket.soTimeout = timeoutMs
|
||||
socket.connect(InetSocketAddress(trimmedHost, port), timeoutMs)
|
||||
|
||||
// Best-effort SNI for hostnames (avoid crashing on IP literals).
|
||||
try {
|
||||
if (trimmedHost.any { it.isLetter() }) {
|
||||
val params = SSLParameters()
|
||||
params.serverNames = listOf(SNIHostName(trimmedHost))
|
||||
socket.sslParameters = params
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
socket.startHandshake()
|
||||
val cert = socket.session.peerCertificates.firstOrNull() as? X509Certificate ?: return@withContext null
|
||||
sha256Hex(cert.encoded)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
} finally {
|
||||
try {
|
||||
socket.close()
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultTrustManager(): X509TrustManager {
|
||||
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
factory.init(null as java.security.KeyStore?)
|
||||
@ -78,7 +147,7 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(data)
|
||||
val out = StringBuilder(digest.size * 2)
|
||||
for (byte in digest) {
|
||||
out.append(String.format("%02x", byte))
|
||||
out.append(String.format(Locale.US, "%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
@ -86,5 +155,5 @@ private fun sha256Hex(data: ByteArray): String {
|
||||
private fun normalizeFingerprint(raw: String): String {
|
||||
val stripped = raw.trim()
|
||||
.replace(Regex("^sha-?256\\s*:?\\s*", RegexOption.IGNORE_CASE), "")
|
||||
return stripped.lowercase().filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
return stripped.lowercase(Locale.US).filter { it in '0'..'9' || it in 'a'..'f' }
|
||||
}
|
||||
|
||||
@ -187,11 +187,11 @@ class AppUpdateHandler(
|
||||
lastNotifUpdate = now
|
||||
if (contentLength > 0) {
|
||||
val pct = ((totalBytes * 100) / contentLength).toInt()
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
val totalMb = String.format("%.1f", contentLength / 1048576.0)
|
||||
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
|
||||
val totalMb = String.format(Locale.US, "%.1f", contentLength / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
|
||||
} else {
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
val mb = String.format(Locale.US, "%.1f", totalBytes / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
|
||||
}
|
||||
}
|
||||
@ -239,13 +239,15 @@ class AppUpdateHandler(
|
||||
// Use PackageInstaller session API — works from background on API 34+
|
||||
// The system handles showing the install confirmation dialog
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Installing Update...")
|
||||
|
||||
notifManager.notify(
|
||||
notifId,
|
||||
android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Installing Update...")
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("${String.format("%.1f", totalBytes / 1048576.0)} MB downloaded")
|
||||
.build())
|
||||
.setContentText("${String.format(Locale.US, "%.1f", totalBytes / 1048576.0)} MB downloaded")
|
||||
.build(),
|
||||
)
|
||||
|
||||
val installer = appContext.packageManager.packageInstaller
|
||||
val params = android.content.pm.PackageInstaller.SessionParams(
|
||||
|
||||
@ -26,6 +26,59 @@ class ConnectionManager(
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
companion object {
|
||||
internal fun resolveTlsParamsForEndpoint(
|
||||
endpoint: GatewayEndpoint,
|
||||
storedFingerprint: String?,
|
||||
manualTlsEnabled: Boolean,
|
||||
): GatewayTlsParams? {
|
||||
val stableId = endpoint.stableId
|
||||
val stored = storedFingerprint?.trim().takeIf { !it.isNullOrEmpty() }
|
||||
val isManual = stableId.startsWith("manual|")
|
||||
|
||||
if (isManual) {
|
||||
if (!manualTlsEnabled) return null
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
// Prefer stored pins. Never let discovery-provided TXT override a stored fingerprint.
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
if (hinted) {
|
||||
// TXT is unauthenticated. Do not treat the advertised fingerprint as authoritative.
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = null,
|
||||
allowTOFU = false,
|
||||
stableId = stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
@ -130,37 +183,6 @@ class ConnectionManager(
|
||||
|
||||
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (manual) {
|
||||
if (!manualTls()) return null
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (hinted) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
return resolveTlsParamsForEndpoint(endpoint, storedFingerprint = stored, manualTlsEnabled = manualTls())
|
||||
}
|
||||
}
|
||||
|
||||
@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@ -42,6 +43,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@ -89,6 +91,7 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val gateways by viewModel.gateways.collectAsState()
|
||||
val discoveryStatusText by viewModel.discoveryStatusText.collectAsState()
|
||||
val pendingTrust by viewModel.pendingGatewayTrust.collectAsState()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
val (wakeWordsText, setWakeWordsText) = remember { mutableStateOf("") }
|
||||
@ -112,6 +115,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingTrust != null) {
|
||||
val prompt = pendingTrust!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
|
||||
title = { Text("Trust this gateway?") },
|
||||
text = {
|
||||
Text(
|
||||
"First-time TLS connection.\n\n" +
|
||||
"Verify this SHA-256 fingerprint out-of-band before trusting:\n" +
|
||||
prompt.fingerprintSha256,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) {
|
||||
Text("Trust and connect")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(wakeWords) { setWakeWordsText(wakeWords.joinToString(", ")) }
|
||||
val commitWakeWords = {
|
||||
val parsed = WakeWords.parseIfChanged(wakeWordsText, wakeWords)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionManagerTest {
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_prefersStoredPinOverAdvertisedFingerprint() {
|
||||
val endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "_openclaw-gw._tcp.|local.|Test",
|
||||
name = "Test",
|
||||
host = "10.0.0.2",
|
||||
port = 18789,
|
||||
tlsEnabled = true,
|
||||
tlsFingerprintSha256 = "attacker",
|
||||
)
|
||||
|
||||
val params =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = "legit",
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertEquals("legit", params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_doesNotTrustAdvertisedFingerprintWhenNoStoredPin() {
|
||||
val endpoint =
|
||||
GatewayEndpoint(
|
||||
stableId = "_openclaw-gw._tcp.|local.|Test",
|
||||
name = "Test",
|
||||
host = "10.0.0.2",
|
||||
port = 18789,
|
||||
tlsEnabled = true,
|
||||
tlsFingerprintSha256 = "attacker",
|
||||
)
|
||||
|
||||
val params =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
|
||||
assertNull(params?.expectedFingerprint)
|
||||
assertEquals(false, params?.allowTOFU)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveTlsParamsForEndpoint_manualRespectsManualTlsToggle() {
|
||||
val endpoint = GatewayEndpoint.manual(host = "example.com", port = 443)
|
||||
|
||||
val off =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = false,
|
||||
)
|
||||
assertNull(off)
|
||||
|
||||
val on =
|
||||
ConnectionManager.resolveTlsParamsForEndpoint(
|
||||
endpoint,
|
||||
storedFingerprint = null,
|
||||
manualTlsEnabled = true,
|
||||
)
|
||||
assertNull(on?.expectedFingerprint)
|
||||
assertEquals(false, on?.allowTOFU)
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ final class CalendarService: CalendarServicing {
|
||||
func events(params: OpenClawCalendarEventsParams) async throws -> OpenClawCalendarEventsPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@ -39,7 +39,7 @@ final class CalendarService: CalendarServicing {
|
||||
func add(params: OpenClawCalendarAddParams) async throws -> OpenClawCalendarAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .event)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Calendar", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "CALENDAR_PERMISSION_REQUIRED: grant Calendar permission",
|
||||
@ -95,38 +95,6 @@ final class CalendarService: CalendarServicing {
|
||||
return OpenClawCalendarAddPayload(event: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveCalendar(
|
||||
store: EKEventStore,
|
||||
calendarId: String?,
|
||||
|
||||
@ -93,14 +93,10 @@ actor CameraController {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
let res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
quality: quality)
|
||||
|
||||
return (
|
||||
format: format.rawValue,
|
||||
@ -335,8 +331,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
@ -364,8 +360,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return }
|
||||
self.didResume = true
|
||||
|
||||
@ -2,8 +2,10 @@ import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
init(gateway: GatewayNodeSession) {
|
||||
@ -33,10 +35,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
// Operator clients receive chat events without node-style subscriptions.
|
||||
// (chat.subscribe is a node event, not an operator RPC method.)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
@ -54,6 +54,7 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
@ -72,8 +73,15 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
do {
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
return decoded
|
||||
} catch {
|
||||
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
|
||||
34
apps/ios/Sources/EventKit/EventKitAuthorization.swift
Normal file
34
apps/ios/Sources/EventKit/EventKitAuthorization.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import EventKit
|
||||
|
||||
enum EventKitAuthorization {
|
||||
static func allowsRead(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func allowsWrite(status: EKAuthorizationStatus) -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import AVFoundation
|
||||
import Contacts
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import CryptoKit
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
@ -9,6 +10,7 @@ import Network
|
||||
import Observation
|
||||
import Photos
|
||||
import ReplayKit
|
||||
import Security
|
||||
import Speech
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
@ -16,13 +18,27 @@ import UIKit
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectionController {
|
||||
struct TrustPrompt: Identifiable, Equatable {
|
||||
let stableID: String
|
||||
let gatewayName: String
|
||||
let host: String
|
||||
let port: Int
|
||||
let fingerprintSha256: String
|
||||
let isManual: Bool
|
||||
|
||||
var id: String { self.stableID }
|
||||
}
|
||||
|
||||
private(set) var gateways: [GatewayDiscoveryModel.DiscoveredGateway] = []
|
||||
private(set) var discoveryStatusText: String = "Idle"
|
||||
private(set) var discoveryDebugLog: [GatewayDiscoveryModel.DebugLogEntry] = []
|
||||
private(set) var pendingTrustPrompt: TrustPrompt?
|
||||
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: (url: URL, stableID: String, isManual: Bool)?
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
self.appModel = appModel
|
||||
@ -56,31 +72,89 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
func allowAutoConnectAgain() {
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
self.updateFromDiscovery()
|
||||
}
|
||||
|
||||
|
||||
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if instanceId.isEmpty {
|
||||
return "Missing instanceId (node.instanceId). Try restarting the app."
|
||||
}
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
|
||||
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
||||
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else {
|
||||
return "Failed to resolve the discovered gateway endpoint."
|
||||
}
|
||||
|
||||
let stableID = gateway.stableID
|
||||
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
|
||||
let tlsRequired = true
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
guard gateway.tlsEnabled || stored != nil else {
|
||||
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
|
||||
}
|
||||
|
||||
if tlsRequired, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
||||
else { return "Failed to build TLS URL for trust verification." }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||
return "Failed to read TLS fingerprint from discovered gateway."
|
||||
}
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
gatewayName: gateway.name,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
fingerprintSha256: fp,
|
||||
isManual: false)
|
||||
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
||||
return nil
|
||||
}
|
||||
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: tlsParams?.required == true,
|
||||
stableID: gateway.stableID)
|
||||
else { return "Failed to build discovered gateway URL." }
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return nil
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
_ = await self.connectWithDiagnostics(gateway)
|
||||
}
|
||||
|
||||
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
||||
@ -92,19 +166,34 @@ final class GatewayConnectionController {
|
||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||
else { return }
|
||||
let stableID = self.manualStableID(host: host, port: resolvedPort)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: host))
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
if resolvedUseTLS, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: host, port: resolvedPort, useTLS: true) else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: true)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
gatewayName: "\(host):\(resolvedPort)",
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
fingerprintSha256: fp,
|
||||
isManual: true)
|
||||
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
||||
return
|
||||
}
|
||||
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: host,
|
||||
port: resolvedPort,
|
||||
useTLS: tlsParams?.required == true,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil,
|
||||
stableID: stableID)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
@ -117,36 +206,63 @@ final class GatewayConnectionController {
|
||||
|
||||
func connectLastKnown() async {
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
await self.connectManual(host: host, port: port, useTLS: useTLS)
|
||||
case let .discovered(stableID, _):
|
||||
guard let gateway = self.gateways.first(where: { $0.stableID == stableID }) else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
}
|
||||
|
||||
func clearPendingTrustPrompt() {
|
||||
self.pendingTrustPrompt = nil
|
||||
self.pendingTrustConnect = nil
|
||||
}
|
||||
|
||||
func acceptPendingTrustPrompt() async {
|
||||
guard let pending = self.pendingTrustConnect,
|
||||
let prompt = self.pendingTrustPrompt,
|
||||
pending.stableID == prompt.stableID
|
||||
else { return }
|
||||
|
||||
GatewayTLSStore.saveFingerprint(prompt.fingerprintSha256, stableID: pending.stableID)
|
||||
self.clearPendingTrustPrompt()
|
||||
|
||||
if pending.isManual {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: prompt.host,
|
||||
port: prompt.port,
|
||||
useTLS: true,
|
||||
stableID: pending.stableID)
|
||||
} else {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: pending.stableID, useTLS: true)
|
||||
}
|
||||
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
let resolvedUseTLS = last.useTLS
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: last.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: last.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if resolvedUseTLS != last.useTLS {
|
||||
GatewaySettingsStore.saveLastGatewayConnection(
|
||||
host: last.host,
|
||||
port: last.port,
|
||||
useTLS: resolvedUseTLS,
|
||||
stableID: last.stableID)
|
||||
}
|
||||
let tlsParams = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: prompt.fingerprintSha256,
|
||||
allowTOFU: false,
|
||||
storeKey: pending.stableID)
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: last.stableID,
|
||||
url: pending.url,
|
||||
gatewayStableID: pending.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
}
|
||||
|
||||
func declinePendingTrustPrompt() {
|
||||
self.clearPendingTrustPrompt()
|
||||
self.appModel?.gatewayStatusText = "Offline"
|
||||
}
|
||||
|
||||
private func updateFromDiscovery() {
|
||||
let newGateways = self.discovery.gateways
|
||||
self.gateways = newGateways
|
||||
@ -223,25 +339,30 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
let resolvedUseTLS = lastKnown.useTLS || self.shouldForceTLS(host: lastKnown.host)
|
||||
let tlsParams = self.resolveManualTLSParams(
|
||||
stableID: lastKnown.stableID,
|
||||
tlsEnabled: resolvedUseTLS,
|
||||
allowTOFUReset: self.shouldForceTLS(host: lastKnown.host))
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: lastKnown.host,
|
||||
port: lastKnown.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
if case let .manual(host, port, useTLS, stableID) = lastKnown {
|
||||
let resolvedUseTLS = useTLS || self.shouldForceTLS(host: host)
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let tlsParams = stored.map { fp in
|
||||
GatewayTLSParams(required: true, expectedFingerprint: fp, allowTOFU: false, storeKey: stableID)
|
||||
}
|
||||
guard let url = self.buildGatewayURL(
|
||||
host: host,
|
||||
port: port,
|
||||
useTLS: resolvedUseTLS && tlsParams != nil)
|
||||
else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: lastKnown.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard tlsParams != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let preferredStableID = defaults.string(forKey: "gateway.preferredStableID")?
|
||||
@ -254,36 +375,26 @@ final class GatewayConnectionController {
|
||||
self.gateways.contains(where: { $0.stableID == id })
|
||||
}) {
|
||||
guard let target = self.gateways.first(where: { $0.stableID == targetStableID }) else { return }
|
||||
guard let host = self.resolveGatewayHost(target) else { return }
|
||||
let port = target.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: target)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard GatewayTLSStore.loadFingerprint(stableID: target.stableID) != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: target.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if self.gateways.count == 1, let gateway = self.gateways.first {
|
||||
guard let host = self.resolveGatewayHost(gateway) else { return }
|
||||
let port = gateway.gatewayPort ?? 18789
|
||||
let tlsParams = self.resolveDiscoveredTLSParams(gateway: gateway)
|
||||
guard let url = self.buildGatewayURL(host: host, port: port, useTLS: tlsParams?.required == true)
|
||||
else { return }
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
guard GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil else { return }
|
||||
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
url: url,
|
||||
gatewayStableID: gateway.stableID,
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -339,15 +450,27 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveDiscoveredTLSParams(gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams? {
|
||||
private func resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
let stableID = gateway.stableID
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil || stored != nil {
|
||||
// Never let unauthenticated discovery (TXT) override a stored pin.
|
||||
if let stored {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: gateway.tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if gateway.tlsEnabled || gateway.tlsFingerprintSha256 != nil {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
@ -364,21 +487,154 @@ final class GatewayConnectionController {
|
||||
return GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil || allowTOFUReset,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func resolveGatewayHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let tailnet = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines), !tailnet.isEmpty {
|
||||
return tailnet
|
||||
private func probeTLSFingerprint(url: URL) async -> String? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let probe = GatewayTLSFingerprintProbe(url: url, timeoutSeconds: 3) { fp in
|
||||
continuation.resume(returning: fp)
|
||||
}
|
||||
probe.start()
|
||||
}
|
||||
if let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines), !lanHost.isEmpty {
|
||||
return lanHost
|
||||
}
|
||||
|
||||
private func resolveServiceEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
guard case let .service(name, type, domain, _) = endpoint else { return nil }
|
||||
let key = "\(domain)|\(type)|\(name)"
|
||||
return await withCheckedContinuation { continuation in
|
||||
let resolver = GatewayServiceResolver(name: name, type: type, domain: domain) { [weak self] result in
|
||||
Task { @MainActor in
|
||||
self?.pendingServiceResolvers[key] = nil
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
}
|
||||
self.pendingServiceResolvers[key] = resolver
|
||||
resolver.start()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
return (host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBonjourServiceToHostPort(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0
|
||||
) async -> (host: String, port: Int)? {
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
// resume the continuation exactly once (timeout/cancel safe).
|
||||
@MainActor
|
||||
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||
private let service: NetService
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
private var finished = false
|
||||
|
||||
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||
self.cont = cont
|
||||
self.service = service
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(timeoutSeconds: TimeInterval) {
|
||||
self.service.delegate = self
|
||||
self.service.schedule(in: .main, forMode: .default)
|
||||
|
||||
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||
// callbacks never arrive (e.g. local network permission issues).
|
||||
self.timeoutTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
self.service.resolve(withTimeout: timeoutSeconds)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
self.finish(Self.extractHostPort(sender))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
private func finish(_ result: (host: String, port: Int)?) {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .default)
|
||||
|
||||
let c = self.cont
|
||||
self.cont = nil
|
||||
c?.resume(returning: result)
|
||||
}
|
||||
|
||||
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||
let port = svc.port
|
||||
|
||||
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
let rc = getnameinfo(
|
||||
base.assumingMemoryBound(to: sockaddr.self),
|
||||
socklen_t(ptr.count),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
let service = NetService(domain: domain, type: type, name: name)
|
||||
let resolver = Resolver(cont: cont, service: service)
|
||||
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
@ -662,5 +918,84 @@ extension GatewayConnectionController {
|
||||
func _test_triggerAutoConnect() {
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func _test_didAutoConnect() -> Bool {
|
||||
self.didAutoConnect
|
||||
}
|
||||
|
||||
func _test_resolveDiscoveredTLSParams(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
allowTOFU: Bool) -> GatewayTLSParams?
|
||||
{
|
||||
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate {
|
||||
private let url: URL
|
||||
private let timeoutSeconds: Double
|
||||
private let onComplete: (String?) -> Void
|
||||
private var didFinish = false
|
||||
private var session: URLSession?
|
||||
private var task: URLSessionWebSocketTask?
|
||||
|
||||
init(url: URL, timeoutSeconds: Double, onComplete: @escaping (String?) -> Void) {
|
||||
self.url = url
|
||||
self.timeoutSeconds = timeoutSeconds
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
func start() {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = self.timeoutSeconds
|
||||
config.timeoutIntervalForResource = self.timeoutSeconds
|
||||
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
self.session = session
|
||||
let task = session.webSocketTask(with: self.url)
|
||||
self.task = task
|
||||
task.resume()
|
||||
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + self.timeoutSeconds) { [weak self] in
|
||||
self?.finish(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let fp = GatewayTLSFingerprintProbe.certificateFingerprint(trust)
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
self.finish(fp)
|
||||
}
|
||||
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self)
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.session?.invalidateAndCancel()
|
||||
self.onComplete(fingerprint)
|
||||
}
|
||||
|
||||
private static func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
|
||||
let cert = chain.first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
|
||||
71
apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
Normal file
71
apps/ios/Sources/Gateway/GatewayConnectionIssue.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayConnectionIssue: Equatable {
|
||||
case none
|
||||
case tokenMissing
|
||||
case unauthorized
|
||||
case pairingRequired(requestId: String?)
|
||||
case network
|
||||
case unknown(String)
|
||||
|
||||
var requestId: String? {
|
||||
if case let .pairingRequired(requestId) = self {
|
||||
return requestId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var needsAuthToken: Bool {
|
||||
switch self {
|
||||
case .tokenMissing, .unauthorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var needsPairing: Bool {
|
||||
if case .pairingRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func detect(from statusText: String) -> Self {
|
||||
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .none }
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
|
||||
return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
|
||||
}
|
||||
if lower.contains("gateway token missing") {
|
||||
return .tokenMissing
|
||||
}
|
||||
if lower.contains("unauthorized") {
|
||||
return .unauthorized
|
||||
}
|
||||
if lower.contains("connection refused") ||
|
||||
lower.contains("timed out") ||
|
||||
lower.contains("network is unreachable") ||
|
||||
lower.contains("cannot find host") ||
|
||||
lower.contains("could not connect")
|
||||
{
|
||||
return .network
|
||||
}
|
||||
if lower.hasPrefix("gateway error:") {
|
||||
return .unknown(trimmed)
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
private static func extractRequestId(from statusText: String) -> String? {
|
||||
let marker = "requestId:"
|
||||
guard let range = statusText.range(of: marker) else { return nil }
|
||||
let suffix = statusText[range.upperBound...]
|
||||
let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let end = trimmed.firstIndex(where: { ch in
|
||||
ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
|
||||
}) ?? trimmed.endIndex
|
||||
let id = String(trimmed[..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return id.isEmpty ? nil : id
|
||||
}
|
||||
}
|
||||
@ -136,43 +136,9 @@ final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
let states = Array(self.statesByDomain.values)
|
||||
if states.isEmpty {
|
||||
self.statusText = self.browsers.isEmpty ? "Idle" : "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
if let failed = states.first(where: { state in
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .failed(err) = failed {
|
||||
self.statusText = "Failed: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let waiting = states.first(where: { state in
|
||||
if case .waiting = state { return true }
|
||||
return false
|
||||
}) {
|
||||
if case let .waiting(err) = waiting {
|
||||
self.statusText = "Waiting: \(err)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .ready = $0 { true } else { false } }) {
|
||||
self.statusText = "Searching…"
|
||||
return
|
||||
}
|
||||
|
||||
if states.contains(where: { if case .setup = $0 { true } else { false } }) {
|
||||
self.statusText = "Setup"
|
||||
return
|
||||
}
|
||||
|
||||
self.statusText = "Searching…"
|
||||
self.statusText = GatewayDiscoveryStatusText.make(
|
||||
states: Array(self.statesByDomain.values),
|
||||
hasBrowsers: !self.browsers.isEmpty)
|
||||
}
|
||||
|
||||
private static func prettyState(_ state: NWBrowser.State) -> String {
|
||||
|
||||
113
apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
Normal file
113
apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayQuickSetupSheet: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var connecting: Bool = false
|
||||
@State private var connectError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Connect to a Gateway?")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let candidate = self.bestCandidate {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: candidate.name)
|
||||
.font(.headline)
|
||||
Text(verbatim: candidate.debugID)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
|
||||
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
Button {
|
||||
self.connectError = nil
|
||||
self.connecting = true
|
||||
Task {
|
||||
let err = await self.gatewayController.connectWithDiagnostics(candidate)
|
||||
await MainActor.run {
|
||||
self.connecting = false
|
||||
self.connectError = err
|
||||
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if self.connecting {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connecting)
|
||||
|
||||
if let connectError {
|
||||
Text(connectError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Not now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connecting)
|
||||
|
||||
Toggle("Don’t show this again", isOn: self.$quickSetupDismissed)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Quick Setup")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.quickSetupDismissed = true
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
// Prefer whatever discovery says is first; the list is already name-sorted.
|
||||
self.gatewayController.gateways.first
|
||||
}
|
||||
}
|
||||
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
55
apps/ios/Sources/Gateway/GatewayServiceResolver.swift
Normal file
@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
|
||||
// NetService-based resolver for Bonjour services.
|
||||
// Used to resolve the service endpoint (SRV + A/AAAA) without trusting TXT for routing.
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: ((host: String, port: Int)?) -> Void
|
||||
private var didFinish = false
|
||||
|
||||
init(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
completion: @escaping ((host: String, port: Int)?) -> Void)
|
||||
{
|
||||
self.service = NetService(domain: domain, type: type, name: name)
|
||||
self.completion = completion
|
||||
super.init()
|
||||
self.service.delegate = self
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
self.service.schedule(in: .main, forMode: .common)
|
||||
self.service.resolve(withTimeout: timeout)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let host = Self.normalizeHost(sender.hostName)
|
||||
let port = sender.port
|
||||
guard let host, !host.isEmpty, port > 0 else {
|
||||
self.finish(result: nil)
|
||||
return
|
||||
}
|
||||
self.finish(result: (host: host, port: port))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
self.finish(result: nil)
|
||||
}
|
||||
|
||||
private func finish(result: ((host: String, port: Int))?) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .common)
|
||||
self.completion(result)
|
||||
}
|
||||
|
||||
private static func normalizeHost(_ raw: String?) -> String? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import os
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
@ -13,6 +14,7 @@ enum GatewaySettingsStore {
|
||||
private static let manualPortDefaultsKey = "gateway.manual.port"
|
||||
private static let manualTlsDefaultsKey = "gateway.manual.tls"
|
||||
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
|
||||
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
|
||||
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
|
||||
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
|
||||
private static let lastGatewayTlsDefaultsKey = "gateway.last.tls"
|
||||
@ -23,6 +25,7 @@ enum GatewaySettingsStore {
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
||||
private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey"
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
self.ensureStableInstanceID()
|
||||
@ -114,25 +117,113 @@ enum GatewaySettingsStore {
|
||||
account: self.gatewayPasswordAccount(instanceId: instanceId))
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnection(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
enum LastGatewayConnection: Equatable {
|
||||
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
|
||||
case discovered(stableID: String, useTLS: Bool)
|
||||
|
||||
var stableID: String {
|
||||
switch self {
|
||||
case let .manual(_, _, _, stableID):
|
||||
return stableID
|
||||
case let .discovered(stableID, _):
|
||||
return stableID
|
||||
}
|
||||
}
|
||||
|
||||
var useTLS: Bool {
|
||||
switch self {
|
||||
case let .manual(_, _, useTLS, _):
|
||||
return useTLS
|
||||
case let .discovered(_, useTLS):
|
||||
return useTLS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LastGatewayKind: String {
|
||||
case manual
|
||||
case discovered
|
||||
}
|
||||
|
||||
static func loadTalkElevenLabsApiKey() -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.talkService,
|
||||
account: self.talkElevenLabsApiKeyAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
|
||||
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(
|
||||
trimmed,
|
||||
service: self.talkService,
|
||||
account: self.talkElevenLabsApiKeyAccount)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> (host: String, port: Int, useTLS: Bool, stableID: String)? {
|
||||
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func loadLastGatewayConnection() -> LastGatewayConnection? {
|
||||
let defaults = UserDefaults.standard
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !stableID.isEmpty else { return nil }
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
|
||||
|
||||
if kind == .discovered {
|
||||
return .discovered(stableID: stableID, useTLS: useTLS)
|
||||
}
|
||||
|
||||
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey)
|
||||
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
guard !host.isEmpty, port > 0, port <= 65535, !stableID.isEmpty else { return nil }
|
||||
return (host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
// Back-compat: older builds persisted manual-style host/port without a kind marker.
|
||||
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
|
||||
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func deleteGatewayCredentials(instanceId: String) {
|
||||
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: trimmed))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
|
||||
42
apps/ios/Sources/Gateway/GatewaySetupCode.swift
Normal file
42
apps/ios/Sources/Gateway/GatewaySetupCode.swift
Normal file
@ -0,0 +1,42 @@
|
||||
import Foundation
|
||||
|
||||
struct GatewaySetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
enum GatewaySetupCode {
|
||||
static func decode(raw: String) -> GatewaySetupPayload? {
|
||||
if let payload = decodeFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func decodeFromJSON(_ json: String) -> GatewaySetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(GatewaySetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private static func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
41
apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift
Normal file
@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayTrustPromptAlert: ViewModifier {
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
|
||||
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
|
||||
Binding(
|
||||
get: { self.gatewayController.pendingTrustPrompt },
|
||||
set: { _ in
|
||||
// Keep pending trust state until explicit user action.
|
||||
// `alert(item:)` may set the binding to nil during dismissal, which can race with
|
||||
// the button handler and cause accept to no-op.
|
||||
})
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.alert(item: self.promptBinding) { prompt in
|
||||
Alert(
|
||||
title: Text("Trust this gateway?"),
|
||||
message: Text(
|
||||
"""
|
||||
First-time TLS connection.
|
||||
|
||||
Verify this SHA-256 fingerprint out-of-band before trusting:
|
||||
\(prompt.fingerprintSha256)
|
||||
"""),
|
||||
primaryButton: .cancel(Text("Cancel")) {
|
||||
self.gatewayController.declinePendingTrustPrompt()
|
||||
},
|
||||
secondaryButton: .default(Text("Trust and connect")) {
|
||||
Task { await self.gatewayController.acceptPendingTrustPrompt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func gatewayTrustPromptAlert() -> some View {
|
||||
self.modifier(GatewayTrustPromptAlert())
|
||||
}
|
||||
}
|
||||
43
apps/ios/Sources/Gateway/TCPProbe.swift
Normal file
43
apps/ios/Sources/Gateway/TCPProbe.swift
Normal file
@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import os
|
||||
|
||||
enum TCPProbe {
|
||||
static func probe(host: String, port: Int, timeoutSeconds: Double, queueLabel: String) async -> Bool {
|
||||
guard port >= 1, port <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: queueLabel)
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,13 +17,13 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260213</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@ -12,6 +12,10 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
|
||||
private var isStreaming = false
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@ -104,6 +108,56 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
self.isStreaming = true
|
||||
if significantChangesOnly {
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
} else {
|
||||
self.manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
|
||||
self.updatesContinuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { @MainActor in
|
||||
self.stopLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLocationUpdates() {
|
||||
guard self.isStreaming else { return }
|
||||
self.isStreaming = false
|
||||
self.manager.stopUpdatingLocation()
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
self.updatesContinuation?.finish()
|
||||
self.updatesContinuation = nil
|
||||
}
|
||||
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
|
||||
self.significantLocationCallback = onUpdate
|
||||
guard !self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = true
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
func stopMonitoringSignificantLocationChanges() {
|
||||
guard self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = false
|
||||
self.significantLocationCallback = nil
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
@ -117,12 +171,22 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let locs = locations
|
||||
Task { @MainActor in
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
// Resolve the one-shot continuation first (if any).
|
||||
if let cont = self.locationContinuation {
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
// Don't return — also forward to significant-change callback below
|
||||
// so both consumers receive updates when both are active.
|
||||
}
|
||||
if let callback = self.significantLocationCallback, let latest = locs.last {
|
||||
callback(latest)
|
||||
}
|
||||
if let latest = locs.last, let updates = self.updatesContinuation {
|
||||
updates.yield(latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
apps/ios/Sources/Location/SignificantLocationMonitor.swift
Normal file
38
apps/ios/Sources/Location/SignificantLocationMonitor.swift
Normal file
@ -0,0 +1,38 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
/// Monitors significant location changes and pushes `location.update`
|
||||
/// events to the gateway so the severance hook can determine whether
|
||||
/// the user is at their configured work location.
|
||||
@MainActor
|
||||
enum SignificantLocationMonitor {
|
||||
static func startIfNeeded(
|
||||
locationService: any LocationServicing,
|
||||
locationMode: OpenClawLocationMode,
|
||||
gateway: GatewayNodeSession
|
||||
) {
|
||||
guard locationMode == .always else { return }
|
||||
let status = locationService.authorizationStatus()
|
||||
guard status == .authorizedAlways else { return }
|
||||
locationService.startMonitoringSignificantLocationChanges { location in
|
||||
struct Payload: Codable {
|
||||
var lat: Double
|
||||
var lon: Double
|
||||
var accuracyMeters: Double
|
||||
var source: String?
|
||||
}
|
||||
let payload = Payload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
accuracyMeters: location.horizontalAccuracy,
|
||||
source: "ios-significant-location")
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
Task { @MainActor in
|
||||
await gateway.sendEvent(event: "location.update", payloadJSON: json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,37 +61,10 @@ extension NodeAppModel {
|
||||
private static func probeTCP(url: URL, timeoutSeconds: Double) async -> Bool {
|
||||
guard let host = url.host, !host.isEmpty else { return false }
|
||||
let portInt = url.port ?? ((url.scheme ?? "").lowercased() == "wss" ? 443 : 80)
|
||||
guard portInt >= 1, portInt <= 65535 else { return false }
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(portInt)) else { return false }
|
||||
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "a2ui.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
}
|
||||
return await TCPProbe.probe(
|
||||
host: host,
|
||||
port: portInt,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "a2ui.preflight")
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,6 @@ import UserNotifications
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@ -37,7 +36,6 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
cont?.resume(returning: response)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NodeAppModel {
|
||||
@ -53,10 +51,17 @@ final class NodeAppModel {
|
||||
private let camera: any CameraServicing
|
||||
private let screenRecorder: any ScreenRecordingServicing
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
var gatewayAutoReconnectEnabled: Bool = true
|
||||
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
|
||||
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
|
||||
// multiple pending requests and cause the onboarding UI to "flip-flop".
|
||||
var gatewayPairingPaused: Bool = false
|
||||
var gatewayPairingRequestId: String?
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
@ -109,6 +114,7 @@ final class NodeAppModel {
|
||||
private var talkVoiceWakeSuspended = false
|
||||
private var backgroundVoiceWakeSuspended = false
|
||||
private var backgroundTalkSuspended = false
|
||||
private var backgroundTalkKeptActive = false
|
||||
private var backgroundedAt: Date?
|
||||
private var reconnectAfterBackgroundArmed = false
|
||||
|
||||
@ -264,15 +270,18 @@ final class NodeAppModel {
|
||||
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||
switch phase {
|
||||
case .background:
|
||||
self.isBackgrounded = true
|
||||
self.stopGatewayHealthMonitor()
|
||||
self.backgroundedAt = Date()
|
||||
self.reconnectAfterBackgroundArmed = true
|
||||
// Be conservative: release the mic when the app backgrounds.
|
||||
// Release voice wake mic in background.
|
||||
self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
|
||||
self.backgroundTalkSuspended = self.talkMode.suspendForBackground()
|
||||
let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled
|
||||
self.backgroundTalkKeptActive = shouldKeepTalkActive
|
||||
self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
|
||||
case .active, .inactive:
|
||||
self.isBackgrounded = false
|
||||
if self.operatorConnected {
|
||||
@ -284,8 +293,12 @@ final class NodeAppModel {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let suspended = await MainActor.run { self.backgroundTalkSuspended }
|
||||
await MainActor.run { self.backgroundTalkSuspended = false }
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended)
|
||||
let keptActive = await MainActor.run { self.backgroundTalkKeptActive }
|
||||
await MainActor.run {
|
||||
self.backgroundTalkSuspended = false
|
||||
self.backgroundTalkKeptActive = false
|
||||
}
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
||||
}
|
||||
}
|
||||
if phase == .active, self.reconnectAfterBackgroundArmed {
|
||||
@ -340,6 +353,7 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
func setTalkEnabled(_ enabled: Bool) {
|
||||
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
|
||||
if enabled {
|
||||
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
|
||||
// When talk is enabled from the UI, prioritize talk and pause voice wake.
|
||||
@ -351,6 +365,11 @@ final class NodeAppModel {
|
||||
self.talkVoiceWakeSuspended = false
|
||||
}
|
||||
self.talkMode.setEnabled(enabled)
|
||||
Task { [weak self] in
|
||||
await self?.pushTalkModeToGateway(
|
||||
enabled: enabled,
|
||||
phase: enabled ? "enabled" : "disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
|
||||
@ -479,16 +498,49 @@ final class NodeAppModel {
|
||||
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "voicewake.changed" else { continue }
|
||||
guard let payload = evt.payload else { continue }
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTalkModeSync(enabled: Bool, phase: String?) {
|
||||
_ = phase
|
||||
guard self.talkMode.isEnabled != enabled else { return }
|
||||
self.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
|
||||
guard await self.isOperatorConnected() else { return }
|
||||
struct TalkModePayload: Encodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
let payload = TalkModePayload(enabled: enabled, phase: phase)
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
_ = try? await self.operatorGateway.request(
|
||||
method: "talk.mode",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 8)
|
||||
}
|
||||
|
||||
private func startGatewayHealthMonitor() {
|
||||
self.gatewayHealthMonitorDisabled = false
|
||||
self.gatewayHealthMonitor.start(
|
||||
@ -577,6 +629,8 @@ final class NodeAppModel {
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
case .gateway:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -1506,6 +1560,8 @@ extension NodeAppModel {
|
||||
|
||||
func disconnectGateway() {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@ -1535,6 +1591,8 @@ extension NodeAppModel {
|
||||
private extension NodeAppModel {
|
||||
func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
@ -1564,6 +1622,10 @@ private extension NodeAppModel {
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
while !Task.isCancelled {
|
||||
if self.gatewayPairingPaused {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@ -1639,8 +1701,13 @@ private extension NodeAppModel {
|
||||
var attempt = 0
|
||||
var currentOptions = nodeOptions
|
||||
var didFallbackClientId = false
|
||||
var pausedForPairingApproval = false
|
||||
|
||||
while !Task.isCancelled {
|
||||
if self.gatewayPairingPaused {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isGatewayConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@ -1669,12 +1736,13 @@ private extension NodeAppModel {
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
}
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
await self.onNodeGatewayConnected()
|
||||
await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) }
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
@ -1726,11 +1794,52 @@ private extension NodeAppModel {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
|
||||
|
||||
// If pairing is required, stop reconnect churn. The user must approve the request
|
||||
// on the gateway before another connect attempt will succeed, and retry loops can
|
||||
// generate multiple pending requests.
|
||||
let lower = error.localizedDescription.lowercased()
|
||||
if lower.contains("not_paired") || lower.contains("pairing required") {
|
||||
let requestId: String? = {
|
||||
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
|
||||
// Keep this resilient since other layers may wrap the text.
|
||||
let text = error.localizedDescription
|
||||
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
|
||||
guard let end = text[start...].firstIndex(of: ")") else { return nil }
|
||||
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw.isEmpty ? nil : raw
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway, then tap Resume."
|
||||
} else {
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
|
||||
}
|
||||
}
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
// we don't generate multiple pending requests while waiting for approval.
|
||||
pausedForPairingApproval = true
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
break
|
||||
}
|
||||
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
if pausedForPairingApproval {
|
||||
// Leave the status text + request id intact so onboarding can guide the user.
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
@ -1757,7 +1866,7 @@ private extension NodeAppModel {
|
||||
clientId: clientId,
|
||||
clientMode: "ui",
|
||||
clientDisplayName: displayName,
|
||||
includeDeviceIdentity: false)
|
||||
includeDeviceIdentity: true)
|
||||
}
|
||||
|
||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
@ -1775,6 +1884,17 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
await self?.talkMode.reloadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension NodeAppModel {
|
||||
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@ -1808,5 +1928,9 @@ extension NodeAppModel {
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -21,6 +21,7 @@ struct GatewayOnboardingView: View {
|
||||
}
|
||||
.navigationTitle("Connect Gateway")
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,15 +257,6 @@ private struct ManualEntryStep: View {
|
||||
self.manualPassword = ""
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCode() {
|
||||
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else {
|
||||
@ -272,7 +264,7 @@ private struct ManualEntryStep: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return
|
||||
}
|
||||
@ -322,34 +314,7 @@ private struct ManualEntryStep: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
}
|
||||
|
||||
private struct ConnectionStatusBox: View {
|
||||
|
||||
52
apps/ios/Sources/Onboarding/OnboardingStateStore.swift
Normal file
52
apps/ios/Sources/Onboarding/OnboardingStateStore.swift
Normal file
@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
enum OnboardingConnectionMode: String, CaseIterable {
|
||||
case homeNetwork = "home_network"
|
||||
case remoteDomain = "remote_domain"
|
||||
case developerLocal = "developer_local"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .homeNetwork:
|
||||
"Home Network"
|
||||
case .remoteDomain:
|
||||
"Remote Domain"
|
||||
case .developerLocal:
|
||||
"Same Machine (Dev)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OnboardingStateStore {
|
||||
private static let completedDefaultsKey = "onboarding.completed"
|
||||
private static let lastModeDefaultsKey = "onboarding.last_mode"
|
||||
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
|
||||
|
||||
@MainActor
|
||||
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
|
||||
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||
return appModel.gatewayServerName == nil
|
||||
}
|
||||
|
||||
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.completedDefaultsKey)
|
||||
if let mode {
|
||||
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
|
||||
}
|
||||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !raw.isEmpty else { return nil }
|
||||
return OnboardingConnectionMode(rawValue: raw)
|
||||
}
|
||||
}
|
||||
852
apps/ios/Sources/Onboarding/OnboardingWizardView.swift
Normal file
852
apps/ios/Sources/Onboarding/OnboardingWizardView.swift
Normal file
@ -0,0 +1,852 @@
|
||||
import CoreImage
|
||||
import OpenClawKit
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum OnboardingStep: Int, CaseIterable {
|
||||
case welcome
|
||||
case mode
|
||||
case connect
|
||||
case auth
|
||||
case success
|
||||
|
||||
var previous: Self? {
|
||||
Self(rawValue: self.rawValue - 1)
|
||||
}
|
||||
|
||||
var next: Self? {
|
||||
Self(rawValue: self.rawValue + 1)
|
||||
}
|
||||
|
||||
/// Progress label for the manual setup flow (mode → connect → auth → success).
|
||||
var manualProgressTitle: String {
|
||||
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
|
||||
guard let idx = manualSteps.firstIndex(of: self) else { return "" }
|
||||
return "Step \(idx + 1) of \(manualSteps.count)"
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome: "Welcome"
|
||||
case .mode: "Connection Mode"
|
||||
case .connect: "Connect"
|
||||
case .auth: "Authentication"
|
||||
case .success: "Connected"
|
||||
}
|
||||
}
|
||||
|
||||
var canGoBack: Bool {
|
||||
self != .welcome && self != .success
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
|
||||
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
|
||||
@State private var step: OnboardingStep = .welcome
|
||||
@State private var selectedMode: OnboardingConnectionMode?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPort: Int = 18789
|
||||
@State private var manualPortText: String = "18789"
|
||||
@State private var manualTLS: Bool = true
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var connectMessage: String?
|
||||
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var issue: GatewayConnectionIssue = .none
|
||||
@State private var didMarkCompleted = false
|
||||
@State private var didAutoPresentQR = false
|
||||
@State private var pairingRequestId: String?
|
||||
@State private var discoveryRestartTask: Task<Void, Never>?
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
let allowSkip: Bool
|
||||
let onClose: () -> Void
|
||||
|
||||
private var isFullScreenStep: Bool {
|
||||
self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch self.step {
|
||||
case .welcome:
|
||||
self.welcomeStep
|
||||
case .success:
|
||||
self.successStep
|
||||
default:
|
||||
Form {
|
||||
switch self.step {
|
||||
case .mode:
|
||||
self.modeStep
|
||||
case .connect:
|
||||
self.connectStep
|
||||
case .auth:
|
||||
self.authStep
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !self.isFullScreenStep {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack(spacing: 2) {
|
||||
Text(self.step.title)
|
||||
.font(.headline)
|
||||
Text(self.step.manualProgressTitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if self.step.canGoBack {
|
||||
Button {
|
||||
self.navigateBack()
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
}
|
||||
} else if self.allowSkip {
|
||||
Button("Close") {
|
||||
self.onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.alert("QR Scanner Unavailable", isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
self.selectedPhoto = nil
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "Could not load the selected image."
|
||||
return
|
||||
}
|
||||
if let message = self.detectQRCode(from: data) {
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if let url = URL(string: message),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
}
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
}
|
||||
.onChange(of: self.manualPortText) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
if digits != newValue {
|
||||
self.manualPortText = digits
|
||||
return
|
||||
}
|
||||
guard let parsed = Int(digits), parsed > 0 else {
|
||||
self.manualPort = 0
|
||||
return
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
let next = GatewayConnectionIssue.detect(from: newValue)
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, next.needsPairing {
|
||||
// Keep the requestId sticky even if the status line omits it after we pause.
|
||||
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !next.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = next
|
||||
}
|
||||
|
||||
if let requestId = next.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
|
||||
if next.needsAuthToken {
|
||||
self.appModel.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing {
|
||||
self.step = .auth
|
||||
}
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.connectMessage = newValue
|
||||
self.statusLine = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
self.statusLine = "Connected."
|
||||
if !self.didMarkCompleted, let selectedMode {
|
||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||
self.didMarkCompleted = true
|
||||
}
|
||||
self.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Welcome")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Connect to your OpenClaw gateway")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
self.step = .mode
|
||||
} label: {
|
||||
Text("Set Up Manually")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text(self.statusLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modeStep: some View {
|
||||
Section("Connection Mode") {
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.homeNetwork.title,
|
||||
subtitle: "LAN or Tailscale host",
|
||||
selected: self.selectedMode == .homeNetwork)
|
||||
{
|
||||
self.selectMode(.homeNetwork)
|
||||
}
|
||||
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.remoteDomain.title,
|
||||
subtitle: "VPS with domain",
|
||||
selected: self.selectedMode == .remoteDomain)
|
||||
{
|
||||
self.selectMode(.remoteDomain)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Developer mode",
|
||||
isOn: Binding(
|
||||
get: { self.developerModeEnabled },
|
||||
set: { newValue in
|
||||
self.developerModeEnabled = newValue
|
||||
if !newValue, self.selectedMode == .developerLocal {
|
||||
self.selectedMode = nil
|
||||
}
|
||||
}))
|
||||
|
||||
if self.developerModeEnabled {
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.developerLocal.title,
|
||||
subtitle: "For local iOS app development",
|
||||
selected: self.selectedMode == .developerLocal)
|
||||
{
|
||||
self.selectMode(.developerLocal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Continue") {
|
||||
self.step = .connect
|
||||
}
|
||||
.disabled(self.selectedMode == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectStep: some View {
|
||||
if let selectedMode {
|
||||
Section {
|
||||
LabeledContent("Mode", value: selectedMode.title)
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Progress", value: self.statusLine)
|
||||
} header: {
|
||||
Text("Status")
|
||||
} footer: {
|
||||
if let connectMessage {
|
||||
Text(connectMessage)
|
||||
}
|
||||
}
|
||||
|
||||
switch selectedMode {
|
||||
case .homeNetwork:
|
||||
self.homeNetworkConnectSection
|
||||
case .remoteDomain:
|
||||
self.remoteDomainConnectSection
|
||||
case .developerLocal:
|
||||
self.developerConnectSection
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text("Choose a mode first.")
|
||||
Button("Back to Mode Selection") {
|
||||
self.step = .mode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var homeNetworkConnectSection: some View {
|
||||
Group {
|
||||
Section("Discovered Gateways") {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
let hasHost = self.gatewayHasResolvableHost(gateway)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gateway.name)
|
||||
if let host = gateway.lanHost ?? gateway.tailnetDns {
|
||||
Text(host)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await self.connectDiscoveredGateway(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else if !hasHost {
|
||||
Text("Resolving…")
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || !hasHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Restart Discovery") {
|
||||
self.gatewayController.restartDiscovery()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
|
||||
self.manualConnectionFieldsSection(title: "Manual Fallback")
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDomainConnectSection: some View {
|
||||
self.manualConnectionFieldsSection(title: "Domain Settings")
|
||||
}
|
||||
|
||||
private var developerConnectSection: some View {
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
} header: {
|
||||
Text("Developer Local")
|
||||
} footer: {
|
||||
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
|
||||
}
|
||||
}
|
||||
|
||||
private var authStep: some View {
|
||||
Group {
|
||||
Section("Authentication") {
|
||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
if self.issue.needsAuthToken {
|
||||
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Auth token looks valid.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if self.issue.needsPairing {
|
||||
Section {
|
||||
Button("Copy: openclaw devices list") {
|
||||
UIPasteboard.general.string = "openclaw devices list"
|
||||
}
|
||||
|
||||
if let id = self.issue.requestId {
|
||||
Button("Copy: openclaw devices approve \(id)") {
|
||||
UIPasteboard.general.string = "openclaw devices approve \(id)"
|
||||
}
|
||||
} else {
|
||||
Button("Copy: openclaw devices approve <requestId>") {
|
||||
UIPasteboard.general.string = "openclaw devices approve <requestId>"
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Pairing Approval")
|
||||
} footer: {
|
||||
Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.retryLastAttempt() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "retry" {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Retry Connection")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button {
|
||||
self.resumeAfterPairingApproval()
|
||||
} label: {
|
||||
Label("Resume After Approval", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
|
||||
|
||||
Button {
|
||||
self.openQRScannerFromOnboarding()
|
||||
} label: {
|
||||
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var successStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connected")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
let server = self.appModel.gatewayServerName ?? "gateway"
|
||||
Text(server)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
Text(addr)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.onClose()
|
||||
} label: {
|
||||
Text("Open OpenClaw")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualHost = link.host
|
||||
self.manualPort = link.port
|
||||
self.manualTLS = link.tls
|
||||
if let token = link.token {
|
||||
self.gatewayToken = token
|
||||
}
|
||||
if let password = link.password {
|
||||
self.gatewayPassword = password
|
||||
}
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||
self.showQRScanner = false
|
||||
self.connectMessage = "Connecting via QR code…"
|
||||
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…"
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
|
||||
}
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
|
||||
private func openQRScannerFromOnboarding() {
|
||||
// Stop active reconnect loops before scanning new credentials.
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.issue = .none
|
||||
self.pairingRequestId = nil
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
private func resumeAfterPairingApproval() {
|
||||
// We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
|
||||
self.appModel.gatewayAutoReconnectEnabled = true
|
||||
self.appModel.gatewayPairingPaused = false
|
||||
self.connectMessage = "Retrying after approval…"
|
||||
self.statusLine = "Retrying after approval…"
|
||||
Task { await self.retryLastAttempt() }
|
||||
}
|
||||
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode, context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.step = target
|
||||
}
|
||||
private var canConnectManual: Bool {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||
}
|
||||
|
||||
private func initializeState() {
|
||||
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
self.manualHost = host
|
||||
self.manualPort = port
|
||||
self.manualTLS = useTLS
|
||||
case .discovered:
|
||||
self.manualHost = "openclaw.local"
|
||||
self.manualPort = 18789
|
||||
self.manualTLS = true
|
||||
}
|
||||
} else {
|
||||
self.manualHost = "openclaw.local"
|
||||
self.manualPort = 18789
|
||||
self.manualTLS = true
|
||||
}
|
||||
}
|
||||
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = OnboardingStateStore.lastMode()
|
||||
}
|
||||
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
|
||||
self.manualHost = "localhost"
|
||||
self.manualTLS = false
|
||||
}
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
|
||||
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.didAutoPresentQR = true
|
||||
self.statusLine = "No saved pairing found. Scan QR code to connect."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDiscoveryRestart() {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.gatewayController.restartDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGatewayCredentials(token: String, password: String) {
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(gateway.name)…"
|
||||
self.statusLine = "Connecting to \(gateway.name)…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func selectMode(_ mode: OnboardingConnectionMode) {
|
||||
self.selectedMode = mode
|
||||
self.applyModeDefaults(mode)
|
||||
}
|
||||
|
||||
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
|
||||
|
||||
switch mode {
|
||||
case .homeNetwork:
|
||||
if hostIsDefaultLike { self.manualHost = "openclaw.local" }
|
||||
self.manualTLS = true
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
case .remoteDomain:
|
||||
if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
|
||||
self.manualTLS = true
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
case .developerLocal:
|
||||
if hostIsDefaultLike { self.manualHost = "localhost" }
|
||||
self.manualTLS = false
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
||||
let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !lanHost.isEmpty { return true }
|
||||
let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !tailnetDns.isEmpty
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
|
||||
self.connectingGatewayID = "manual"
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(host)…"
|
||||
self.statusLine = "Connecting to \(host):\(self.manualPort)…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
|
||||
}
|
||||
|
||||
private func retryLastAttempt() async {
|
||||
self.connectingGatewayID = "retry"
|
||||
self.issue = .none
|
||||
self.connectMessage = "Retrying…"
|
||||
self.statusLine = "Retrying last connection…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingModeRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.body.weight(.semibold))
|
||||
Text(self.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
96
apps/ios/Sources/Onboarding/QRScannerView.swift
Normal file
96
apps/ios/Sources/Onboarding/QRScannerView.swift
Normal file
@ -0,0 +1,96 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onGatewayLink: (GatewayConnectDeepLink) -> Void
|
||||
let onError: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
guard DataScannerViewController.isSupported else {
|
||||
context.coordinator.reportError("QR scanning is not supported on this device.")
|
||||
return UIViewController()
|
||||
}
|
||||
guard DataScannerViewController.isAvailable else {
|
||||
context.coordinator.reportError("Camera scanning is currently unavailable.")
|
||||
return UIViewController()
|
||||
}
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
isHighlightingEnabled: true)
|
||||
scanner.delegate = context.coordinator
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
context.coordinator.reportError("Could not start QR scanner.")
|
||||
}
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
|
||||
if let scanner = uiViewController as? DataScannerViewController {
|
||||
scanner.stopScanning()
|
||||
}
|
||||
coordinator.parent.onDismiss()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let parent: QRScannerView
|
||||
private var handled = false
|
||||
private var reportedError = false
|
||||
|
||||
init(parent: QRScannerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func reportError(_ message: String) {
|
||||
guard !self.reportedError else { return }
|
||||
self.reportedError = true
|
||||
Task { @MainActor in
|
||||
self.parent.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
|
||||
guard !self.handled else { return }
|
||||
for item in items {
|
||||
guard case let .barcode(barcode) = item,
|
||||
let payload = barcode.payloadStringValue
|
||||
else { continue }
|
||||
|
||||
// Try setup code format first (base64url JSON from /pair qr).
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
|
||||
self.handled = true
|
||||
self.parent.onGatewayLink(link)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to deep link URL format (openclaw://gateway?...).
|
||||
if let url = URL(string: payload),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handled = true
|
||||
self.parent.onGatewayLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
|
||||
|
||||
func dataScanner(
|
||||
_: DataScannerViewController,
|
||||
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
|
||||
{
|
||||
self.reportError("Camera is not available on this device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct OpenClawApp: App {
|
||||
@ -7,6 +8,7 @@ struct OpenClawApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
_appModel = State(initialValue: appModel)
|
||||
@ -29,3 +31,18 @@ struct OpenClawApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawApp {
|
||||
private static func installUncaughtExceptionLogger() {
|
||||
NSLog("OpenClaw: installing uncaught exception handler")
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
|
||||
// produce a normal Swift error backtrace.
|
||||
let reason = exception.reason ?? "(no reason)"
|
||||
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
|
||||
for line in exception.callStackSymbols {
|
||||
NSLog(" %@", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ final class RemindersService: RemindersServicing {
|
||||
func list(params: OpenClawRemindersListParams) async throws -> OpenClawRemindersListPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsRead(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@ -50,7 +50,7 @@ final class RemindersService: RemindersServicing {
|
||||
func add(params: OpenClawRemindersAddParams) async throws -> OpenClawRemindersAddPayload {
|
||||
let store = EKEventStore()
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let authorized = await Self.ensureWriteAuthorization(store: store, status: status)
|
||||
let authorized = EventKitAuthorization.allowsWrite(status: status)
|
||||
guard authorized else {
|
||||
throw NSError(domain: "Reminders", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "REMINDERS_PERMISSION_REQUIRED: grant Reminders permission",
|
||||
@ -100,38 +100,6 @@ final class RemindersService: RemindersServicing {
|
||||
return OpenClawRemindersAddPayload(reminder: payload)
|
||||
}
|
||||
|
||||
private static func ensureAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
case .fullAccess:
|
||||
return true
|
||||
case .writeOnly:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureWriteAuthorization(store: EKEventStore, status: EKAuthorizationStatus) async -> Bool {
|
||||
switch status {
|
||||
case .authorized, .fullAccess, .writeOnly:
|
||||
return true
|
||||
case .notDetermined:
|
||||
// Don’t prompt during node.invoke; prompts block the invoke and lead to timeouts.
|
||||
return false
|
||||
case .restricted, .denied:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveList(
|
||||
store: EKEventStore,
|
||||
listId: String?,
|
||||
|
||||
@ -3,34 +3,69 @@ import UIKit
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.colorScheme) private var systemColorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@State private var didEvaluateOnboarding: Bool = false
|
||||
@State private var didAutoOpenSettings: Bool = false
|
||||
|
||||
private enum PresentedSheet: Identifiable {
|
||||
case settings
|
||||
case chat
|
||||
case quickSetup
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .settings: 0
|
||||
case .chat: 1
|
||||
case .quickSetup: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
// On first run or explicit launch onboarding state, onboarding always wins.
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
// Settings auto-open is a recovery path for previously-connected installs only.
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CanvasContent(
|
||||
@ -52,31 +87,63 @@ struct RootCanvas: View {
|
||||
CameraFlashOverlay(nonce: self.appModel.cameraFlashNonce)
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.sheet(item: self.$presentedSheet) { sheet in
|
||||
switch sheet {
|
||||
case .settings:
|
||||
SettingsTab()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
gateway: self.appModel.operatorSession,
|
||||
// Mobile chat UI should use the node role RPC surface (chat.* / sessions.*)
|
||||
// to avoid requiring operator scopes like operator.read.
|
||||
gateway: self.appModel.gatewaySession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
userAccent: self.appModel.seamColor)
|
||||
case .quickSetup:
|
||||
GatewayQuickSetupSheet()
|
||||
.environment(self.appModel)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
OnboardingStateStore.markCompleted(mode: nil)
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
}
|
||||
@ -135,11 +202,31 @@ struct RootCanvas: View {
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func shouldAutoOpenSettings() -> Bool {
|
||||
if self.appModel.gatewayServerName != nil { return false }
|
||||
if !self.hasConnectedOnce { return true }
|
||||
if !self.onboardingComplete { return true }
|
||||
return !self.hasExistingGatewayConfig()
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.didEvaluateOnboarding else { return }
|
||||
self.didEvaluateOnboarding = true
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
break
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
}
|
||||
|
||||
private func hasExistingGatewayConfig() -> Bool {
|
||||
@ -150,10 +237,26 @@ struct RootCanvas: View {
|
||||
|
||||
private func maybeAutoOpenSettings() {
|
||||
guard !self.didAutoOpenSettings else { return }
|
||||
guard self.shouldAutoOpenSettings() else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
|
||||
private func maybeShowQuickSetup() {
|
||||
guard !self.quickSetupDismissed else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
guard self.presentedSheet == nil else { return }
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard !self.gatewayController.gateways.isEmpty else { return }
|
||||
self.presentedSheet = .quickSetup
|
||||
}
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
|
||||
@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct RootTabs: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@State private var selectedTab: Int = 0
|
||||
@State private var voiceWakeToastText: String?
|
||||
@ -52,14 +53,14 @@ struct RootTabs: View {
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.toastDismissTask?.cancel()
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
|
||||
self.toastDismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
}
|
||||
@ -104,66 +105,10 @@ struct RootTabs: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText = self.appModel.cameraHUDText,
|
||||
let cameraHUDKind = self.appModel.cameraHUDKind,
|
||||
!cameraHUDText.isEmpty
|
||||
{
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.appModel.cameraHUDText,
|
||||
cameraHUDKind: self.appModel.cameraHUDKind)
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,6 +28,12 @@ protocol LocationServicing: Sendable {
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
func stopLocationUpdates()
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
|
||||
func stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
protocol DeviceStatusServicing: Sendable {
|
||||
|
||||
@ -15,6 +15,8 @@ struct SettingsTab: View {
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@ -28,17 +30,27 @@ struct SettingsTab: View {
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
|
||||
// Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var talkElevenLabsApiKey: String = ""
|
||||
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
@State private var gatewayExpanded: Bool = true
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
@State private var showResetOnboardingAlert: Bool = false
|
||||
@State private var suppressCredentialPersist: Bool = false
|
||||
|
||||
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
||||
|
||||
var body: some View {
|
||||
@ -103,7 +115,6 @@ struct SettingsTab: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
if self.appModel.gatewayServerName == nil {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
@ -148,69 +159,74 @@ struct SettingsTab: View {
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
DisclosureGroup("Advanced") {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
} else {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
Button("Reset Onboarding", role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDebugText())
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDebugText())
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
@ -235,6 +251,20 @@ struct SettingsTab: View {
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
Text("Keep listening when the app is in the background. Uses more battery.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled)
|
||||
Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||
|
||||
@ -303,8 +333,17 @@ struct SettingsTab: View {
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = Self.primaryIPv4Address()
|
||||
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
self.syncManualPortText()
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -312,6 +351,7 @@ struct SettingsTab: View {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
@ -331,17 +371,22 @@ struct SettingsTab: View {
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.talkElevenLabsApiKey) { _, newValue in
|
||||
GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
@ -376,6 +421,7 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -388,11 +434,13 @@ struct SettingsTab: View {
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
if let lastKnown = GatewaySettingsStore.loadLastGatewayConnection(),
|
||||
case let .manual(host, port, _, _) = lastKnown
|
||||
{
|
||||
Button {
|
||||
Task { await self.connectLastKnown() }
|
||||
} label: {
|
||||
self.lastKnownButtonLabel(host: lastKnown.host, port: lastKnown.port)
|
||||
self.lastKnownButtonLabel(host: host, port: port)
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
.buttonStyle(.borderedProminent)
|
||||
@ -418,10 +466,11 @@ struct SettingsTab: View {
|
||||
ForEach(rows) { gateway in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(gateway.name)
|
||||
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
|
||||
Text(verbatim: gateway.name)
|
||||
let detailLines = self.gatewayDetailLines(gateway)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(line)
|
||||
Text(verbatim: line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@ -507,7 +556,10 @@ struct SettingsTab: View {
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
await self.gatewayController.connect(gateway)
|
||||
let err = await self.gatewayController.connectWithDiagnostics(gateway)
|
||||
if let err {
|
||||
self.setupStatusText = err
|
||||
}
|
||||
}
|
||||
|
||||
private func connectLastKnown() async {
|
||||
@ -587,15 +639,6 @@ struct SettingsTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct SetupPayload: Codable {
|
||||
var url: String?
|
||||
var host: String?
|
||||
var port: Int?
|
||||
var tls: Bool?
|
||||
var token: String?
|
||||
var password: String?
|
||||
}
|
||||
|
||||
private func applySetupCodeAndConnect() async {
|
||||
self.setupStatusText = nil
|
||||
guard self.applySetupCode() else { return }
|
||||
@ -623,7 +666,7 @@ struct SettingsTab: View {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let payload = self.decodeSetupPayload(raw: raw) else {
|
||||
guard let payload = GatewaySetupCode.decode(raw: raw) else {
|
||||
self.setupStatusText = "Setup code not recognized."
|
||||
return false
|
||||
}
|
||||
@ -724,67 +767,14 @@ struct SettingsTab: View {
|
||||
}
|
||||
|
||||
private static func probeTCP(host: String, port: Int, timeoutSeconds: Double) async -> Bool {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: UInt16(port)) else { return false }
|
||||
let endpointHost = NWEndpoint.Host(host)
|
||||
let connection = NWConnection(host: endpointHost, port: nwPort, using: .tcp)
|
||||
return await withCheckedContinuation { cont in
|
||||
let queue = DispatchQueue(label: "gateway.preflight")
|
||||
let finished = OSAllocatedUnfairLock(initialState: false)
|
||||
let finish: @Sendable (Bool) -> Void = { ok in
|
||||
let shouldResume = finished.withLock { flag -> Bool in
|
||||
if flag { return false }
|
||||
flag = true
|
||||
return true
|
||||
}
|
||||
guard shouldResume else { return }
|
||||
connection.cancel()
|
||||
cont.resume(returning: ok)
|
||||
}
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(true)
|
||||
case .failed, .cancelled:
|
||||
finish(false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) {
|
||||
finish(false)
|
||||
}
|
||||
}
|
||||
await TCPProbe.probe(
|
||||
host: host,
|
||||
port: port,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
queueLabel: "gateway.preflight")
|
||||
}
|
||||
|
||||
private func decodeSetupPayload(raw: String) -> SetupPayload? {
|
||||
if let payload = decodeSetupPayloadFromJSON(raw) {
|
||||
return payload
|
||||
}
|
||||
if let decoded = decodeBase64Payload(raw),
|
||||
let payload = decodeSetupPayloadFromJSON(decoded)
|
||||
{
|
||||
return payload
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func decodeSetupPayloadFromJSON(_ json: String) -> SetupPayload? {
|
||||
guard let data = json.data(using: .utf8) else { return nil }
|
||||
return try? JSONDecoder().decode(SetupPayload.self, from: data)
|
||||
}
|
||||
|
||||
private func decodeBase64Payload(_ raw: String) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = trimmed
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let padding = normalized.count % 4
|
||||
let padded = padding == 0 ? normalized : normalized + String(repeating: "=", count: 4 - padding)
|
||||
guard let data = Data(base64Encoded: padded) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
// (GatewaySetupCode) decode raw setup codes.
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualGatewayHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@ -849,44 +839,6 @@ struct SettingsTab: View {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func primaryIPv4Address() -> String? {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||
defer { freeifaddrs(addrList) }
|
||||
|
||||
var fallback: String?
|
||||
var en0: String?
|
||||
|
||||
for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) {
|
||||
let flags = Int32(ptr.pointee.ifa_flags)
|
||||
let isUp = (flags & IFF_UP) != 0
|
||||
let isLoopback = (flags & IFF_LOOPBACK) != 0
|
||||
let name = String(cString: ptr.pointee.ifa_name)
|
||||
let family = ptr.pointee.ifa_addr.pointee.sa_family
|
||||
if !isUp || isLoopback || family != UInt8(AF_INET) { continue }
|
||||
|
||||
var addr = ptr.pointee.ifa_addr.pointee
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
let result = getnameinfo(
|
||||
&addr,
|
||||
socklen_t(ptr.pointee.ifa_addr.pointee.sa_len),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard result == 0 else { continue }
|
||||
let len = buffer.prefix { $0 != 0 }
|
||||
let bytes = len.map { UInt8(bitPattern: $0) }
|
||||
guard let ip = String(bytes: bytes, encoding: .utf8) else { continue }
|
||||
|
||||
if name == "en0" { en0 = ip; break }
|
||||
if fallback == nil { fallback = ip }
|
||||
}
|
||||
|
||||
return en0 ?? fallback
|
||||
}
|
||||
|
||||
private static func hasTailnetIPv4() -> Bool {
|
||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return false }
|
||||
@ -946,6 +898,43 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func resetOnboarding() {
|
||||
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
self.gatewayAutoConnect = false
|
||||
|
||||
self.suppressCredentialPersist = true
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
|
||||
// Clear manual override so it doesn't count as an existing gateway config.
|
||||
self.manualGatewayEnabled = false
|
||||
self.manualGatewayHost = ""
|
||||
|
||||
// Force re-present even without app restart.
|
||||
self.onboardingRequestID += 1
|
||||
|
||||
// The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
|
||||
self.dismiss()
|
||||
}
|
||||
|
||||
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
|
||||
71
apps/ios/Sources/Status/StatusActivityBuilder.swift
Normal file
71
apps/ios/Sources/Status/StatusActivityBuilder.swift
Normal file
@ -0,0 +1,71 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StatusActivityBuilder {
|
||||
@MainActor
|
||||
static func build(
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
cameraHUDText: String?,
|
||||
cameraHUDKind: NodeAppModel.CameraHUDKind?
|
||||
) -> StatusPill.Activity? {
|
||||
// Keep the top pill consistent across tabs (camera + voice wake + pairing states).
|
||||
if appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if voiceWakeEnabled {
|
||||
let voiceStatus = appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ import SwiftUI
|
||||
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
@ -49,11 +51,11 @@ struct StatusPill: View {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
.scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
@ -64,17 +66,17 @@ struct StatusPill: View {
|
||||
if let activity {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
Text(activity.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
@ -87,21 +89,28 @@ struct StatusPill: View {
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Status")
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
|
||||
.accessibilityHint("Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue)
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
@ -113,9 +122,9 @@ struct StatusPill: View {
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
|
||||
guard gateway == .connecting, scenePhase == .active else {
|
||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
|
||||
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var command: String
|
||||
var brighten: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(self.command)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@ -23,11 +25,14 @@ struct VoiceWakeToast: View {
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.accessibilityLabel("Voice Wake")
|
||||
.accessibilityValue(self.command)
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import Speech
|
||||
final class TalkModeManager: NSObject {
|
||||
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
|
||||
var isEnabled: Bool = false
|
||||
var isListening: Bool = false
|
||||
var isSpeaking: Bool = false
|
||||
@ -218,8 +219,12 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
/// Suspends microphone usage without disabling Talk Mode.
|
||||
/// Used when the app backgrounds (or when we need to temporarily release the mic).
|
||||
func suspendForBackground() -> Bool {
|
||||
func suspendForBackground(keepActive: Bool = false) -> Bool {
|
||||
guard self.isEnabled else { return false }
|
||||
if keepActive {
|
||||
self.statusText = self.isListening ? "Listening" : self.statusText
|
||||
return false
|
||||
}
|
||||
let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive
|
||||
|
||||
self.isListening = false
|
||||
@ -246,7 +251,8 @@ final class TalkModeManager: NSObject {
|
||||
return wasActive
|
||||
}
|
||||
|
||||
func resumeAfterBackground(wasSuspended: Bool) async {
|
||||
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
|
||||
if wasKeptActive { return }
|
||||
guard wasSuspended else { return }
|
||||
guard self.isEnabled else { return }
|
||||
await self.start()
|
||||
@ -814,29 +820,24 @@ final class TalkModeManager: NSObject {
|
||||
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
guard let gateway else { return }
|
||||
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
// Operator clients receive chat events without node-style subscriptions.
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
}
|
||||
|
||||
private func unsubscribeAllChats() async {
|
||||
guard let gateway else { return }
|
||||
let keys = self.chatSubscribedSessionKeys
|
||||
self.chatSubscribedSessionKeys.removeAll()
|
||||
for key in keys {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPrompt(transcript: String) -> String {
|
||||
let interrupted = self.lastInterruptedAtSeconds
|
||||
self.lastInterruptedAtSeconds = nil
|
||||
return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted)
|
||||
let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
|
||||
return TalkPromptBuilder.build(
|
||||
transcript: transcript,
|
||||
interruptedAtSeconds: interrupted,
|
||||
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
|
||||
}
|
||||
|
||||
private enum ChatCompletionState: CustomStringConvertible {
|
||||
@ -1114,6 +1115,7 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func shouldInterrupt(with transcript: String) -> Bool {
|
||||
guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false }
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 3 else { return false }
|
||||
if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) {
|
||||
@ -1122,6 +1124,20 @@ final class TalkModeManager: NSObject {
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool {
|
||||
let route = AVAudioSession.sharedInstance().currentRoute
|
||||
// Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts.
|
||||
// Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay).
|
||||
return !route.outputs.contains { output in
|
||||
switch output.portType {
|
||||
case .builtInSpeaker, .builtInReceiver:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldUseIncrementalTTS() -> Bool {
|
||||
true
|
||||
}
|
||||
@ -1668,6 +1684,15 @@ extension TalkModeManager {
|
||||
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
||||
}
|
||||
|
||||
private static func normalizedTalkApiKey(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed != Self.redactedConfigSentinel else { return nil }
|
||||
// Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`).
|
||||
if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
@ -1699,7 +1724,15 @@ extension TalkModeManager {
|
||||
}
|
||||
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
|
||||
let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
|
||||
if rawConfigApiKey == Self.redactedConfigSentinel {
|
||||
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil
|
||||
GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
|
||||
} else {
|
||||
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
|
||||
}
|
||||
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
|
||||
self.interruptOnSpeech = interrupt
|
||||
}
|
||||
|
||||
@ -76,4 +76,52 @@ import Testing
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkParsesCommonFields() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .gateway(
|
||||
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: "tok",
|
||||
password: "pw"))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: "tok",
|
||||
password: nil))
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,4 +76,47 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
|
||||
}
|
||||
}
|
||||
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"camera.enabled": true,
|
||||
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let commands = Set(controller._test_currentCommands())
|
||||
|
||||
// iOS should expose notify, but not host shell/exec-approval commands.
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
withUserDefaults([:]) {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
withUserDefaults([
|
||||
"gateway.last.kind": "manual",
|
||||
"gateway.last.host": "",
|
||||
"gateway.last.port": 0,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|invalid|0",
|
||||
]) {
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal file
33
apps/ios/Tests/GatewayConnectionIssueTests.swift
Normal file
@ -0,0 +1,33 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionIssueTests {
|
||||
@Test func detectsTokenMissing() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
|
||||
#expect(issue == .tokenMissing)
|
||||
#expect(issue.needsAuthToken)
|
||||
}
|
||||
|
||||
@Test func detectsUnauthorized() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
|
||||
#expect(issue == .unauthorized)
|
||||
#expect(issue.needsAuthToken)
|
||||
}
|
||||
|
||||
@Test func detectsPairingWithRequestId() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
|
||||
#expect(issue == .pairingRequired(requestId: "abc123"))
|
||||
#expect(issue.needsPairing)
|
||||
#expect(issue.requestId == "abc123")
|
||||
}
|
||||
|
||||
@Test func detectsNetworkError() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
|
||||
#expect(issue == .network)
|
||||
}
|
||||
|
||||
@Test func returnsNoneForBenignStatus() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Connected")
|
||||
#expect(issue == .none)
|
||||
}
|
||||
}
|
||||
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
105
apps/ios/Tests/GatewayConnectionSecurityTests.swift
Normal file
@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionSecurityTests {
|
||||
private func clearTLSFingerprint(stableID: String) {
|
||||
let suite = UserDefaults(suiteName: "ai.openclaw.shared") ?? .standard
|
||||
suite.removeObject(forKey: "gateway.tls.\(stableID)")
|
||||
}
|
||||
|
||||
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "evil.example.com",
|
||||
tailnetDns: "evil.example.com",
|
||||
gatewayPort: 12345,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == "11")
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: nil,
|
||||
tailnetDns: nil,
|
||||
gatewayPort: nil,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: "22",
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
|
||||
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
|
||||
#expect(params?.expectedFingerprint == nil)
|
||||
#expect(params?.allowTOFU == false)
|
||||
}
|
||||
|
||||
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
|
||||
let stableID = "test|\(UUID().uuidString)"
|
||||
defer { clearTLSFingerprint(stableID: stableID) }
|
||||
clearTLSFingerprint(stableID: stableID)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(true, forKey: "gateway.autoconnect")
|
||||
defaults.set(false, forKey: "gateway.manual.enabled")
|
||||
defaults.removeObject(forKey: "gateway.last.host")
|
||||
defaults.removeObject(forKey: "gateway.last.port")
|
||||
defaults.removeObject(forKey: "gateway.last.tls")
|
||||
defaults.removeObject(forKey: "gateway.last.stableID")
|
||||
defaults.removeObject(forKey: "gateway.last.kind")
|
||||
defaults.removeObject(forKey: "gateway.preferredStableID")
|
||||
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
|
||||
|
||||
let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil)
|
||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
name: "Test",
|
||||
endpoint: endpoint,
|
||||
stableID: stableID,
|
||||
debugID: "debug",
|
||||
lanHost: "test.local",
|
||||
tailnetDns: nil,
|
||||
gatewayPort: 18789,
|
||||
canvasPort: nil,
|
||||
tlsEnabled: true,
|
||||
tlsFingerprintSha256: nil,
|
||||
cliPath: nil)
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
controller._test_setGateways([gateway])
|
||||
controller._test_triggerAutoConnect()
|
||||
|
||||
#expect(controller._test_didAutoConnect() == false)
|
||||
}
|
||||
}
|
||||
@ -124,4 +124,76 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
#expect(defaults.string(forKey: "gateway.preferredStableID") == "preferred-from-keychain")
|
||||
#expect(defaults.string(forKey: "gateway.lastDiscoveredStableID") == "last-from-keychain")
|
||||
}
|
||||
|
||||
@Test func lastGateway_manualRoundTrip() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|example.com|443")
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.com", port: 443, useTLS: true, stableID: "manual|example.com|443"))
|
||||
}
|
||||
|
||||
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
// Simulate a prior manual record that included host/port.
|
||||
applyDefaults([
|
||||
"gateway.last.host": "10.0.0.99",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": true,
|
||||
"gateway.last.stableID": "manual|10.0.0.99|18789",
|
||||
"gateway.last.kind": "manual",
|
||||
])
|
||||
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
#expect(defaults.object(forKey: "gateway.last.host") == nil)
|
||||
#expect(defaults.object(forKey: "gateway.last.port") == nil)
|
||||
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
|
||||
}
|
||||
|
||||
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() {
|
||||
let keys = [
|
||||
"gateway.last.kind",
|
||||
"gateway.last.host",
|
||||
"gateway.last.port",
|
||||
"gateway.last.tls",
|
||||
"gateway.last.stableID",
|
||||
]
|
||||
let snapshot = snapshotDefaults(keys)
|
||||
defer { restoreDefaults(snapshot) }
|
||||
|
||||
applyDefaults([
|
||||
"gateway.last.kind": nil,
|
||||
"gateway.last.host": "example.org",
|
||||
"gateway.last.port": 18789,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|example.org|18789",
|
||||
])
|
||||
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,10 +15,10 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260213</string>
|
||||
</dict>
|
||||
</plist>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
57
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal file
57
apps/ios/Tests/OnboardingStateStoreTests.swift
Normal file
@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OnboardingStateStoreTests {
|
||||
@Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func doesNotPresentWhenConnected() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = "gateway"
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func markCompletedPersistsMode() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
|
||||
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
|
||||
OnboardingStateStore.markIncomplete(defaults: defaults)
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
private struct TestDefaults {
|
||||
var suiteName: String
|
||||
var defaults: UserDefaults
|
||||
}
|
||||
|
||||
private func makeDefaults() -> TestDefaults {
|
||||
let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
|
||||
return TestDefaults(
|
||||
suiteName: suiteName,
|
||||
defaults: UserDefaults(suiteName: suiteName) ?? .standard)
|
||||
}
|
||||
|
||||
private func reset(_ defaults: TestDefaults) {
|
||||
defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
|
||||
}
|
||||
}
|
||||
@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClaw
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.2.13"
|
||||
CFBundleVersion: "20260213"
|
||||
CFBundleShortVersionString: "2026.2.16"
|
||||
CFBundleVersion: "20260216"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: OpenClawTests
|
||||
CFBundleShortVersionString: "2026.2.13"
|
||||
CFBundleVersion: "20260213"
|
||||
CFBundleShortVersionString: "2026.2.16"
|
||||
CFBundleVersion: "20260216"
|
||||
|
||||
@ -110,8 +110,8 @@ struct AboutSettings: View {
|
||||
private var buildTimestamp: String? {
|
||||
guard
|
||||
let raw =
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String) ??
|
||||
(Bundle.main.object(forInfoDictionaryKey: "OpenClawBuildTimestamp") as? String)
|
||||
else { return nil }
|
||||
let parser = ISO8601DateFormatter()
|
||||
parser.formatOptions = [.withInternetDateTime]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
// Human-friendly age string (e.g., "2m ago").
|
||||
/// Human-friendly age string (e.g., "2m ago").
|
||||
func age(from date: Date, now: Date = .init()) -> String {
|
||||
let seconds = max(0, Int(now.timeIntervalSince(date)))
|
||||
let minutes = seconds / 60
|
||||
|
||||
@ -19,7 +19,7 @@ enum AgentWorkspace {
|
||||
]
|
||||
enum BootstrapSafety: Equatable {
|
||||
case safe
|
||||
case unsafe(reason: String)
|
||||
case unsafe (reason: String)
|
||||
}
|
||||
|
||||
static func displayPath(for url: URL) -> String {
|
||||
@ -72,7 +72,7 @@ enum AgentWorkspace {
|
||||
return .safe
|
||||
}
|
||||
if !isDir.boolValue {
|
||||
return .unsafe(reason: "Workspace path points to a file.")
|
||||
return .unsafe (reason: "Workspace path points to a file.")
|
||||
}
|
||||
let agentsURL = self.agentsURL(workspaceURL: workspaceURL)
|
||||
if fm.fileExists(atPath: agentsURL.path) {
|
||||
@ -82,9 +82,9 @@ enum AgentWorkspace {
|
||||
let entries = try self.workspaceEntries(workspaceURL: workspaceURL)
|
||||
return entries.isEmpty
|
||||
? .safe
|
||||
: .unsafe(reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||
: .unsafe (reason: "Folder isn't empty. Choose a new folder or add AGENTS.md first.")
|
||||
} catch {
|
||||
return .unsafe(reason: "Couldn't inspect the workspace folder.")
|
||||
return .unsafe (reason: "Couldn't inspect the workspace folder.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -234,9 +234,8 @@ enum OpenClawOAuthStore {
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
let home = FileManager().homeDirectoryForCurrentUser
|
||||
let preferred = home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
return home.appendingPathComponent(".openclaw", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
return preferred
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
|
||||
@ -1,18 +1,34 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
// Prefer the OpenClawKit wrapper to keep gateway request payloads consistent.
|
||||
typealias AnyCodable = OpenClawKit.AnyCodable
|
||||
typealias InstanceIdentity = OpenClawKit.InstanceIdentity
|
||||
|
||||
extension AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: AnyCodable]? { self.value as? [String: AnyCodable] }
|
||||
var arrayValue: [AnyCodable]? { self.value as? [AnyCodable] }
|
||||
var stringValue: String? {
|
||||
self.value as? String
|
||||
}
|
||||
|
||||
var boolValue: Bool? {
|
||||
self.value as? Bool
|
||||
}
|
||||
|
||||
var intValue: Int? {
|
||||
self.value as? Int
|
||||
}
|
||||
|
||||
var doubleValue: Double? {
|
||||
self.value as? Double
|
||||
}
|
||||
|
||||
var dictionaryValue: [String: AnyCodable]? {
|
||||
self.value as? [String: AnyCodable]
|
||||
}
|
||||
|
||||
var arrayValue: [AnyCodable]? {
|
||||
self.value as? [AnyCodable]
|
||||
}
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
@ -25,23 +41,3 @@ extension AnyCodable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawProtocol.AnyCodable {
|
||||
var stringValue: String? { self.value as? String }
|
||||
var boolValue: Bool? { self.value as? Bool }
|
||||
var intValue: Int? { self.value as? Int }
|
||||
var doubleValue: Double? { self.value as? Double }
|
||||
var dictionaryValue: [String: OpenClawProtocol.AnyCodable]? { self.value as? [String: OpenClawProtocol.AnyCodable] }
|
||||
var arrayValue: [OpenClawProtocol.AnyCodable]? { self.value as? [OpenClawProtocol.AnyCodable] }
|
||||
|
||||
var foundationValue: Any {
|
||||
switch self.value {
|
||||
case let dict as [String: OpenClawProtocol.AnyCodable]:
|
||||
dict.mapValues { $0.foundationValue }
|
||||
case let array as [OpenClawProtocol.AnyCodable]:
|
||||
array.map(\.foundationValue)
|
||||
default:
|
||||
self.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -422,11 +422,10 @@ final class AppState {
|
||||
let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
|
||||
let port = parsed.port
|
||||
let assembled: String
|
||||
if let user {
|
||||
assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
let assembled: String = if let user {
|
||||
port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
|
||||
} else {
|
||||
assembled = port == 22 ? host : "\(host):\(port)"
|
||||
port == 22 ? host : "\(host):\(port)"
|
||||
}
|
||||
if assembled != self.remoteTarget {
|
||||
self.remoteTarget = assembled
|
||||
@ -698,7 +697,9 @@ extension AppState {
|
||||
@MainActor
|
||||
enum AppStateStore {
|
||||
static let shared = AppState()
|
||||
static var isPausedFlag: Bool { UserDefaults.standard.bool(forKey: pauseDefaultsKey) }
|
||||
static var isPausedFlag: Bool {
|
||||
UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||
}
|
||||
|
||||
static func updateLaunchAtLogin(enabled: Bool) {
|
||||
Task.detached(priority: .utility) {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import AVFoundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
actor CameraCaptureService {
|
||||
@ -106,14 +106,16 @@ actor CameraCaptureService {
|
||||
}
|
||||
withExtendedLifetime(delegate) {}
|
||||
|
||||
let maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
let maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
let res = try JPEGTranscoder.transcodeToJPEG(
|
||||
imageData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality,
|
||||
maxBytes: maxEncodedBytes)
|
||||
let res: (data: Data, widthPx: Int, heightPx: Int)
|
||||
do {
|
||||
res = try PhotoCapture.transcodeJPEGForGateway(
|
||||
rawData: rawData,
|
||||
maxWidthPx: maxWidth,
|
||||
quality: quality)
|
||||
} catch {
|
||||
throw CameraError.captureFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
return (data: res.data, size: CGSize(width: res.widthPx, height: res.heightPx))
|
||||
}
|
||||
|
||||
@ -355,8 +357,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishProcessingPhoto photo: AVCapturePhoto,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
self.cont = nil
|
||||
@ -378,8 +380,8 @@ private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegat
|
||||
func photoOutput(
|
||||
_ output: AVCapturePhotoOutput,
|
||||
didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings,
|
||||
error: Error?)
|
||||
{
|
||||
error: Error?
|
||||
) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume, let cont else { return }
|
||||
self.didResume = true
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
@ -39,7 +39,9 @@ final class HoverChromeContainerView: NSView {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func updateTrackingAreas() {
|
||||
super.updateTrackingAreas()
|
||||
@ -60,14 +62,18 @@ final class HoverChromeContainerView: NSView {
|
||||
self.window?.performDrag(with: event)
|
||||
}
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private final class CanvasResizeHandleView: NSView {
|
||||
private var startPoint: NSPoint = .zero
|
||||
private var startFrame: NSRect = .zero
|
||||
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool { true }
|
||||
override func acceptsFirstMouse(for _: NSEvent?) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard let window else { return }
|
||||
@ -102,7 +108,9 @@ final class HoverChromeContainerView: NSView {
|
||||
private let resizeHandle = CanvasResizeHandleView(frame: .zero)
|
||||
|
||||
private final class PassthroughVisualEffectView: NSVisualEffectView {
|
||||
override func hitTest(_: NSPoint) -> NSView? { nil }
|
||||
override func hitTest(_: NSPoint) -> NSView? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private let closeBackground: NSVisualEffectView = {
|
||||
@ -190,7 +198,9 @@ final class HoverChromeContainerView: NSView {
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// When the chrome is hidden, do not intercept any mouse events (let the WKWebView receive them).
|
||||
|
||||
@ -1,17 +1,13 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CanvasFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.canvaswatcher")
|
||||
self.onChange = onChange
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [url.path],
|
||||
queueLabel: "ai.openclaw.canvaswatcher",
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -19,76 +15,10 @@ final class CanvasFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CanvasFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.url.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CanvasFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, _, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CanvasFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(numEvents: Int, eventFlags: UnsafePointer<FSEventStreamEventFlags>?) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
import WebKit
|
||||
|
||||
|
||||
@ -11,8 +11,13 @@ enum CanvasLayout {
|
||||
}
|
||||
|
||||
final class CanvasPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { true }
|
||||
override var canBecomeKey: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override var canBecomeMain: Bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
enum CanvasPresentation {
|
||||
|
||||
@ -19,7 +19,8 @@ extension CanvasWindowController {
|
||||
// Deep links: allow local Canvas content to invoke the agent without bouncing through NSWorkspace.
|
||||
if scheme == "openclaw" {
|
||||
if let currentScheme = self.webView.url?.scheme,
|
||||
CanvasScheme.allSchemes.contains(currentScheme) {
|
||||
CanvasScheme.allSchemes.contains(currentScheme)
|
||||
{
|
||||
Task { await DeepLinkHandler.shared.handle(url: url) }
|
||||
} else {
|
||||
canvasWindowLogger
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import OpenClawIPC
|
||||
import OpenClawKit
|
||||
import Foundation
|
||||
import WebKit
|
||||
|
||||
@MainActor
|
||||
@ -183,7 +183,9 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") }
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
@MainActor deinit {
|
||||
for name in CanvasA2UIActionMessageHandler.allMessageNames {
|
||||
|
||||
@ -10,7 +10,6 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func channelHeaderActions(_ channel: ChannelItem) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if channel.id == "whatsapp" {
|
||||
@ -88,7 +87,6 @@ extension ChannelsSettings {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func genericChannelSection(_ channel: ChannelItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
self.configEditorSection(channelId: channel.id)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension ChannelsStore {
|
||||
func loadConfigSchema() async {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawProtocol
|
||||
|
||||
struct ChannelsStatusSnapshot: Codable {
|
||||
struct WhatsAppSelf: Codable {
|
||||
|
||||
111
apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
Normal file
111
apps/macos/Sources/OpenClaw/CoalescingFSEventsWatcher.swift
Normal file
@ -0,0 +1,111 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class CoalescingFSEventsWatcher: @unchecked Sendable {
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
|
||||
private let paths: [String]
|
||||
private let shouldNotify: (Int, UnsafeMutableRawPointer?) -> Bool
|
||||
private let onChange: () -> Void
|
||||
private let coalesceDelay: TimeInterval
|
||||
|
||||
init(
|
||||
paths: [String],
|
||||
queueLabel: String,
|
||||
coalesceDelay: TimeInterval = 0.12,
|
||||
shouldNotify: @escaping (Int, UnsafeMutableRawPointer?) -> Bool = { _, _ in true },
|
||||
onChange: @escaping () -> Void
|
||||
) {
|
||||
self.paths = paths
|
||||
self.queue = DispatchQueue(label: queueLabel)
|
||||
self.coalesceDelay = coalesceDelay
|
||||
self.shouldNotify = shouldNotify
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.stop()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = self.paths as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension CoalescingFSEventsWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<CoalescingFSEventsWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(numEvents: numEvents, eventPaths: eventPaths, eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?
|
||||
) {
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.shouldNotify(numEvents, eventPaths) else { return }
|
||||
|
||||
// Coalesce rapid changes (common during builds/atomic saves).
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + self.coalesceDelay) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
import CoreServices
|
||||
import Foundation
|
||||
|
||||
final class ConfigFileWatcher: @unchecked Sendable {
|
||||
private let url: URL
|
||||
private let queue: DispatchQueue
|
||||
private var stream: FSEventStreamRef?
|
||||
private var pending = false
|
||||
private let onChange: () -> Void
|
||||
private let watchedDir: URL
|
||||
private let targetPath: String
|
||||
private let targetName: String
|
||||
private let watcher: CoalescingFSEventsWatcher
|
||||
|
||||
init(url: URL, onChange: @escaping () -> Void) {
|
||||
self.url = url
|
||||
self.queue = DispatchQueue(label: "ai.openclaw.configwatcher")
|
||||
self.onChange = onChange
|
||||
self.watchedDir = url.deletingLastPathComponent()
|
||||
self.targetPath = url.path
|
||||
self.targetName = url.lastPathComponent
|
||||
let watchedDirPath = self.watchedDir.path
|
||||
let targetPath = self.targetPath
|
||||
let targetName = self.targetName
|
||||
self.watcher = CoalescingFSEventsWatcher(
|
||||
paths: [watchedDirPath],
|
||||
queueLabel: "ai.openclaw.configwatcher",
|
||||
shouldNotify: { _, eventPaths in
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == targetPath { return true }
|
||||
if path.hasSuffix("/\(targetName)") { return true }
|
||||
if path == watchedDirPath { return true }
|
||||
}
|
||||
return false
|
||||
},
|
||||
onChange: onChange)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@ -25,94 +36,10 @@ final class ConfigFileWatcher: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.stream == nil else { return }
|
||||
|
||||
let retainedSelf = Unmanaged.passRetained(self)
|
||||
var context = FSEventStreamContext(
|
||||
version: 0,
|
||||
info: retainedSelf.toOpaque(),
|
||||
retain: nil,
|
||||
release: { pointer in
|
||||
guard let pointer else { return }
|
||||
Unmanaged<ConfigFileWatcher>.fromOpaque(pointer).release()
|
||||
},
|
||||
copyDescription: nil)
|
||||
|
||||
let paths = [self.watchedDir.path] as CFArray
|
||||
let flags = FSEventStreamCreateFlags(
|
||||
kFSEventStreamCreateFlagFileEvents |
|
||||
kFSEventStreamCreateFlagUseCFTypes |
|
||||
kFSEventStreamCreateFlagNoDefer)
|
||||
|
||||
guard let stream = FSEventStreamCreate(
|
||||
kCFAllocatorDefault,
|
||||
Self.callback,
|
||||
&context,
|
||||
paths,
|
||||
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
||||
0.05,
|
||||
flags)
|
||||
else {
|
||||
retainedSelf.release()
|
||||
return
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
FSEventStreamSetDispatchQueue(stream, self.queue)
|
||||
if FSEventStreamStart(stream) == false {
|
||||
self.stream = nil
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
self.watcher.start()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let stream = self.stream else { return }
|
||||
self.stream = nil
|
||||
FSEventStreamStop(stream)
|
||||
FSEventStreamSetDispatchQueue(stream, nil)
|
||||
FSEventStreamInvalidate(stream)
|
||||
FSEventStreamRelease(stream)
|
||||
}
|
||||
}
|
||||
|
||||
extension ConfigFileWatcher {
|
||||
private static let callback: FSEventStreamCallback = { _, info, numEvents, eventPaths, eventFlags, _ in
|
||||
guard let info else { return }
|
||||
let watcher = Unmanaged<ConfigFileWatcher>.fromOpaque(info).takeUnretainedValue()
|
||||
watcher.handleEvents(
|
||||
numEvents: numEvents,
|
||||
eventPaths: eventPaths,
|
||||
eventFlags: eventFlags)
|
||||
}
|
||||
|
||||
private func handleEvents(
|
||||
numEvents: Int,
|
||||
eventPaths: UnsafeMutableRawPointer?,
|
||||
eventFlags: UnsafePointer<FSEventStreamEventFlags>?)
|
||||
{
|
||||
guard numEvents > 0 else { return }
|
||||
guard eventFlags != nil else { return }
|
||||
guard self.matchesTarget(eventPaths: eventPaths) else { return }
|
||||
|
||||
if self.pending { return }
|
||||
self.pending = true
|
||||
self.queue.asyncAfter(deadline: .now() + 0.12) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.pending = false
|
||||
self.onChange()
|
||||
}
|
||||
}
|
||||
|
||||
private func matchesTarget(eventPaths: UnsafeMutableRawPointer?) -> Bool {
|
||||
guard let eventPaths else { return true }
|
||||
let paths = unsafeBitCast(eventPaths, to: NSArray.self)
|
||||
for case let path as String in paths {
|
||||
if path == self.targetPath { return true }
|
||||
if path.hasSuffix("/\(self.targetName)") { return true }
|
||||
if path == self.watchedDir.path { return true }
|
||||
}
|
||||
return false
|
||||
self.watcher.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,11 +39,26 @@ struct ConfigSchemaNode {
|
||||
self.raw = dict
|
||||
}
|
||||
|
||||
var title: String? { self.raw["title"] as? String }
|
||||
var description: String? { self.raw["description"] as? String }
|
||||
var enumValues: [Any]? { self.raw["enum"] as? [Any] }
|
||||
var constValue: Any? { self.raw["const"] }
|
||||
var explicitDefault: Any? { self.raw["default"] }
|
||||
var title: String? {
|
||||
self.raw["title"] as? String
|
||||
}
|
||||
|
||||
var description: String? {
|
||||
self.raw["description"] as? String
|
||||
}
|
||||
|
||||
var enumValues: [Any]? {
|
||||
self.raw["enum"] as? [Any]
|
||||
}
|
||||
|
||||
var constValue: Any? {
|
||||
self.raw["const"]
|
||||
}
|
||||
|
||||
var explicitDefault: Any? {
|
||||
self.raw["default"]
|
||||
}
|
||||
|
||||
var requiredKeys: Set<String> {
|
||||
Set((self.raw["required"] as? [String]) ?? [])
|
||||
}
|
||||
|
||||
@ -45,7 +45,9 @@ extension ConfigSettings {
|
||||
let help: String?
|
||||
let node: ConfigSchemaNode
|
||||
|
||||
var id: String { self.key }
|
||||
var id: String {
|
||||
self.key
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConfigSubsection: Identifiable {
|
||||
@ -55,7 +57,9 @@ extension ConfigSettings {
|
||||
let node: ConfigSchemaNode
|
||||
let path: ConfigPath
|
||||
|
||||
var id: String { self.key }
|
||||
var id: String {
|
||||
self.key
|
||||
}
|
||||
}
|
||||
|
||||
private var sections: [ConfigSection] {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user