mirror of https://github.com/openclaw/openclaw.git
Merge branch 'main' into main
This commit is contained in:
commit
2deff6c533
|
|
@ -1,12 +1,16 @@
|
||||||
name: Setup Node environment
|
name: Setup Node environment
|
||||||
description: >
|
description: >
|
||||||
Initialize submodules with retry, install Node 22, pnpm, optionally Bun,
|
Initialize submodules with retry, install Node 24 by default, pnpm, optionally Bun,
|
||||||
and optionally run pnpm install. Requires actions/checkout to run first.
|
and optionally run pnpm install. Requires actions/checkout to run first.
|
||||||
inputs:
|
inputs:
|
||||||
node-version:
|
node-version:
|
||||||
description: Node.js version to install.
|
description: Node.js version to install.
|
||||||
required: false
|
required: false
|
||||||
default: "22.x"
|
default: "24.x"
|
||||||
|
cache-key-suffix:
|
||||||
|
description: Suffix appended to the pnpm store cache key.
|
||||||
|
required: false
|
||||||
|
default: "node24"
|
||||||
pnpm-version:
|
pnpm-version:
|
||||||
description: pnpm version for corepack.
|
description: pnpm version for corepack.
|
||||||
required: false
|
required: false
|
||||||
|
|
@ -16,7 +20,7 @@ inputs:
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: "true"
|
||||||
use-sticky-disk:
|
use-sticky-disk:
|
||||||
description: Use Blacksmith sticky disks for pnpm store caching.
|
description: Request Blacksmith sticky-disk pnpm caching on trusted runs; pull_request runs fall back to actions/cache.
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
install-deps:
|
install-deps:
|
||||||
|
|
@ -54,7 +58,7 @@ runs:
|
||||||
uses: ./.github/actions/setup-pnpm-store-cache
|
uses: ./.github/actions/setup-pnpm-store-cache
|
||||||
with:
|
with:
|
||||||
pnpm-version: ${{ inputs.pnpm-version }}
|
pnpm-version: ${{ inputs.pnpm-version }}
|
||||||
cache-key-suffix: "node22"
|
cache-key-suffix: ${{ inputs.cache-key-suffix }}
|
||||||
use-sticky-disk: ${{ inputs.use-sticky-disk }}
|
use-sticky-disk: ${{ inputs.use-sticky-disk }}
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ inputs:
|
||||||
cache-key-suffix:
|
cache-key-suffix:
|
||||||
description: Suffix appended to the cache key.
|
description: Suffix appended to the cache key.
|
||||||
required: false
|
required: false
|
||||||
default: "node22"
|
default: "node24"
|
||||||
use-sticky-disk:
|
use-sticky-disk:
|
||||||
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store.
|
description: Use Blacksmith sticky disks instead of actions/cache for pnpm store on trusted runs; pull_request runs fall back to actions/cache.
|
||||||
required: false
|
required: false
|
||||||
default: "false"
|
default: "false"
|
||||||
use-restore-keys:
|
use-restore-keys:
|
||||||
|
|
@ -18,7 +18,7 @@ inputs:
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: "true"
|
||||||
use-actions-cache:
|
use-actions-cache:
|
||||||
description: Whether to restore/save pnpm store with actions/cache.
|
description: Whether to restore/save pnpm store with actions/cache, including pull_request fallback when sticky disks are disabled.
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: "true"
|
||||||
runs:
|
runs:
|
||||||
|
|
@ -51,21 +51,23 @@ runs:
|
||||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Mount pnpm store sticky disk
|
- name: Mount pnpm store sticky disk
|
||||||
if: inputs.use-sticky-disk == 'true'
|
# Keep persistent sticky-disk state off untrusted PR runs.
|
||||||
|
if: inputs.use-sticky-disk == 'true' && github.event_name != 'pull_request'
|
||||||
uses: useblacksmith/stickydisk@v1
|
uses: useblacksmith/stickydisk@v1
|
||||||
with:
|
with:
|
||||||
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ inputs.cache-key-suffix }}
|
key: ${{ github.repository }}-pnpm-store-${{ runner.os }}-${{ github.ref_name }}-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
|
|
||||||
- name: Restore pnpm store cache (exact key only)
|
- name: Restore pnpm store cache (exact key only)
|
||||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys != 'true'
|
# PRs that request sticky disks still need a safe cache restore path.
|
||||||
|
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys != 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ inputs.cache-key-suffix }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
||||||
- name: Restore pnpm store cache (with fallback keys)
|
- name: Restore pnpm store cache (with fallback keys)
|
||||||
if: inputs.use-actions-cache == 'true' && inputs.use-sticky-disk != 'true' && inputs.use-restore-keys == 'true'
|
if: inputs.use-actions-cache == 'true' && (inputs.use-sticky-disk != 'true' || github.event_name == 'pull_request') && inputs.use-restore-keys == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-store.outputs.path }}
|
path: ${{ steps.pnpm-store.outputs.path }}
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,40 @@ jobs:
|
||||||
- name: Check docs
|
- name: Check docs
|
||||||
run: pnpm check:docs
|
run: pnpm check:docs
|
||||||
|
|
||||||
|
compat-node22:
|
||||||
|
name: "compat-node22"
|
||||||
|
needs: [docs-scope, changed-scope]
|
||||||
|
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
|
||||||
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Setup Node 22 compatibility environment
|
||||||
|
uses: ./.github/actions/setup-node-env
|
||||||
|
with:
|
||||||
|
node-version: "22.x"
|
||||||
|
cache-key-suffix: "node22"
|
||||||
|
install-bun: "false"
|
||||||
|
use-sticky-disk: "true"
|
||||||
|
|
||||||
|
- name: Configure Node 22 test resources
|
||||||
|
run: |
|
||||||
|
# Keep the compatibility lane aligned with the default Node test lane.
|
||||||
|
echo "OPENCLAW_TEST_WORKERS=2" >> "$GITHUB_ENV"
|
||||||
|
echo "OPENCLAW_TEST_MAX_OLD_SPACE_SIZE_MB=6144" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build under Node 22
|
||||||
|
run: pnpm build
|
||||||
|
|
||||||
|
- name: Run tests under Node 22
|
||||||
|
run: pnpm test
|
||||||
|
|
||||||
|
- name: Verify npm pack under Node 22
|
||||||
|
run: pnpm release:check
|
||||||
|
|
||||||
skills-python:
|
skills-python:
|
||||||
needs: [docs-scope, changed-scope]
|
needs: [docs-scope, changed-scope]
|
||||||
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true')
|
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true' || needs.changed-scope.outputs.run_skills_python == 'true')
|
||||||
|
|
@ -401,14 +435,14 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 24.x
|
||||||
check-latest: false
|
check-latest: false
|
||||||
|
|
||||||
- name: Setup pnpm + cache store
|
- name: Setup pnpm + cache store
|
||||||
uses: ./.github/actions/setup-pnpm-store-cache
|
uses: ./.github/actions/setup-pnpm-store-cache
|
||||||
with:
|
with:
|
||||||
pnpm-version: "10.23.0"
|
pnpm-version: "10.23.0"
|
||||||
cache-key-suffix: "node22"
|
cache-key-suffix: "node24"
|
||||||
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
# Sticky disk mount currently retries/fails on every shard and adds ~50s
|
||||||
# before install while still yielding zero pnpm store reuse.
|
# before install while still yielding zero pnpm store reuse.
|
||||||
# Try exact-key actions/cache restores instead to recover store reuse
|
# Try exact-key actions/cache restores instead to recover store reuse
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Builder
|
- name: Set up Docker Builder
|
||||||
uses: useblacksmith/setup-docker-builder@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|
@ -137,7 +137,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Builder
|
- name: Set up Docker Builder
|
||||||
uses: useblacksmith/setup-docker-builder@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Builder
|
- name: Set up Docker Builder
|
||||||
uses: useblacksmith/setup-docker-builder@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
# Blacksmith can fall back to the local docker driver, which rejects gha
|
# Blacksmith can fall back to the local docker driver, which rejects gha
|
||||||
# cache export/import. Keep smoke builds driver-agnostic.
|
# cache export/import. Keep smoke builds driver-agnostic.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ concurrency:
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "22.x"
|
NODE_VERSION: "24.x"
|
||||||
PNPM_VERSION: "10.23.0"
|
PNPM_VERSION: "10.23.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ jobs:
|
||||||
submodules: false
|
submodules: false
|
||||||
|
|
||||||
- name: Set up Docker Builder
|
- name: Set up Docker Builder
|
||||||
uses: useblacksmith/setup-docker-builder@v1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build minimal sandbox base (USER sandbox)
|
- name: Build minimal sandbox base (USER sandbox)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,11 @@ dist/protocol.schema.json
|
||||||
# Synthing
|
# Synthing
|
||||||
**/.stfolder/
|
**/.stfolder/
|
||||||
.dev-state
|
.dev-state
|
||||||
|
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
|
||||||
|
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
||||||
|
.gitignore
|
||||||
|
test/config-form.analyze.telegram.test.ts
|
||||||
|
ui/src/ui/theme-variants.browser.test.ts
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
ui/src/ui/__screenshots__/navigation.browser.test.ts/control-UI-routing-auto-scrolls-chat-history-to-the-latest-message-1.png
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
**/node_modules/
|
||||||
|
|
@ -12991,7 +12991,7 @@
|
||||||
"filename": "ui/src/i18n/locales/en.ts",
|
"filename": "ui/src/i18n/locales/en.ts",
|
||||||
"hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6",
|
"hashed_secret": "de0ff6b974d6910aca8d6b830e1b761f076d8fe6",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 61
|
"line_number": 74
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ui/src/i18n/locales/pt-BR.ts": [
|
"ui/src/i18n/locales/pt-BR.ts": [
|
||||||
|
|
@ -13000,7 +13000,7 @@
|
||||||
"filename": "ui/src/i18n/locales/pt-BR.ts",
|
"filename": "ui/src/i18n/locales/pt-BR.ts",
|
||||||
"hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243",
|
"hashed_secret": "ef7b6f95faca2d7d3a5aa5a6434c89530c6dd243",
|
||||||
"is_verified": false,
|
"is_verified": false,
|
||||||
"line_number": 61
|
"line_number": 73
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vendor/a2ui/README.md": [
|
"vendor/a2ui/README.md": [
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@
|
||||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
- 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.
|
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||||
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
- Naming: use **OpenClaw** for product/app/docs headings; use `openclaw` for CLI command, package/binary, paths, and config keys.
|
||||||
|
- Written English: use American spelling and grammar in code, comments, docs, and UI strings (e.g. "color" not "colour", "behavior" not "behaviour", "analyze" not "analyse").
|
||||||
|
|
||||||
## Release Channels (Naming)
|
## Release Channels (Naming)
|
||||||
|
|
||||||
|
|
|
||||||
265
CHANGELOG.md
265
CHANGELOG.md
|
|
@ -4,17 +4,121 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||||
|
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||||
|
- Agents/compaction: compare post-compaction token sanity checks against full-session pre-compaction totals and skip the check when token estimation fails, so sessions with large bootstrap context keep real token counts instead of falling back to unknown. (#28347) thanks @efe-arv.
|
||||||
|
- Discord/gateway startup: treat plain-text and transient `/gateway/bot` metadata fetch failures as transient startup errors so Discord gateway boot no longer crashes on unhandled rejections. (#44397) Thanks @jalehman.
|
||||||
|
|
||||||
|
## 2026.3.12
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
- Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.
|
||||||
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
- OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across `/fast`, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.
|
||||||
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
- Anthropic/Claude fast mode: map the shared `/fast` toggle and `params.fastMode` to direct Anthropic API-key `service_tier` requests, with live verification for both Anthropic and OpenAI fast-mode tiers.
|
||||||
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
- Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.
|
||||||
|
- Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi
|
||||||
|
- Agents/subagents: add `sessions_yield` so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff
|
||||||
|
- Slack/agent replies: support `channelData.slack.blocks` in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Security/device pairing: switch `/pair` and `openclaw qr` setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.
|
||||||
|
- Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (`GHSA-99qw-6mr3-36qr`)(#44174) Thanks @lintsinghua and @vincentkoc.
|
||||||
|
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||||
|
- TUI/chat log: reuse the active assistant message component for the same streaming run so `openclaw tui` no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.
|
||||||
|
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||||
|
- Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.
|
||||||
|
- Models/Kimi Coding: send the built-in `User-Agent: claude-code/0.1.0` header by default for `kimi-coding` while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.
|
||||||
|
- Models/OpenAI Codex Spark: keep `gpt-5.3-codex-spark` working on the `openai-codex/*` path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct `openai/*` Spark row that OpenAI rejects live.
|
||||||
|
- Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like `kimi-k2.5:cloud`, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.
|
||||||
|
- Moonshot CN API: respect explicit `baseUrl` (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.
|
||||||
|
- Kimi Coding/provider config: respect explicit `models.providers["kimi-coding"].baseUrl` when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.
|
||||||
|
- Gateway/main-session routing: keep TUI and other `mode:UI` main-session sends on the internal surface when `deliver` is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.
|
||||||
|
- BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching `fromMe` event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.
|
||||||
|
- iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching `is_from_me` event was just seen for the same chat, text, and `created_at`, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.
|
||||||
|
- Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.
|
||||||
|
- Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding `replyToId` from the block reply dedup key and adding an explicit `threading` dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.
|
||||||
|
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||||
|
- macOS/Reminders: add the missing `NSRemindersUsageDescription` to the bundled app so `apple-reminders` can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.
|
||||||
|
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
|
||||||
|
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||||
|
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
|
||||||
|
- Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so `openclaw update` no longer dies early on missing `git` or `node-llama-cpp` download setup.
|
||||||
|
- Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed `write` no longer reports success while creating empty files. (#43876) Thanks @glitch418x.
|
||||||
|
- Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible `\u{...}` escapes instead of spoofing the reviewed command. (`GHSA-pcqg-f7rg-xfvv`)(#43687) Thanks @EkiXu and @vincentkoc.
|
||||||
|
- Hooks/loader: fail closed when workspace hook paths cannot be resolved with `realpath`, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.
|
||||||
|
- Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.
|
||||||
|
- Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (`GHSA-9r3v-37xh-2cf6`)(#44091) Thanks @wooluo and @vincentkoc.
|
||||||
|
- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
|
||||||
|
- Security/commands: require sender ownership for `/config` and `/debug` so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (`GHSA-r7vr-gr74-94p8`)(#44305) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (`GHSA-rqpp-rjj8-7wv8`)(#44306) Thanks @LUOYEcode and @vincentkoc.
|
||||||
|
- Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via `session_status`. (`GHSA-wcxr-59v9-rxr8`)(#43754) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Security/agent tools: mark `nodes` as explicitly owner-only and document/test that `canvas` remains a shared trusted-operator surface unless a real boundary bypass exists.
|
||||||
|
- Security/exec approvals: fail closed for Ruby approval flows that use `-r`, `--require`, or `-I` so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.
|
||||||
|
- Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (`GHSA-2pwv-x786-56f8`)(#43686) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Docs/onboarding: align the legacy wizard reference and `openclaw onboard` command docs with the Ollama onboarding flow so all onboarding reference paths now document `--auth-choice ollama`, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.
|
||||||
|
- Models/secrets: enforce source-managed SecretRef markers in generated `models.json` so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.
|
||||||
|
- Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc.
|
||||||
|
- Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (`GHSA-6rph-mmhp-h7h9`)(#43684) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Security/host env: block inherited `GIT_EXEC_PATH` from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (`GHSA-jf5v-pqgw-gm5m`)(#43685) Thanks @zpbrent and @vincentkoc.
|
||||||
|
- Security/Feishu webhook: require `encryptKey` alongside `verificationToken` in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (`GHSA-g353-mgv3-8pcj`)(#44087) Thanks @lintsinghua and @vincentkoc.
|
||||||
|
- Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc.
|
||||||
|
- Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
|
||||||
|
- Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth `429` responses. (`GHSA-5m9r-p9g7-679c`)(#44173) Thanks @zpbrent and @vincentkoc.
|
||||||
|
- Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind `channels.zalouser.dangerouslyAllowNameMatching`. Thanks @zpbrent.
|
||||||
|
- Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's `dangerouslyAllowNameMatching` break-glass flag.
|
||||||
|
- Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap `pnpm`/`npm exec`/`npx` script runners before approval binding. (`GHSA-57jw-9722-6rf2`)(`GHSA-jvqh-rfmh-jh27`)(`GHSA-x7pp-23xv-mmr4`)(`GHSA-jc5j-vg4r-j5jx`)(#44247) Thanks @tdjackey and @vincentkoc.
|
||||||
|
- Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.
|
||||||
|
- Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.
|
||||||
|
- Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.
|
||||||
|
- Context engine/session routing: forward optional `sessionKey` through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.
|
||||||
|
- Agents/failover: classify z.ai `network_error` stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.
|
||||||
|
- Memory/session sync: add mode-aware post-compaction session reindexing with `agents.defaults.compaction.postIndexSync` plus `agents.defaults.memorySearch.sync.sessions.postCompactionForce`, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.
|
||||||
|
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||||
|
- Telegram/native command sync: suppress expected `BOT_COMMANDS_TOO_MUCH` retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.
|
||||||
|
- Mattermost/reply media delivery: pass agent-scoped `mediaLocalRoots` through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.
|
||||||
|
- Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process `HOME`/`OPENCLAW_HOME` changes no longer reuse stale plugin state or misreport `~/...` plugins as untracked. (#44046) thanks @gumadeiras.
|
||||||
|
- Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated `session.store` roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.
|
||||||
|
- Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write.
|
||||||
|
- Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted.
|
||||||
|
- Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.
|
||||||
|
- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.
|
||||||
|
- Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.
|
||||||
|
- Cron/doctor: stop flagging canonical `agentTurn` and `systemEvent` payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.
|
||||||
|
- ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving `end_turn`, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.
|
||||||
|
- Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.
|
||||||
|
|
||||||
|
## 2026.3.11
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
|
||||||
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
|
- iOS/Home canvas: add a bundled welcome screen with a live agent overview that refreshes on connect, reconnect, and foreground return, and move the compact connection pill off the top-left canvas overlay. (#42456) Thanks @ngutman.
|
||||||
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
|
- iOS/Home canvas: replace floating controls with a docked toolbar, make the bundled home scaffold adapt to smaller phones, and open chat in the resolved main session instead of a synthetic `ios` session. (#42456) Thanks @ngutman.
|
||||||
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
|
|
||||||
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
|
|
||||||
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
|
- macOS/chat UI: add a chat model picker, persist explicit thinking-level selections across relaunch, and harden provider-aware session model sync for the shared chat composer. (#42314) Thanks @ImLukeF.
|
||||||
|
- Onboarding/Ollama: add first-class Ollama setup with Local or Cloud + Local modes, browser-based cloud sign-in, curated model suggestions, and cloud-model handling that skips unnecessary local pulls. (#41529) Thanks @BruceMacD.
|
||||||
|
- OpenCode/onboarding: add new OpenCode Go provider, treat Zen and Go as one OpenCode setup in the wizard/docs while keeping the runtime providers split, store one shared OpenCode key for both profiles, and stop overriding the built-in `opencode-go` catalog routing. (#42313) Thanks @ImLukeF and @vincentkoc.
|
||||||
|
- Memory: add opt-in multimodal image and audio indexing for `memorySearch.extraPaths` with Gemini `gemini-embedding-2-preview`, strict fallback gating, and scope-based reindexing. (#43460) Thanks @gumadeiras.
|
||||||
|
- Memory/Gemini: add `gemini-embedding-2-preview` memory-search support with configurable output dimensions and automatic reindexing when the configured dimensions change. (#42501) Thanks @BillChirico and @gumadeiras.
|
||||||
|
- macOS/onboarding: detect when remote gateways need a shared auth token, explain where to find it on the gateway host, and clarify when a successful check used paired-device auth instead. (#43100) Thanks @ngutman.
|
||||||
|
- Discord/auto threads: add `autoArchiveDuration` channel config for auto-created threads so Discord thread archiving can stay at 1 hour, 1 day, 3 days, or 1 week instead of always using the 1-hour default. (#35065) Thanks @davidguttman.
|
||||||
|
- iOS/TestFlight: add a local beta release flow with Fastlane prepare/archive/upload support, canonical beta bundle IDs, and watch-app archive fixes. (#42991) Thanks @ngutman.
|
||||||
|
- ACP/sessions_spawn: add optional `resumeSessionId` for `runtime: "acp"` so spawned ACP sessions can resume an existing ACPX/Codex conversation instead of always starting fresh. (#41847) Thanks @pejmanjohn.
|
||||||
|
- Gateway/node pending work: add narrow in-memory pending-work queue primitives (`node.pending.enqueue` / `node.pending.drain`) and wake-helper reuse as a foundation for dormant-node work delivery. (#41409) Thanks @mbelinky.
|
||||||
|
- Git/runtime state: ignore the gateway-generated `.dev-state` file so local runtime state does not show up as untracked repo noise. (#41848) Thanks @smysle.
|
||||||
|
- Exec/child commands: mark child command environments with `OPENCLAW_CLI` so subprocesses can detect when they were launched from the OpenClaw CLI. (#41411) Thanks @vincentkoc.
|
||||||
|
- LLM Task/Lobster: add an optional `thinking` override so workflow calls can explicitly set embedded reasoning level with shared validation for invalid values and unsupported `xhigh` modes. (#15606) Thanks @xadenryan and @ImLukeF.
|
||||||
|
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
|
||||||
|
- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
|
|
@ -22,76 +126,112 @@ Docs: https://docs.openclaw.ai
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
|
||||||
|
- Windows/update: mirror the native installer environment during global npm updates, including portable Git fallback and Windows-safe npm shell settings, so `openclaw update` works again on native Windows installs.
|
||||||
|
- Gateway/status: expose `runtimeVersion` in gateway status output so install/update smoke tests can verify the running version before and after updates.
|
||||||
|
- Windows/onboarding: explain when non-interactive local onboarding is waiting for an already-running gateway, and surface native Scheduled Task admin requirements more clearly instead of failing with an opaque gateway timeout.
|
||||||
|
- Windows/gateway install: fall back from denied Scheduled Task creation to a per-user Startup-folder login item, so native `openclaw gateway install` and `--install-daemon` keep working without an elevated PowerShell shell.
|
||||||
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
|
- Agents/text sanitization: strip leaked model control tokens (`<|...|>` and full-width `<|...|>` variants) from user-facing assistant text, preventing GLM-5 and DeepSeek internal delimiters from reaching end users. (#42173) Thanks @imwyvern.
|
||||||
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
||||||
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
|
||||||
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
|
||||||
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
|
||||||
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
- Gateway/Control UI: keep dashboard auth tokens in session-scoped browser storage so same-tab refreshes preserve remote token auth without restoring long-lived localStorage token persistence, while scoping tokens to the selected gateway URL and fragment-only bootstrap flow. (#40892) thanks @velvet-shark.
|
||||||
|
- Gateway/macOS launchd restarts: keep the LaunchAgent registered during explicit restarts, hand off self-restarts through a detached launchd helper, and recover config/hot reload restart paths without unloading the service. Fixes #43311, #43406, #43035, and #43049.
|
||||||
|
- macOS/LaunchAgent install: tighten LaunchAgent directory and plist permissions during install so launchd bootstrap does not fail when the target home path or generated plist inherited group/world-writable modes.
|
||||||
|
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
||||||
|
- Feishu/local image auto-convert: pass `mediaLocalRoots` through the `sendText` local-image shim so allowed local image paths upload as Feishu images again instead of falling back to raw path text. (#40623) Thanks @ayanesakura.
|
||||||
|
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
||||||
|
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
|
||||||
|
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
||||||
|
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
|
||||||
|
- Telegram/final preview cleanup follow-up: clear stale cleanup-retain state only for transient preview finals so archived-preview retains no longer leave a stale partial bubble beside a later fallback-sent final. (#41763) Thanks @obviyus.
|
||||||
|
- Telegram/poll restarts: scope process-level polling restarts to real Telegram `getUpdates` failures so unrelated network errors, such as Slack DNS misses, no longer bounce Telegram polling. (#43799) Thanks @obviyus.
|
||||||
|
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
|
||||||
|
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
|
||||||
|
- Agents/Azure OpenAI Responses: include the `azure-openai` provider in the Responses API store override so Azure OpenAI multi-turn cron jobs and embedded agent runs no longer fail with HTTP 400 "store is set to false". (#42934, fixes #42800) Thanks @ademczuk.
|
||||||
|
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
|
||||||
|
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
||||||
|
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
||||||
|
- Agents/fallback: recognize Venice `402 Insufficient USD or Diem balance` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#43205) Thanks @Squabble9.
|
||||||
|
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
|
||||||
|
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
|
||||||
|
- Agents/cooldowns: default cooldown windows with no recorded failure history to `unknown` instead of `rate_limit`, avoiding false API rate-limit warnings while preserving cooldown recovery probes. (#42911) Thanks @VibhorGautam.
|
||||||
|
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
||||||
|
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
||||||
|
- Agents/context pruning: prune image-only tool results during soft-trim, align context-pruning coverage with the new tool-result contract, and extend historical image cleanup to the same screenshot-heavy session path. (#43045) Thanks @MoerAI.
|
||||||
|
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
|
||||||
|
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
||||||
|
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
|
||||||
|
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
||||||
|
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
||||||
|
- CLI/skills JSON: strip ANSI and C1 control bytes from `skills list --json`, `skills info --json`, and `skills check --json` so machine-readable output stays valid for terminals and skill metadata with embedded control characters. Fixes #27530. Related #27557. Thanks @Jimmy-xuzimo and @vincentkoc.
|
||||||
|
- CLI/tables: default shared tables to ASCII borders on legacy Windows consoles while keeping Unicode borders on modern Windows terminals, so commands like `openclaw skills` stop rendering mojibake under GBK/936 consoles. Fixes #40853. Related #41015. Thanks @ApacheBin and @vincentkoc.
|
||||||
|
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
||||||
|
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
||||||
|
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
||||||
|
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
||||||
|
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
||||||
|
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
||||||
|
- Signal/config schema: accept `channels.signal.accountUuid` in strict config validation so loop-protection configs no longer fail with an unrecognized-key error. (#35578) Thanks @ingyukoh.
|
||||||
|
- Telegram/config schema: accept `channels.telegram.actions.editMessage` and `createForumTopic` in strict config validation so existing Telegram action toggles no longer fail as unrecognized keys. (#35498) Thanks @ingyukoh.
|
||||||
|
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
|
||||||
|
- Discord/config typing: expose channel-level `autoThread` on the canonical guild-channel config type so strict config loading matches the existing Discord schema and runtime behavior. (#35608) Thanks @ingyukoh.
|
||||||
|
- fix(models): guard optional model.input capability checks (#42096) thanks @andyliu
|
||||||
|
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
|
||||||
|
- Resolve web tool SecretRefs atomically at runtime. (#41599) Thanks @joshavant.
|
||||||
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
|
- Secret files: harden CLI and channel credential file reads against path-swap races by requiring direct regular files for `*File` secret inputs and rejecting symlink-backed secret files.
|
||||||
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
|
- Archive extraction: harden TAR and external `tar.bz2` installs against destination symlink and pre-existing child-symlink escapes by extracting into staging first and merging into the canonical destination with safe file opens.
|
||||||
- Models/Kimi Coding: send `anthropic-messages` tools in native Anthropic format again so `kimi-coding` stops degrading tool calls into XML/plain-text pseudo invocations instead of real `tool_use` blocks. (#38669, #39907, #40552) Thanks @opriz.
|
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
|
||||||
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
||||||
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
|
||||||
|
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
|
||||||
|
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
|
||||||
|
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
|
||||||
|
- Security/plugin runtime: stop unauthenticated plugin HTTP routes from inheriting synthetic admin gateway scopes when they call `runtime.subagent.*`, so admin-only methods like `sessions.delete` stay blocked without gateway auth.
|
||||||
|
- Security/nodes: treat the `nodes` agent tool as owner-only fallback policy so non-owner senders cannot reach paired-node approval or invoke paths through the shared tool set.
|
||||||
|
- Sandbox/sessions_spawn: restore real workspace handoff for read-only sandboxed sessions so spawned subagents mount the configured workspace at `/agent` instead of inheriting the sandbox copy. Related #40582.
|
||||||
|
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
||||||
|
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
||||||
|
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
|
||||||
|
- ACP/ACPX plugin: bump the bundled `acpx` pin to `0.1.16` so plugin-local installs and strict version checks match the latest published CLI. (#41975) Thanks @dutifulbob.
|
||||||
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
- ACP/sessions.patch: allow `spawnedBy` and `spawnDepth` lineage fields on ACP session keys so `sessions_spawn` with `runtime: "acp"` no longer fails during child-session setup. Fixes #40971. (#40995) thanks @xaeon2026.
|
||||||
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
- ACP/stop reason mapping: resolve gateway chat `state: "error"` completions as ACP `end_turn` instead of `refusal` so transient backend failures are not surfaced as deliberate refusals. (#41187) thanks @pejmanjohn.
|
||||||
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
|
- ACP/setSessionMode: propagate gateway `sessions.patch` failures back to ACP clients so rejected mode changes no longer return silent success. (#41185) thanks @pejmanjohn.
|
||||||
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
|
||||||
- iOS/gateway foreground recovery: reconnect immediately on foreground return after stale background sockets are torn down, so the app no longer stays disconnected until a later wake path happens. (#41384) Thanks @mbelinky.
|
|
||||||
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
|
||||||
- Auth/cooldowns: reset expired auth-profile cooldown error counters before computing the next backoff so stale on-disk counters do not re-escalate into long cooldown loops after expiry. (#41028) thanks @zerone0x.
|
|
||||||
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
|
||||||
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
- ACP/bridge mode: reject unsupported per-session MCP server setup and propagate rejected session-mode changes so IDE clients see explicit bridge limitations instead of silent success. (#41424) Thanks @mbelinky.
|
||||||
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
|
- ACP/session UX: replay stored user and assistant text on `loadSession`, expose Gateway-backed session controls and metadata, and emit approximate session usage updates so IDE clients restore context more faithfully. (#41425) Thanks @mbelinky.
|
||||||
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
|
- ACP/tool streaming: enrich `tool_call` and `tool_call_update` events with best-effort text content and file-location hints so IDE clients can follow bridge tool activity more naturally. (#41442) Thanks @mbelinky.
|
||||||
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
|
- ACP/runtime attachments: forward normalized inbound image attachments into ACP runtime turns so ACPX sessions can preserve image prompt content on the runtime path. (#41427) Thanks @mbelinky.
|
||||||
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
|
- ACP/regressions: add gateway RPC coverage for ACP lineage patching, ACPX runtime coverage for image prompt serialization, and an operator smoke-test procedure for live ACP spawn verification. (#41456) Thanks @mbelinky.
|
||||||
- Agents/billing recovery: probe single-provider billing cooldowns on the existing throttle so topping up credits can recover without a manual gateway restart. (#41422) thanks @altaywtf.
|
|
||||||
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
|
- ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky.
|
||||||
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
|
||||||
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
|
||||||
- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera.
|
|
||||||
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
|
||||||
- Agents/fallback: treat HTTP 499 responses as transient in both raw-text and structured failover paths so Anthropic-style client-closed overload responses trigger model fallback reliably. (#41468) thanks @zeroasterisk.
|
|
||||||
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
- Plugins/context-engine model auth: expose `runtime.modelAuth` and plugin-sdk auth helpers so plugins can resolve provider/model API keys through the normal auth pipeline. (#41090) thanks @xinhuagu.
|
||||||
- CLI/memory teardown: close cached memory search/index managers in the one-shot CLI shutdown path so watcher-backed memory caches no longer keep completed CLI runs alive after output finishes. (#40389) thanks @Julbarth.
|
- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
|
||||||
- Tools/web search: treat Brave `llm-context` grounding snippets as plain strings so `web_search` no longer returns empty snippet arrays in LLM Context mode. (#41387) thanks @zheliu2.
|
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
|
||||||
- Telegram/exec approvals: reject `/approve` commands aimed at other bots, keep deterministic approval prompts visible when tool-result delivery fails, and stop resolved exact IDs from matching other pending approvals by prefix. (#37233) Thanks @huntharo.
|
- Context engine/tests: add bundled-registry regression coverage for cross-chunk resolution, plugin-sdk re-exports, and concurrent chunk registration. (#40460) thanks @dsantoreis.
|
||||||
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
|
- Agents/embedded runner: bound compaction retry waiting and drain embedded runs during SIGUSR1 restart so session lanes recover instead of staying blocked behind compaction. (#40324) thanks @cgdusek.
|
||||||
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
|
- Agents/embedded logs: add structured, sanitized lifecycle and failover observation events so overload and provider failures are easier to tail and filter. (#41336) thanks @altaywtf.
|
||||||
|
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
|
||||||
|
- Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf.
|
||||||
|
- Logging/probe observations: suppress structured embedded and model-fallback probe warnings on the console without hiding error or fatal output. (#41338) thanks @altaywtf.
|
||||||
|
- Agents/context-engine compaction: guard thrown engine-owned overflow compaction attempts and fire compaction hooks for `ownsCompaction` engines so overflow recovery no longer crashes and plugin subscribers still observe compact runs. (#41361) thanks @davidrudduck.
|
||||||
|
- Gateway/node pending drain followup: keep `hasMore` true when the deferred baseline status item still needs delivery, and avoid allocating empty pending-work state for drain-only nodes with no queued work. (#41429) Thanks @mbelinky.
|
||||||
|
- Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky.
|
||||||
|
- Cron/subagent followup: do not misclassify empty or `NO_REPLY` cron responses as interim acknowledgements that need a rerun, so deliberately silent cron jobs are no longer retried. (#41383) thanks @jackal092927.
|
||||||
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
|
||||||
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
|
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
|
||||||
- Security/external content: treat whitespace-delimited `EXTERNAL UNTRUSTED CONTENT` boundary markers like underscore-delimited variants so prompt wrappers cannot bypass marker sanitization. (#35983) Thanks @urianpaul94.
|
|
||||||
- Telegram/network env-proxy: apply configured transport policy to proxied HTTPS dispatchers as well as direct `NO_PROXY` bypasses, so resolver-scoped IPv4 fallback and network settings work consistently for env-proxied Telegram traffic. (#40740) Thanks @sircrumpet.
|
|
||||||
- Agents/memory flush: forward `memoryFlushWritePath` through `runEmbeddedPiAgent` so memory-triggered flush turns keep the append-only write guard without aborting before tool setup. Follows up on #38574. (#41761) Thanks @frankekn.
|
|
||||||
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
|
- CI/CodeQL Swift toolchain: select Xcode 26.1 before installing Swift build tools so the CodeQL Swift job uses Swift tools 6.2 on `macos-latest`. (#41787) thanks @BunsDev.
|
||||||
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
|
- Sandbox/subagents: pass the real configured workspace through `sessions_spawn` inheritance when a parent agent runs in a copied-workspace sandbox, so child `/agent` mounts point at the configured workspace instead of the parent sandbox copy. (#40757) Thanks @dsantoreis.
|
||||||
- Mattermost/plugin send actions: normalize direct `replyTo` fallback handling so threaded plugin sends trim blank IDs and reuse the correct reply target again. (#41176) Thanks @hnykda.
|
|
||||||
- MS Teams/allowlist resolution: use the General channel conversation ID as the resolved team key (with Graph GUID fallback) so Bot Framework runtime `channelData.team.id` matching works for team and team/channel allowlist entries. (#41838) Thanks @BradGroux.
|
|
||||||
- Mattermost/Markdown formatting: preserve first-line indentation when stripping bot mentions so nested list items and indented code blocks keep their structure, and render Mattermost tables natively by default instead of fenced-code fallback. (#18655) thanks @echo931.
|
|
||||||
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
|
- Agents/fallback cooldown probing: cap cooldown-bypass probing to one attempt per provider per fallback run so multi-model same-provider cooldown chains can continue to cross-provider fallbacks instead of repeatedly stalling on duplicate cooldown probes. (#41711) Thanks @cgdusek.
|
||||||
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
|
- Telegram/direct delivery: bridge direct delivery sends to internal `message:sent` hooks so internal hook listeners observe successful Telegram deliveries. (#40185) Thanks @vincentkoc.
|
||||||
- Plugins/global hook runner: harden singleton state handling so shared global hook runner reuse does not leak or corrupt runner state across executions. (#40184) Thanks @vincentkoc.
|
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
|
||||||
- Agents/fallback: recognize Poe `402 You've used up your points!` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#42278) Thanks @CryUshio.
|
- Telegram/polling restarts: clear bounded cleanup timeout handles after `runner.stop()` and `bot.stop()` settle so stall recovery no longer leaves stray 15-second timers behind on clean shutdown. (#43188) thanks @kyohwang.
|
||||||
- Telegram/outbound HTML sends: chunk long HTML-mode messages, preserve plain-text fallback and silent-delivery params across retries, and cut over to plain text when HTML chunk planning cannot safely preserve the full message. (#42240) thanks @obviyus.
|
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
|
||||||
- Agents/embedded overload logs: include the failing model and provider in error-path console output, with lifecycle regression coverage for the rendered and sanitized `consoleMessage`. (#41236) thanks @jiarung.
|
- Hooks/plugin context parity followup: pass `trigger` and `channelId` through embedded `llm_input`, `agent_end`, and `llm_output` hook contexts so plugins receive the same agent metadata across hook phases. (#42362) Thanks @zhoulf1006.
|
||||||
- Agents/failover: treat Gemini `MALFORMED_RESPONSE` stop reasons as retryable timeouts so preview-model enum drift falls back cleanly instead of crashing the run, without also reclassifying malformed function-call errors. (#42292) Thanks @jnMetaCode.
|
- Status/context windows: normalize provider-qualified override cache keys so `/status` resolves the active provider's configured context window even when `models.providers` keys use mixed case or surrounding whitespace. (#36389) Thanks @haoruilee.
|
||||||
- Discord/Telegram outbound runtime config: thread runtime-resolved config through Discord and Telegram send paths so SecretRef-based credentials stay resolved during message delivery. (#42352) Thanks @joshavant.
|
- ACP/main session aliases: canonicalize `main` before ACP session lookup so restarted ACP main sessions rehydrate instead of failing closed with `Session is not ACP-enabled: main`. (#43285, fixes #25692)
|
||||||
- Secrets/SecretRef: reject exec SecretRef traversal ids across schema, runtime, and gateway. (#42370) Thanks @joshavant.
|
- Agents/embedded runner: recover canonical allowlisted tool names from malformed `toolCallId` and malformed non-blank tool-name variants before dispatch, while failing closed on ambiguous matches. (#34485) thanks @yuweuii.
|
||||||
- Telegram/docs: clarify that `channels.telegram.groups` allowlists chats while `groupAllowFrom` allowlists users inside those chats, and point invalid negative chat IDs at the right config key. (#42451) Thanks @altaywtf.
|
- Agents/failover: classify ZenMux quota-refresh `402` responses as `rate_limit` so model fallback retries continue instead of stopping on a temporary subscription window. (#43917) thanks @bwjoke.
|
||||||
- Models/Alibaba Cloud Model Studio: wire `MODELSTUDIO_API_KEY` through shared env auth, implicit provider discovery, and shell-env fallback so onboarding works outside the wizard too. (#40634) Thanks @pomelo-nwu.
|
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
|
||||||
- Subagents/authority: persist leaf vs orchestrator control scope at spawn time and route tool plus slash-command control through shared ownership checks, so leaf sessions cannot regain orchestration privileges after restore or flat-key lookups. Thanks @tdjackey.
|
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
|
||||||
- ACP/sessions_spawn: implicitly stream `mode="run"` ACP spawns to parent only for eligible subagent orchestrator sessions (heartbeat `target: "last"` with a usable session-local route), restoring parent progress relays without thread binding. (#42404) Thanks @davidguttman.
|
|
||||||
- Sessions/reset model recompute: clear stale runtime model, context-token, and system-prompt metadata before session resets recompute the replacement session, so resets pick up current defaults and explicit overrides instead of reusing old runtime model state. (#41173) thanks @PonyX-lab.
|
|
||||||
- Browser/Browserbase 429 handling: surface stable no-retry rate-limit guidance without buffering discarded HTTP 429 response bodies from remote browser services. (#40491) thanks @mvanhorn.
|
|
||||||
- Gateway/auth: allow one trusted device-token retry on shared-token mismatch with recovery hints to prevent reconnect churn during token drift. (#42507) Thanks @joshavant.
|
|
||||||
- Channels/allowlists: remove stale matcher caching so same-array allowlist edits and wildcard replacements take effect immediately, with regression coverage for in-place mutation cases.
|
|
||||||
- Gateway/auth: fail closed when local `gateway.auth.*` SecretRefs are configured but unavailable, instead of silently falling back to `gateway.remote.*` credentials in local mode. (#42672) Thanks @joshavant.
|
|
||||||
- Sandbox/fs bridge: pin staged writes to verified parent directories so temporary write files cannot materialize outside the allowed mount before atomic replace. Thanks @tdjackey.
|
|
||||||
- Commands/config writes: enforce `configWrites` against both the originating account and the targeted account scope for `/config` and config-backed `/allowlist` edits, blocking sibling-account mutations while preserving gateway `operator.admin` flows. Thanks @tdjackey for reporting.
|
|
||||||
- Security/system.run: fail closed for approval-backed interpreter/runtime commands when OpenClaw cannot bind exactly one concrete local file operand, while extending best-effort direct-file binding to additional runtime forms. Thanks @tdjackey for reporting.
|
|
||||||
- Gateway/session reset auth: split conversation `/new` and `/reset` handling away from the admin-only `sessions.reset` control-plane RPC so write-scoped gateway callers can no longer reach the privileged reset path through `agent`. Thanks @tdjackey for reporting.
|
|
||||||
- Telegram/final preview delivery followup: keep ambiguous missing-`message_id` finals only when a preview was already visible, while first-preview/no-id cases still fall back so Telegram users do not lose the final reply. (#41932) thanks @hougangdev.
|
|
||||||
|
|
||||||
## 2026.3.8
|
## 2026.3.8
|
||||||
|
|
||||||
|
|
@ -156,12 +296,20 @@ Docs: https://docs.openclaw.ai
|
||||||
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
|
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
|
||||||
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
|
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
|
||||||
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
|
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
|
||||||
|
- Security/Gateway: block `device.token.rotate` from minting operator scopes broader than the caller session already holds, closing the critical paired-device token privilege escalation reported as GHSA-4jpw-hj22-2xmc.
|
||||||
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
|
- Security/system.run: bind approved `bun` and `deno run` script operands to on-disk file snapshots so post-approval script rewrites are denied before execution.
|
||||||
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
|
- Skills/download installs: pin the validated per-skill tools root before writing downloaded archives, so rebinding the lexical tools path cannot redirect download writes outside the intended tools directory. Thanks @tdjackey.
|
||||||
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
|
- Control UI/Debug: replace the Manual RPC free-text method field with a sorted dropdown sourced from gateway-advertised methods, and stack the form vertically for narrower layouts. (#14967) thanks @rixau.
|
||||||
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
|
- Auth/profile resolution: log debug details when auto-discovered auth profiles fail during provider API-key resolution, so `--debug` output surfaces the real refresh/keychain/credential-store failure instead of only the generic missing-key message. (#41271) thanks @he-yufeng.
|
||||||
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
|
- ACP/cancel scoping: scope `chat.abort` and shared-session ACP event routing by `runId` so one session cannot cancel or consume another session's run when they share the same gateway session key. (#41331) Thanks @pejmanjohn.
|
||||||
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
|
- SecretRef/models: harden custom/provider secret persistence and reuse across models.json snapshots, merge behavior, runtime headers, and secret audits. (#42554) Thanks @joshavant.
|
||||||
|
- macOS/browser proxy: serialize non-GET browser proxy request bodies through `AnyCodable.foundationValue` so nested JSON bodies no longer crash the macOS app with `Invalid type in JSON write (__SwiftValue)`. (#43069) Thanks @Effet.
|
||||||
|
- CLI/skills tables: keep terminal table borders aligned for wide graphemes, use full reported terminal width, and switch a few ambiguous skill icons to Terminal-safe emoji so `openclaw skills` renders more consistently in Terminal.app and iTerm. Thanks @vincentkoc.
|
||||||
|
- Memory/Gemini: normalize returned Gemini embeddings across direct query, direct batch, and async batch paths so memory search uses consistent vector handling for Gemini too. (#43409) Thanks @gumadeiras.
|
||||||
|
- Agents/failover: recognize additional serialized network errno strings plus `EHOSTDOWN` and `EPIPE` structured codes so transient transport failures trigger timeout failover more reliably. (#42830) Thanks @jnMetaCode.
|
||||||
|
- Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in `/models` button validation. (#40105) Thanks @avirweb.
|
||||||
|
- Agents/embedded runner: carry provider-observed overflow token counts into compaction so overflow retries and diagnostics use the rejected live prompt size instead of only transcript estimates. (#40357) thanks @rabsef-bicrym.
|
||||||
|
- Agents/compaction transcript updates: emit a transcript-update event immediately after successful embedded compaction so downstream listeners observe the post-compact transcript without waiting for a later write. (#25558) thanks @rodrigouroz.
|
||||||
|
|
||||||
## 2026.3.7
|
## 2026.3.7
|
||||||
|
|
||||||
|
|
@ -522,6 +670,7 @@ Docs: https://docs.openclaw.ai
|
||||||
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
- Browser/config schema: accept `browser.profiles.*.driver: "openclaw"` while preserving legacy `"clawd"` compatibility in validated config. (#39374; based on #35621) Thanks @gambletan and @ingyukoh.
|
||||||
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
- Memory flush/bootstrap file protection: restrict memory-flush runs to append-only `read`/`write` tools and route host-side memory appends through root-enforced safe file handles so flush turns cannot overwrite bootstrap files via `exec` or unsafe raw rewrites. (#38574) Thanks @frankekn.
|
||||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||||
|
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
|
||||||
|
|
||||||
## 2026.3.2
|
## 2026.3.2
|
||||||
|
|
||||||
|
|
@ -1029,6 +1178,7 @@ Docs: https://docs.openclaw.ai
|
||||||
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
|
- Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc.
|
||||||
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
- FS/Sandbox workspace boundaries: add a dedicated `outside-workspace` safe-open error code for root-escape checks, and propagate specific outside-workspace messages across edit/browser/media consumers instead of generic not-found/invalid-path fallbacks. (#29715) Thanks @YuzuruS.
|
||||||
- Diagnostics/Stuck session signal: add configurable stuck-session warning threshold via `diagnostics.stuckSessionWarnMs` (default 120000ms) to reduce false-positive warnings on long multi-tool turns. (#31032)
|
- Diagnostics/Stuck session signal: add configurable stuck-session warning threshold via `diagnostics.stuckSessionWarnMs` (default 120000ms) to reduce false-positive warnings on long multi-tool turns. (#31032)
|
||||||
|
- Agents/error classification: check billing errors before context overflow heuristics in the agent runner catch block so spend-limit and quota errors show the billing-specific message instead of being misclassified as "Context overflow: prompt too large". (#40409) Thanks @ademczuk.
|
||||||
|
|
||||||
## 2026.2.26
|
## 2026.2.26
|
||||||
|
|
||||||
|
|
@ -4001,6 +4151,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||||
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
- Gateway/Daemon/Doctor: atomic config writes; repair gateway service entrypoint + install switches; non-interactive legacy migrations; systemd unit alignment + KillMode=process; node bridge keepalive/pings; Launch at Login persistence; bundle MoltbotKit resources + Swift 6.2 compat dylib; relay version check + remove smoke test; regen Swift GatewayModels + keep agent provider string; cron jobId alias + channel alias migration + main session key normalization; heartbeat Telegram accountId resolution; avoid WhatsApp fallback for internal runs; gateway listener error wording; serveBaseUrl param; honor gateway --dev; fix wide-area discovery updates; align agents.defaults schema; provider account metadata in daemon status; refresh Carbon patch for gateway fixes; restore doctor prompter initialValue handling.
|
||||||
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
- Control UI/TUI: persist per-session verbose off + hide tool cards; logs tab opens at bottom; relative asset paths + landing cleanup; session labels lookup/persistence; stop pinning main session in recents; start logs at bottom; TUI status bar refresh + timeout handling + hide reasoning label when off.
|
||||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||||
|
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
|
||||||
|
|
||||||
### Maintenance
|
### Maintenance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ Welcome to the lobster tank! 🦞
|
||||||
- Describe what & why
|
- Describe what & why
|
||||||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||||
|
- Use American English spelling and grammar in code, comments, docs, and UI strings
|
||||||
|
|
||||||
## Review Conversations Are Author-Owned
|
## Review Conversations Are Author-Owned
|
||||||
|
|
||||||
|
|
|
||||||
40
Dockerfile
40
Dockerfile
|
|
@ -14,14 +14,14 @@
|
||||||
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
|
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
|
||||||
ARG OPENCLAW_EXTENSIONS=""
|
ARG OPENCLAW_EXTENSIONS=""
|
||||||
ARG OPENCLAW_VARIANT=default
|
ARG OPENCLAW_VARIANT=default
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:22-bookworm@sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9"
|
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:b501c082306a4f528bc4038cbf2fbb58095d583d0419a259b2114b5ac53d12e9"
|
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:22-bookworm-slim@sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9"
|
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:9c2c405e3ff9b9afb2873232d24bb06367d649aa3e6259cbe314da59578e81e9"
|
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||||
|
|
||||||
# Base images are pinned to SHA256 digests for reproducible builds.
|
# Base images are pinned to SHA256 digests for reproducible builds.
|
||||||
# Trade-off: digests must be updated manually when upstream tags move.
|
# Trade-off: digests must be updated manually when upstream tags move.
|
||||||
# To update, run: docker manifest inspect node:22-bookworm (or podman)
|
# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman)
|
||||||
# and replace the digest below with the current multi-arch manifest list entry.
|
# and replace the digest below with the current multi-arch manifest list entry.
|
||||||
|
|
||||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
|
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
|
||||||
|
|
@ -39,8 +39,18 @@ RUN mkdir -p /out && \
|
||||||
# ── Stage 2: Build ──────────────────────────────────────────────
|
# ── Stage 2: Build ──────────────────────────────────────────────
|
||||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS build
|
||||||
|
|
||||||
# Install Bun (required for build scripts)
|
# Install Bun (required for build scripts). Retry the whole bootstrap flow to
|
||||||
RUN curl -fsSL https://bun.sh/install | bash
|
# tolerate transient 5xx failures from bun.sh/GitHub during CI image builds.
|
||||||
|
RUN set -eux; \
|
||||||
|
for attempt in 1 2 3 4 5; do \
|
||||||
|
if curl --retry 5 --retry-all-errors --retry-delay 2 -fsSL https://bun.sh/install | bash; then \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
if [ "$attempt" -eq 5 ]; then \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
sleep $((attempt * 2)); \
|
||||||
|
done
|
||||||
ENV PATH="/root/.bun/bin:${PATH}"
|
ENV PATH="/root/.bun/bin:${PATH}"
|
||||||
|
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
@ -92,12 +102,12 @@ RUN CI=true pnpm prune --prod && \
|
||||||
# ── Runtime base images ─────────────────────────────────────────
|
# ── Runtime base images ─────────────────────────────────────────
|
||||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
|
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
|
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
|
||||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm" \
|
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \
|
||||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
|
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
|
||||||
|
|
||||||
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
|
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
|
||||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
|
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
|
||||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:22-bookworm-slim" \
|
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \
|
||||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
|
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
|
||||||
|
|
||||||
# ── Stage 3: Runtime ────────────────────────────────────────────
|
# ── Stage 3: Runtime ────────────────────────────────────────────
|
||||||
|
|
@ -141,7 +151,15 @@ COPY --from=runtime-assets --chown=node:node /app/docs ./docs
|
||||||
ENV COREPACK_HOME=/usr/local/share/corepack
|
ENV COREPACK_HOME=/usr/local/share/corepack
|
||||||
RUN install -d -m 0755 "$COREPACK_HOME" && \
|
RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||||
corepack enable && \
|
corepack enable && \
|
||||||
corepack prepare "$(node -p "require('./package.json').packageManager")" --activate && \
|
for attempt in 1 2 3 4 5; do \
|
||||||
|
if corepack prepare "$(node -p "require('./package.json').packageManager")" --activate; then \
|
||||||
|
break; \
|
||||||
|
fi; \
|
||||||
|
if [ "$attempt" -eq 5 ]; then \
|
||||||
|
exit 1; \
|
||||||
|
fi; \
|
||||||
|
sleep $((attempt * 2)); \
|
||||||
|
done && \
|
||||||
chmod -R a+rX "$COREPACK_HOME"
|
chmod -R a+rX "$COREPACK_HOME"
|
||||||
|
|
||||||
# Install additional system packages needed by your skills or extensions.
|
# Install additional system packages needed by your skills or extensions.
|
||||||
|
|
@ -209,7 +227,7 @@ RUN ln -sf /app/openclaw.mjs /usr/local/bin/openclaw \
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
# Security hardening: Run as non-root user
|
# Security hardening: Run as non-root user
|
||||||
# The node:22-bookworm image includes a 'node' user (uid 1000)
|
# The node:24-bookworm image includes a 'node' user (uid 1000)
|
||||||
# This reduces the attack surface by preventing container escape via root privileges
|
# This reduces the attack surface by preventing container escape via root privileges
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ For fastest triage, include all of the following:
|
||||||
- Exact vulnerable path (`file`, function, and line range) on a current revision.
|
- Exact vulnerable path (`file`, function, and line range) on a current revision.
|
||||||
- Tested version details (OpenClaw version and/or commit SHA).
|
- Tested version details (OpenClaw version and/or commit SHA).
|
||||||
- Reproducible PoC against latest `main` or latest released version.
|
- Reproducible PoC against latest `main` or latest released version.
|
||||||
|
- If the claim targets a released version, evidence from the shipped tag and published artifact/package for that exact version (not only `main`).
|
||||||
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
|
- Demonstrated impact tied to OpenClaw's documented trust boundaries.
|
||||||
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
|
- For exposed-secret reports: proof the credential is OpenClaw-owned (or grants access to OpenClaw-operated infrastructure/services).
|
||||||
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
|
- Explicit statement that the report does not rely on adversarial operators sharing one gateway host/config.
|
||||||
|
|
@ -55,6 +56,7 @@ These are frequently reported but are typically closed with no code change:
|
||||||
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
|
- Authorized user-triggered local actions presented as privilege escalation. Example: an allowlisted/owner sender running `/export-session /absolute/path.html` to write on the host. In this trust model, authorized user actions are trusted host actions unless you demonstrate an auth/sandbox/boundary bypass.
|
||||||
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
|
- Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it.
|
||||||
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
|
- Reports that assume per-user multi-tenant authorization on a shared gateway host/config.
|
||||||
|
- Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries.
|
||||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||||
|
|
@ -65,6 +67,7 @@ These are frequently reported but are typically closed with no code change:
|
||||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||||
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
- Claims that Microsoft Teams `fileConsent/invoke` `uploadInfo.uploadUrl` is attacker-controlled without demonstrating one of: auth boundary bypass, a real authenticated Teams/Bot Framework event carrying attacker-chosen URL, or compromise of the Microsoft/Bot trust path.
|
||||||
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
|
- Scanner-only claims against stale/nonexistent paths, or claims without a working repro.
|
||||||
|
- Reports that restate an already-fixed issue against later released versions without showing the vulnerable path still exists in the shipped tag or published artifact for that later version.
|
||||||
|
|
||||||
### Duplicate Report Handling
|
### Duplicate Report Handling
|
||||||
|
|
||||||
|
|
@ -90,6 +93,7 @@ When patching a GHSA via `gh api`, include `X-GitHub-Api-Version: 2022-11-28` (o
|
||||||
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
|
OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boundary.
|
||||||
|
|
||||||
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
|
- Authenticated Gateway callers are treated as trusted operators for that gateway instance.
|
||||||
|
- The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split.
|
||||||
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
|
- Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries.
|
||||||
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
|
- If one operator can view data from another operator on the same gateway, that is expected in this trust model.
|
||||||
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
|
- OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary.
|
||||||
|
|
@ -145,6 +149,7 @@ OpenClaw security guidance assumes:
|
||||||
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
|
OpenClaw's security model is "personal assistant" (one trusted operator, potentially many agents), not "shared multi-tenant bus."
|
||||||
|
|
||||||
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
|
- If multiple people can message the same tool-enabled agent (for example a shared Slack workspace), they can all steer that agent within its granted permissions.
|
||||||
|
- Non-owner sender status only affects owner-only tools/commands. If a non-owner can still access a non-owner-only tool on that same agent (for example `canvas`), that is within the granted tool boundary unless the report demonstrates an auth, policy, allowlist, approval, or sandbox bypass.
|
||||||
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
|
- Session or memory scoping reduces context bleed, but does **not** create per-user host authorization boundaries.
|
||||||
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
|
- For mixed-trust or adversarial users, isolate by OS user/host/gateway and use separate credentials per boundary.
|
||||||
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
|
- A company-shared agent can be a valid setup when users are in the same trust boundary and the agent is strictly business-only.
|
||||||
|
|
|
||||||
312
appcast.xml
312
appcast.xml
|
|
@ -2,6 +2,98 @@
|
||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>OpenClaw</title>
|
<title>OpenClaw</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.3.12</title>
|
||||||
|
<pubDate>Fri, 13 Mar 2026 04:25:50 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||||
|
<sparkle:version>2026031290</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.3.12</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>OpenClaw 2026.3.12</h2>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Control UI/dashboard-v2: refresh the gateway dashboard with modular overview, chat, config, agent, and session views, plus a command palette, mobile bottom tabs, and richer chat tools like slash commands, search, export, and pinned messages. (#41503) Thanks @BunsDev.</li>
|
||||||
|
<li>OpenAI/GPT-5.4 fast mode: add configurable session-level fast toggles across <code>/fast</code>, TUI, Control UI, and ACP, with per-model config defaults and OpenAI/Codex request shaping.</li>
|
||||||
|
<li>Anthropic/Claude fast mode: map the shared <code>/fast</code> toggle and <code>params.fastMode</code> to direct Anthropic API-key <code>service_tier</code> requests, with live verification for both Anthropic and OpenAI fast-mode tiers.</li>
|
||||||
|
<li>Models/plugins: move Ollama, vLLM, and SGLang onto the provider-plugin architecture, with provider-owned onboarding, discovery, model-picker setup, and post-selection hooks so core provider wiring is more modular.</li>
|
||||||
|
<li>Docs/Kubernetes: Add a starter K8s install path with raw manifests, Kind setup, and deployment docs. Thanks @sallyom @dzianisv @egkristi</li>
|
||||||
|
<li>Agents/subagents: add <code>sessions_yield</code> so orchestrators can end the current turn immediately, skip queued tool work, and carry a hidden follow-up payload into the next session turn. (#36537) thanks @jriff</li>
|
||||||
|
<li>Slack/agent replies: support <code>channelData.slack.blocks</code> in the shared reply delivery path so agents can send Block Kit messages through standard Slack outbound delivery. (#44592) Thanks @vincentkoc.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Security/device pairing: switch <code>/pair</code> and <code>openclaw qr</code> setup codes to short-lived bootstrap tokens so the next release no longer embeds shared gateway credentials in chat or QR pairing payloads. Thanks @lintsinghua.</li>
|
||||||
|
<li>Security/plugins: disable implicit workspace plugin auto-load so cloned repositories cannot execute workspace plugin code without an explicit trust decision. (<code>GHSA-99qw-6mr3-36qr</code>)(#44174) Thanks @lintsinghua and @vincentkoc.</li>
|
||||||
|
<li>Models/Kimi Coding: send <code>anthropic-messages</code> tools in native Anthropic format again so <code>kimi-coding</code> stops degrading tool calls into XML/plain-text pseudo invocations instead of real <code>tool_use</code> blocks. (#38669, #39907, #40552) Thanks @opriz.</li>
|
||||||
|
<li>TUI/chat log: reuse the active assistant message component for the same streaming run so <code>openclaw tui</code> no longer renders duplicate assistant replies. (#35364) Thanks @lisitan.</li>
|
||||||
|
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||||
|
<li>Cron/proactive delivery: keep isolated direct cron sends out of the write-ahead resend queue so transient-send retries do not replay duplicate proactive messages after restart. (#40646) Thanks @openperf and @vincentkoc.</li>
|
||||||
|
<li>Models/Kimi Coding: send the built-in <code>User-Agent: claude-code/0.1.0</code> header by default for <code>kimi-coding</code> while still allowing explicit provider headers to override it, so Kimi Code subscription auth can work without a local header-injection proxy. (#30099) Thanks @Amineelfarssi and @vincentkoc.</li>
|
||||||
|
<li>Models/OpenAI Codex Spark: keep <code>gpt-5.3-codex-spark</code> working on the <code>openai-codex/*</code> path via resolver fallbacks and clearer Codex-only handling, while continuing to suppress the stale direct <code>openai/*</code> Spark row that OpenAI rejects live.</li>
|
||||||
|
<li>Ollama/Kimi Cloud: apply the Moonshot Kimi payload compatibility wrapper to Ollama-hosted Kimi models like <code>kimi-k2.5:cloud</code>, so tool routing no longer breaks when thinking is enabled. (#41519) Thanks @vincentkoc.</li>
|
||||||
|
<li>Moonshot CN API: respect explicit <code>baseUrl</code> (api.moonshot.cn) in implicit provider resolution so platform.moonshot.cn API keys authenticate correctly instead of returning HTTP 401. (#33637) Thanks @chengzhichao-xydt.</li>
|
||||||
|
<li>Kimi Coding/provider config: respect explicit <code>models.providers["kimi-coding"].baseUrl</code> when resolving the implicit provider so custom Kimi Coding endpoints no longer get overwritten by the built-in default. (#36353) Thanks @2233admin.</li>
|
||||||
|
<li>Gateway/main-session routing: keep TUI and other <code>mode:UI</code> main-session sends on the internal surface when <code>deliver</code> is enabled, so replies no longer inherit the session's persisted Telegram/WhatsApp route. (#43918) Thanks @obviyus.</li>
|
||||||
|
<li>BlueBubbles/self-chat echo dedupe: drop reflected duplicate webhook copies only when a matching <code>fromMe</code> event was just seen for the same chat, body, and timestamp, preventing self-chat loops without broad webhook suppression. Related to #32166. (#38442) Thanks @vincentkoc.</li>
|
||||||
|
<li>iMessage/self-chat echo dedupe: drop reflected duplicate copies only when a matching <code>is_from_me</code> event was just seen for the same chat, text, and <code>created_at</code>, preventing self-chat loops without broad text-only suppression. Related to #32166. (#38440) Thanks @vincentkoc.</li>
|
||||||
|
<li>Subagents/completion announce retries: raise the default announce timeout to 90 seconds and stop retrying gateway-timeout failures for externally delivered completion announces, preventing duplicate user-facing completion messages after slow gateway responses. Fixes #41235. Thanks @vasujain00 and @vincentkoc.</li>
|
||||||
|
<li>Mattermost/block streaming: fix duplicate message delivery (one threaded, one top-level) when block streaming is active by excluding <code>replyToId</code> from the block reply dedup key and adding an explicit <code>threading</code> dock to the Mattermost plugin. (#41362) Thanks @mathiasnagler and @vincentkoc.</li>
|
||||||
|
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||||
|
<li>macOS/Reminders: add the missing <code>NSRemindersUsageDescription</code> to the bundled app so <code>apple-reminders</code> can trigger the system permission prompt from OpenClaw.app. (#8559) Thanks @dinakars777.</li>
|
||||||
|
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||||
|
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||||
|
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||||
|
<li>Windows/native update: make package installs use the npm update path instead of the git path, carry portable Git into native Windows updates, and mirror the installer's Windows npm env so <code>openclaw update</code> no longer dies early on missing <code>git</code> or <code>node-llama-cpp</code> download setup.</li>
|
||||||
|
<li>Sandbox/write: preserve pinned mutation-helper payload stdin so sandboxed <code>write</code> no longer reports success while creating empty files. (#43876) Thanks @glitch418x.</li>
|
||||||
|
<li>Security/exec approvals: escape invisible Unicode format characters in approval prompts so zero-width command text renders as visible <code>\u{...}</code> escapes instead of spoofing the reviewed command. (<code>GHSA-pcqg-f7rg-xfvv</code>)(#43687) Thanks @EkiXu and @vincentkoc.</li>
|
||||||
|
<li>Hooks/loader: fail closed when workspace hook paths cannot be resolved with <code>realpath</code>, so unreadable or broken internal hook paths are skipped instead of falling back to unresolved imports. (#44437) Thanks @vincentkoc.</li>
|
||||||
|
<li>Hooks/agent deliveries: dedupe repeated hook requests by optional idempotency key so webhook retries can reuse the first run instead of launching duplicate agent executions. (#44438) Thanks @vincentkoc.</li>
|
||||||
|
<li>Security/exec detection: normalize compatibility Unicode and strip invisible formatting code points before obfuscation checks so zero-width and fullwidth command tricks no longer suppress heuristic detection. (<code>GHSA-9r3v-37xh-2cf6</code>)(#44091) Thanks @wooluo and @vincentkoc.</li>
|
||||||
|
<li>Security/exec allowlist: preserve POSIX case sensitivity and keep <code>?</code> within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (<code>GHSA-f8r2-vg7x-gh8m</code>)(#43798) Thanks @zpbrent and @vincentkoc.</li>
|
||||||
|
<li>Security/commands: require sender ownership for <code>/config</code> and <code>/debug</code> so authorized non-owner senders can no longer reach owner-only config and runtime debug surfaces. (<code>GHSA-r7vr-gr74-94p8</code>)(#44305) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Security/gateway auth: clear unbound client-declared scopes on shared-token WebSocket connects so device-less shared-token operators cannot self-declare elevated scopes. (<code>GHSA-rqpp-rjj8-7wv8</code>)(#44306) Thanks @LUOYEcode and @vincentkoc.</li>
|
||||||
|
<li>Security/browser.request: block persistent browser profile create/delete routes from write-scoped <code>browser.request</code> so callers can no longer persist admin-only browser profile changes through the browser control surface. (<code>GHSA-vmhq-cqm9-6p7q</code>)(#43800) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external <code>agent</code> callers can no longer override the gateway workspace boundary. (<code>GHSA-2rqg-gjgv-84jm</code>)(#43801) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Security/session_status: enforce sandbox session-tree visibility and shared agent-to-agent access guards before reading or mutating target session state, so sandboxed subagents can no longer inspect parent session metadata or write parent model overrides via <code>session_status</code>. (<code>GHSA-wcxr-59v9-rxr8</code>)(#43754) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Security/agent tools: mark <code>nodes</code> as explicitly owner-only and document/test that <code>canvas</code> remains a shared trusted-operator surface unless a real boundary bypass exists.</li>
|
||||||
|
<li>Security/exec approvals: fail closed for Ruby approval flows that use <code>-r</code>, <code>--require</code>, or <code>-I</code> so approval-backed commands no longer bind only the main script while extra local code-loading flags remain outside the reviewed file snapshot.</li>
|
||||||
|
<li>Security/device pairing: cap issued and verified device-token scopes to each paired device's approved scope baseline so stale or overbroad tokens cannot exceed approved access. (<code>GHSA-2pwv-x786-56f8</code>)(#43686) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Docs/onboarding: align the legacy wizard reference and <code>openclaw onboard</code> command docs with the Ollama onboarding flow so all onboarding reference paths now document <code>--auth-choice ollama</code>, Cloud + Local mode, and non-interactive usage. (#43473) Thanks @BruceMacD.</li>
|
||||||
|
<li>Models/secrets: enforce source-managed SecretRef markers in generated <code>models.json</code> so runtime-resolved provider secrets are not persisted when runtime projection is skipped. (#43759) Thanks @joshavant.</li>
|
||||||
|
<li>Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (<code>GHSA-jv4g-m82p-2j93</code>)(#44089) (<code>GHSA-xwx2-ppv2-wx98</code>)(#44089) Thanks @ez-lbz and @vincentkoc.</li>
|
||||||
|
<li>Security/proxy attachments: restore the shared media-store size cap for persisted browser proxy files so oversized payloads are rejected instead of overriding the intended 5 MB limit. (<code>GHSA-6rph-mmhp-h7h9</code>)(#43684) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Security/host env: block inherited <code>GIT_EXEC_PATH</code> from sanitized host exec environments so Git helper resolution cannot be steered by host environment state. (<code>GHSA-jf5v-pqgw-gm5m</code>)(#43685) Thanks @zpbrent and @vincentkoc.</li>
|
||||||
|
<li>Security/Feishu webhook: require <code>encryptKey</code> alongside <code>verificationToken</code> in webhook mode so unsigned forged events are rejected instead of being processed with token-only configuration. (<code>GHSA-g353-mgv3-8pcj</code>)(#44087) Thanks @lintsinghua and @vincentkoc.</li>
|
||||||
|
<li>Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic <code>p2p</code> reactions. (<code>GHSA-m69h-jm2f-2pv8</code>)(#44088) Thanks @zpbrent and @vincentkoc.</li>
|
||||||
|
<li>Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a <code>200</code> response. (<code>GHSA-mhxh-9pjm-w7q5</code>)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.</li>
|
||||||
|
<li>Security/Zalo webhook: rate limit invalid secret guesses before auth so weak webhook secrets cannot be brute-forced through unauthenticated churned requests without pre-auth <code>429</code> responses. (<code>GHSA-5m9r-p9g7-679c</code>)(#44173) Thanks @zpbrent and @vincentkoc.</li>
|
||||||
|
<li>Security/Zalouser groups: require stable group IDs for allowlist auth by default and gate mutable group-name matching behind <code>channels.zalouser.dangerouslyAllowNameMatching</code>. Thanks @zpbrent.</li>
|
||||||
|
<li>Security/Slack and Teams routing: require stable channel and team IDs for allowlist routing by default, with mutable name matching only via each channel's <code>dangerouslyAllowNameMatching</code> break-glass flag.</li>
|
||||||
|
<li>Security/exec approvals: fail closed for ambiguous inline loader and shell-payload script execution, bind the real script after POSIX shell value-taking flags, and unwrap <code>pnpm</code>/<code>npm exec</code>/<code>npx</code> script runners before approval binding. (<code>GHSA-57jw-9722-6rf2</code>)(<code>GHSA-jvqh-rfmh-jh27</code>)(<code>GHSA-x7pp-23xv-mmr4</code>)(<code>GHSA-jc5j-vg4r-j5jx</code>)(#44247) Thanks @tdjackey and @vincentkoc.</li>
|
||||||
|
<li>Doctor/gateway service audit: canonicalize service entrypoint paths before comparing them so symlink-vs-realpath installs no longer trigger false "entrypoint does not match the current install" repair prompts. (#43882) Thanks @ngutman.</li>
|
||||||
|
<li>Doctor/gateway service audit: earlier groundwork for this fix landed in the superseded #28338 branch. Thanks @realriphub.</li>
|
||||||
|
<li>Gateway/session stores: regenerate the Swift push-test protocol models and align Windows native session-store realpath handling so protocol checks and sync session discovery stop drifting on Windows. (#44266) thanks @jalehman.</li>
|
||||||
|
<li>Context engine/session routing: forward optional <code>sessionKey</code> through context-engine lifecycle calls so plugins can see structured routing metadata during bootstrap, assembly, post-turn ingestion, and compaction. (#44157) thanks @jalehman.</li>
|
||||||
|
<li>Agents/failover: classify z.ai <code>network_error</code> stop reasons as retryable timeouts so provider connectivity failures trigger fallback instead of surfacing raw unhandled-stop-reason errors. (#43884) Thanks @hougangdev.</li>
|
||||||
|
<li>Memory/session sync: add mode-aware post-compaction session reindexing with <code>agents.defaults.compaction.postIndexSync</code> plus <code>agents.defaults.memorySearch.sync.sessions.postCompactionForce</code>, so compacted session memory can refresh immediately without forcing every deployment into synchronous reindexing. (#25561) thanks @rodrigouroz.</li>
|
||||||
|
<li>Telegram/model picker: make inline model button selections persist the chosen session model correctly, clear overrides when selecting the configured default, and include effective fallback models in <code>/models</code> button validation. (#40105) Thanks @avirweb.</li>
|
||||||
|
<li>Telegram/native command sync: suppress expected <code>BOT_COMMANDS_TOO_MUCH</code> retry error noise, add a final fallback summary log, and document the difference between command-menu overflow and real Telegram network failures.</li>
|
||||||
|
<li>Mattermost/reply media delivery: pass agent-scoped <code>mediaLocalRoots</code> through shared reply delivery so allowed local files upload correctly from button, slash-command, and model-picker replies. (#44021) Thanks @LyleLiu666.</li>
|
||||||
|
<li>Plugins/env-scoped roots: fix plugin discovery/load caches and provenance tracking so same-process <code>HOME</code>/<code>OPENCLAW_HOME</code> changes no longer reuse stale plugin state or misreport <code>~/...</code> plugins as untracked. (#44046) thanks @gumadeiras.</li>
|
||||||
|
<li>Gateway/session discovery: discover disk-only and retired ACP session stores under custom templated <code>session.store</code> roots so ACP reconciliation, session-id/session-label targeting, and run-id fallback keep working after restart. (#44176) thanks @gumadeiras.</li>
|
||||||
|
<li>Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and <code>models list --plain</code>, and migrate legacy duplicated <code>openrouter/openrouter/...</code> config entries forward on write.</li>
|
||||||
|
<li>Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when <code>hooks.allowedAgentIds</code> leaves hook routing unrestricted.</li>
|
||||||
|
<li>Agents/compaction: skip the post-compaction <code>cache-ttl</code> marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI.</li>
|
||||||
|
<li>Native chat/macOS: add <code>/new</code>, <code>/reset</code>, and <code>/clear</code> reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639.</li>
|
||||||
|
<li>Agents/compaction safeguard: route missing-model and missing-API-key cancellation warnings through the shared subsystem logger so they land in structured and file logs. (#9974) Thanks @dinakars777.</li>
|
||||||
|
<li>Cron/doctor: stop flagging canonical <code>agentTurn</code> and <code>systemEvent</code> payload kinds as legacy cron storage, while still normalizing whitespace-padded and non-canonical variants. (#44012) Thanks @shuicici.</li>
|
||||||
|
<li>ACP/client final-message delivery: preserve terminal assistant text snapshots before resolving <code>end_turn</code>, so ACP clients no longer drop the last visible reply when the gateway sends the final message body on the terminal chat event. (#17615) Thanks @pjeby.</li>
|
||||||
|
<li>Telegram/Discord status reactions: show a temporary compacting reaction during auto-compaction pauses and restore thinking afterward so the bot no longer appears frozen while context is being compacted. (#35474) thanks @Cypherm.</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.3.12/OpenClaw-2026.3.12.zip" length="23628700" type="application/octet-stream" sparkle:edSignature="o6Zdcw36l3I0jUg14H+RBqNwrhuuSsq1WMDi4tBRa1+5TC3VCVdFKZ2hzmH2Xjru9lDEzVMP8v2A6RexSbOCBQ=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.3.8-beta.1</title>
|
<title>2026.3.8-beta.1</title>
|
||||||
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
|
<pubDate>Mon, 09 Mar 2026 07:19:57 +0000</pubDate>
|
||||||
|
|
@ -438,225 +530,5 @@
|
||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
|
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.3.7/OpenClaw-2026.3.7.zip" length="23263833" type="application/octet-stream" sparkle:edSignature="SO0zedZMzrvSDltLkuaSVQTWFPPPe1iu/enS4TGGb5EGckhqRCmNJWMKNID5lKwFC8vefTbfG9JTlSrZedP4Bg=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.3.2</title>
|
|
||||||
<pubDate>Tue, 03 Mar 2026 04:30:29 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
|
||||||
<sparkle:version>2026030290</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.3.2</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>OpenClaw 2026.3.2</h2>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Secrets/SecretRef coverage: expand SecretRef support across the full supported user-supplied credential surface (64 targets total), including runtime collectors, <code>openclaw secrets</code> planning/apply/audit flows, onboarding SecretInput UX, and related docs; unresolved refs now fail fast on active surfaces while inactive surfaces report non-blocking diagnostics. (#29580) Thanks @joshavant.</li>
|
|
||||||
<li>Tools/PDF analysis: add a first-class <code>pdf</code> tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (<code>agents.defaults.pdfModel</code>, <code>pdfMaxBytesMb</code>, <code>pdfMaxPages</code>), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.</li>
|
|
||||||
<li>Outbound adapters/plugins: add shared <code>sendPayload</code> support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.</li>
|
|
||||||
<li>Models/MiniMax: add first-class <code>MiniMax-M2.5-highspeed</code> support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy <code>MiniMax-M2.5-Lightning</code> compatibility for existing configs.</li>
|
|
||||||
<li>Sessions/Attachments: add inline file attachment support for <code>sessions_spawn</code> (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via <code>tools.sessions_spawn.attachments</code>. (#16761) Thanks @napetrov.</li>
|
|
||||||
<li>Telegram/Streaming defaults: default <code>channels.telegram.streaming</code> to <code>partial</code> (from <code>off</code>) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.</li>
|
|
||||||
<li>Telegram/DM streaming: use <code>sendMessageDraft</code> for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.</li>
|
|
||||||
<li>Telegram/voice mention gating: add optional <code>disableAudioPreflight</code> on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.</li>
|
|
||||||
<li>CLI/Config validation: add <code>openclaw config validate</code> (with <code>--json</code>) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.</li>
|
|
||||||
<li>Tools/Diffs: add PDF file output support and rendering quality customization controls (<code>fileQuality</code>, <code>fileScale</code>, <code>fileMaxWidth</code>) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.</li>
|
|
||||||
<li>Memory/Ollama embeddings: add <code>memorySearch.provider = "ollama"</code> and <code>memorySearch.fallback = "ollama"</code> support, honor <code>models.providers.ollama</code> settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.</li>
|
|
||||||
<li>Zalo Personal plugin (<code>@openclaw/zalouser</code>): rebuilt channel runtime to use native <code>zca-js</code> integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.</li>
|
|
||||||
<li>Plugin SDK/channel extensibility: expose <code>channelRuntime</code> on <code>ChannelGatewayContext</code> so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.</li>
|
|
||||||
<li>Plugin runtime/STT: add <code>api.runtime.stt.transcribeAudioFile(...)</code> so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.</li>
|
|
||||||
<li>Plugin hooks/session lifecycle: include <code>sessionKey</code> in <code>session_start</code>/<code>session_end</code> hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.</li>
|
|
||||||
<li>Hooks/message lifecycle: add internal hook events <code>message:transcribed</code> and <code>message:preprocessed</code>, plus richer outbound <code>message:sent</code> context (<code>isGroup</code>, <code>groupId</code>) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.</li>
|
|
||||||
<li>Media understanding/audio echo: add optional <code>tools.media.audio.echoTranscript</code> + <code>echoFormat</code> to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.</li>
|
|
||||||
<li>Plugin runtime/system: expose <code>runtime.system.requestHeartbeatNow(...)</code> so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.</li>
|
|
||||||
<li>Plugin runtime/events: expose <code>runtime.events.onAgentEvent</code> and <code>runtime.events.onSessionTranscriptUpdate</code> for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.</li>
|
|
||||||
<li>CLI/Banner taglines: add <code>cli.banner.taglineMode</code> (<code>random</code> | <code>default</code> | <code>off</code>) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Breaking</h3>
|
|
||||||
<ul>
|
|
||||||
<li><strong>BREAKING:</strong> Onboarding now defaults <code>tools.profile</code> to <code>messaging</code> for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.</li>
|
|
||||||
<li><strong>BREAKING:</strong> ACP dispatch now defaults to enabled unless explicitly disabled (<code>acp.dispatch.enabled=false</code>). If you need to pause ACP turn routing while keeping <code>/acp</code> controls, set <code>acp.dispatch.enabled=false</code>. Docs: https://docs.openclaw.ai/tools/acp-agents</li>
|
|
||||||
<li><strong>BREAKING:</strong> Plugin SDK removed <code>api.registerHttpHandler(...)</code>. Plugins must register explicit HTTP routes via <code>api.registerHttpRoute({ path, auth, match, handler })</code>, and dynamic webhook lifecycles should use <code>registerPluginHttpRoute(...)</code>.</li>
|
|
||||||
<li><strong>BREAKING:</strong> Zalo Personal plugin (<code>@openclaw/zalouser</code>) no longer depends on external <code>zca</code>-compatible CLI binaries (<code>openzca</code>, <code>zca-cli</code>) for runtime send/listen/login; operators should use <code>openclaw channels login --channel zalouser</code> after upgrade to refresh sessions in the new JS-native path.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (<code>trim</code> on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing <code>token.trim()</code> crashes during status/start flows. (#31973) Thanks @ningding97.</li>
|
|
||||||
<li>Discord/lifecycle startup status: push an immediate <code>connected</code> status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.</li>
|
|
||||||
<li>Feishu/LINE group system prompts: forward per-group <code>systemPrompt</code> config into inbound context <code>GroupSystemPrompt</code> for Feishu and LINE group/room events so configured group-specific behavior actually applies at dispatch time. (#31713) Thanks @whiskyboy.</li>
|
|
||||||
<li>Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.</li>
|
|
||||||
<li>Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older <code>openclaw/plugin-sdk</code> builds omit webhook default constants. (#31606)</li>
|
|
||||||
<li>Feishu/group broadcast dispatch: add configurable multi-agent group broadcast dispatch with observer-session isolation, cross-account dedupe safeguards, and non-mention history buffering rules that avoid duplicate replay in broadcast/topic workflows. (#29575) Thanks @ohmyskyhigh.</li>
|
|
||||||
<li>Gateway/Subagent TLS pairing: allow authenticated local <code>gateway-client</code> backend self-connections to skip device pairing while still requiring pairing for non-local/direct-host paths, restoring <code>sessions_spawn</code> with <code>gateway.tls.enabled=true</code> in Docker/LAN setups. Fixes #30740. Thanks @Sid-Qin and @vincentkoc.</li>
|
|
||||||
<li>Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.</li>
|
|
||||||
<li>Synology Chat/webhook ingress hardening: enforce bounded body reads (size + timeout) via shared request-body guards to prevent unauthenticated slow-body hangs before token validation. (#25831) Thanks @bmendonca3.</li>
|
|
||||||
<li>Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)</li>
|
|
||||||
<li>Voice-call/runtime lifecycle: prevent <code>EADDRINUSE</code> loops by resetting failed runtime promises, making webhook <code>start()</code> idempotent with the actual bound port, and fully cleaning up webhook/tunnel/tailscale resources after startup failures. (#32395) Thanks @scoootscooob.</li>
|
|
||||||
<li>Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when <code>gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback</code> accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example <code>(a|aa)+</code>), and bound large regex-evaluation inputs for session-filter and log-redaction paths.</li>
|
|
||||||
<li>Gateway/Plugin HTTP hardening: require explicit <code>auth</code> for plugin route registration, add route ownership guards for duplicate <code>path+match</code> registrations, centralize plugin path matching/auth logic into dedicated modules, and share webhook target-route lifecycle wiring across channel monitors to avoid stale or conflicting registrations. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Browser/Profile defaults: prefer <code>openclaw</code> profile over <code>chrome</code> in headless/no-sandbox environments unless an explicit <code>defaultProfile</code> is configured. (#14944) Thanks @BenediktSchackenberg.</li>
|
|
||||||
<li>Gateway/WS security: keep plaintext <code>ws://</code> loopback-only by default, with explicit break-glass private-network opt-in via <code>OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1</code>; align onboarding/client/call validation and tests to this strict-default policy. (#28670) Thanks @dashed, @vincentkoc.</li>
|
|
||||||
<li>OpenAI Codex OAuth/TLS prerequisites: add an OAuth TLS cert-chain preflight with actionable remediation for cert trust failures, and gate doctor TLS prerequisite probing to OpenAI Codex OAuth-configured installs (or explicit <code>doctor --deep</code>) to avoid unconditional outbound probe latency. (#32051) Thanks @alexfilatov.</li>
|
|
||||||
<li>Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.</li>
|
|
||||||
<li>CLI/Config validation and routing hardening: dedupe <code>openclaw config validate</code> failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including <code>--json</code> fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed <code>config get/unset</code> with split root options). Thanks @gumadeiras.</li>
|
|
||||||
<li>Browser/Extension relay reconnect tolerance: keep <code>/json/version</code> and <code>/cdp</code> reachable during short MV3 worker disconnects when attached targets still exist, and retain clients across reconnect grace windows. (#30232) Thanks @Sid-Qin.</li>
|
|
||||||
<li>CLI/Browser start timeout: honor <code>openclaw browser --timeout <ms> start</code> and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.</li>
|
|
||||||
<li>Synology Chat/gateway lifecycle: keep <code>startAccount</code> pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.</li>
|
|
||||||
<li>Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like <code>/usr/bin/g++</code> and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.</li>
|
|
||||||
<li>Synology Chat/webhook compatibility: accept JSON and alias payload fields, allow token resolution from body/query/header sources, and ACK webhook requests with <code>204</code> to avoid persistent <code>Processing...</code> states in Synology Chat clients. (#26635) Thanks @memphislee09-source.</li>
|
|
||||||
<li>Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.</li>
|
|
||||||
<li>Slack/Bolt startup compatibility: remove invalid <code>message.channels</code> and <code>message.groups</code> event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified <code>message</code> handler (<code>channel_type</code>). (#32033) Thanks @mahopan.</li>
|
|
||||||
<li>Slack/socket auth failure handling: fail fast on non-recoverable auth errors (<code>account_inactive</code>, <code>invalid_auth</code>, etc.) during startup and reconnect instead of retry-looping indefinitely, including <code>unable_to_socket_mode_start</code> error payload propagation. (#32377) Thanks @scoootscooob.</li>
|
|
||||||
<li>Gateway/macOS LaunchAgent hardening: write <code>Umask=077</code> in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>macOS/LaunchAgent security defaults: write <code>Umask=63</code> (octal <code>077</code>) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system <code>022</code>. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from <code>HTTPS_PROXY</code>/<code>HTTP_PROXY</code> env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.</li>
|
|
||||||
<li>Sandbox/workspace mount permissions: make primary <code>/workspace</code> bind mounts read-only whenever <code>workspaceAccess</code> is not <code>rw</code> (including <code>none</code>) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.</li>
|
|
||||||
<li>Tools/fsPolicy propagation: honor <code>tools.fs.workspaceOnly</code> for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.</li>
|
|
||||||
<li>Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like <code>node@22</code>) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.</li>
|
|
||||||
<li>Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded <code>/api/channels/*</code> variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Browser/Gateway hardening: preserve env credentials for <code>OPENCLAW_GATEWAY_URL</code> / <code>CLAWDBOT_GATEWAY_URL</code> while treating explicit <code>--url</code> as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc.</li>
|
|
||||||
<li>Gateway/Control UI basePath webhook passthrough: let non-read methods under configured <code>controlUiBasePath</code> fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.</li>
|
|
||||||
<li>Control UI/Legacy browser compatibility: replace <code>toSorted</code>-dependent cron suggestion sorting in <code>app-render</code> with a compatibility helper so older browsers without <code>Array.prototype.toSorted</code> no longer white-screen. (#31775) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>macOS/PeekabooBridge: add compatibility socket symlinks for legacy <code>clawdbot</code>, <code>clawdis</code>, and <code>moltbot</code> Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.</li>
|
|
||||||
<li>Gateway/message tool reliability: avoid false <code>Unknown channel</code> failures when <code>message.*</code> actions receive platform-specific channel ids by falling back to <code>toolContext.currentChannelProvider</code>, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.</li>
|
|
||||||
<li>Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for <code>.cmd</code> shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman.</li>
|
|
||||||
<li>Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for <code>sessions_spawn</code> with <code>runtime="acp"</code> by rejecting ACP spawns from sandboxed requester sessions and rejecting <code>sandbox="require"</code> for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.</li>
|
|
||||||
<li>Security/Web tools SSRF guard: keep DNS pinning for untrusted <code>web_fetch</code> and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Gemini schema sanitization: coerce malformed JSON Schema <code>properties</code> values (<code>null</code>, arrays, primitives) to <code>{}</code> before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.</li>
|
|
||||||
<li>Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.</li>
|
|
||||||
<li>Browser/Extension navigation reattach: preserve debugger re-attachment when relay is temporarily disconnected by deferring relay attach events until reconnect/re-announce, reducing post-navigation tab loss. (#28725) Thanks @stone-jin.</li>
|
|
||||||
<li>Browser/Extension relay stale tabs: evict stale cached targets from <code>/json/list</code> when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.</li>
|
|
||||||
<li>Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up <code>PortInUseError</code> races after <code>browser start</code>/<code>open</code>. (#29538) Thanks @AaronWander.</li>
|
|
||||||
<li>OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty <code>function_call_output.call_id</code> payloads in the WS conversion path to avoid OpenAI 400 errors (<code>Invalid 'input[n].call_id': empty string</code>), with regression coverage for both inbound stream normalization and outbound payload guards.</li>
|
|
||||||
<li>Security/Nodes camera URL downloads: bind node <code>camera.snap</code>/<code>camera.clip</code> URL payload downloads to the resolved node host, enforce fail-closed behavior when node <code>remoteIp</code> is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Config/backups hardening: enforce owner-only (<code>0600</code>) permissions on rotated config backups and clean orphan <code>.bak.*</code> files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.</li>
|
|
||||||
<li>Telegram/inbound media filenames: preserve original <code>file_name</code> metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.</li>
|
|
||||||
<li>Gateway/OpenAI chat completions: honor <code>x-openclaw-message-channel</code> when building <code>agentCommand</code> input for <code>/v1/chat/completions</code>, preserving caller channel identity instead of forcing <code>webchat</code>. (#30462) Thanks @bmendonca3.</li>
|
|
||||||
<li>Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.</li>
|
|
||||||
<li>Media/MIME normalization: normalize parameterized/case-variant MIME strings in <code>kindFromMime</code> (for example <code>Audio/Ogg; codecs=opus</code>) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.</li>
|
|
||||||
<li>Discord/audio preflight mentions: detect audio attachments via Discord <code>content_type</code> and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.</li>
|
|
||||||
<li>Feishu/topic session routing: use <code>thread_id</code> as topic session scope fallback when <code>root_id</code> is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.</li>
|
|
||||||
<li>Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of <code>NO_REPLY</code> and keep final-message buffering in sync, preventing partial <code>NO</code> leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.</li>
|
|
||||||
<li>Context-window metadata warmup: add exponential config-load retry backoff (1s -> 2s -> 4s, capped at 60s) so transient startup failures recover automatically without hot-loop retries.</li>
|
|
||||||
<li>Voice-call/Twilio external outbound: auto-register webhook-first <code>outbound-api</code> calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.</li>
|
|
||||||
<li>Feishu/topic root replies: prefer <code>root_id</code> as outbound <code>replyTargetMessageId</code> when present, and parse millisecond <code>message_create_time</code> values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.</li>
|
|
||||||
<li>Feishu/DM pairing reply target: send pairing challenge replies to <code>chat:<chat_id></code> instead of <code>user:<sender_open_id></code> so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.</li>
|
|
||||||
<li>Feishu/Lark private DM routing: treat inbound <code>chat_type: "private"</code> as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.</li>
|
|
||||||
<li>Signal/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.</li>
|
|
||||||
<li>Discord/message actions: allow <code>react</code> to fall back to <code>toolContext.currentMessageId</code> when <code>messageId</code> is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.</li>
|
|
||||||
<li>Synology Chat/reply delivery: resolve webhook usernames to Chat API <code>user_id</code> values for outbound chatbot replies, avoiding mismatches between webhook user IDs and <code>method=chatbot</code> recipient IDs in multi-account setups. (#23709) Thanks @druide67.</li>
|
|
||||||
<li>Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.</li>
|
|
||||||
<li>Slack/session routing: keep top-level channel messages in one shared session when <code>replyToMode=off</code>, while preserving thread-scoped keys for true thread replies and non-off modes. (#32193) Thanks @bmendonca3.</li>
|
|
||||||
<li>Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.</li>
|
|
||||||
<li>Zalo/Pairing auth tests: add webhook regression coverage asserting DM pairing-store reads/writes remain account-scoped, preventing cross-account authorization bleed in multi-account setups. (#26121) Thanks @bmendonca3.</li>
|
|
||||||
<li>Zalouser/Pairing auth tests: add account-scoped DM pairing-store regression coverage (<code>monitor.account-scope.test.ts</code>) to prevent cross-account allowlist bleed in multi-account setups. (#26672) Thanks @bmendonca3.</li>
|
|
||||||
<li>Feishu/Send target prefixes: normalize explicit <code>group:</code>/<code>dm:</code> send targets and preserve explicit receive-id routing hints when resolving outbound Feishu targets. (#31594) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Webchat/Feishu session continuation: preserve routable <code>OriginatingChannel</code>/<code>OriginatingTo</code> metadata from session delivery context in <code>chat.send</code>, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)</li>
|
|
||||||
<li>Telegram/implicit mention forum handling: exclude Telegram forum system service messages (<code>forum_topic_*</code>, <code>general_forum_topic_*</code>) from reply-chain implicit mention detection so <code>requireMention</code> does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.</li>
|
|
||||||
<li>Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.</li>
|
|
||||||
<li>Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (<code>provider: "message"</code>) and normalize <code>lark</code>/<code>feishu</code> provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)</li>
|
|
||||||
<li>Webchat/silent token leak: filter assistant <code>NO_REPLY</code>-only transcript entries from <code>chat.history</code> responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.</li>
|
|
||||||
<li>Doctor/local memory provider checks: stop false-positive local-provider warnings when <code>provider=local</code> and no explicit <code>modelPath</code> is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.</li>
|
|
||||||
<li>Media understanding/parakeet CLI output parsing: read <code>parakeet-mlx</code> transcripts from <code>--output-dir/<media-basename>.txt</code> when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.</li>
|
|
||||||
<li>Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.</li>
|
|
||||||
<li>Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Gateway/Node browser proxy routing: honor <code>profile</code> from <code>browser.request</code> JSON body when query params omit it, while preserving query-profile precedence when both are present. (#28852) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Gateway/Control UI basePath POST handling: return 405 for <code>POST</code> on exact basePath routes (for example <code>/openclaw</code>) instead of redirecting, and add end-to-end regression coverage that root-mounted webhook POST paths still pass through to plugin handlers. (#31349) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Browser/default profile selection: default <code>browser.defaultProfile</code> behavior now prefers <code>openclaw</code> (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the <code>chrome</code> relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Sandbox/mkdirp boundary checks: allow existing in-boundary directories to pass mkdirp boundary validation when directory open probes return platform-specific I/O errors, with regression coverage for directory-safe fallback behavior. (#31547) Thanks @stakeswky.</li>
|
|
||||||
<li>Models/config env propagation: apply <code>config.env.vars</code> before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.</li>
|
|
||||||
<li>Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so <code>openclaw models status</code> no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.</li>
|
|
||||||
<li>Gateway/Heartbeat model reload: treat <code>models.*</code> and <code>agents.defaults.model</code> config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.</li>
|
|
||||||
<li>Memory/LanceDB embeddings: forward configured <code>embedding.dimensions</code> into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.</li>
|
|
||||||
<li>Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.</li>
|
|
||||||
<li>Browser/CDP status accuracy: require a successful <code>Browser.getVersion</code> response over the CDP websocket (not just socket-open) before reporting <code>cdpReady</code>, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.</li>
|
|
||||||
<li>Daemon/systemd checks in containers: treat missing <code>systemctl</code> invocations (including <code>spawn systemctl ENOENT</code>/<code>EACCES</code>) as unavailable service state during <code>is-enabled</code> checks, preventing container flows from failing with <code>Gateway service check failed</code> before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc.</li>
|
|
||||||
<li>Security/Node exec approvals: revalidate approval-bound <code>cwd</code> identity immediately before execution/forwarding and fail closed with an explicit denial when <code>cwd</code> drifts after approval hardening.</li>
|
|
||||||
<li>Security audit/skills workspace hardening: add <code>skills.workspace.symlink_escape</code> warning in <code>openclaw security audit</code> when workspace <code>skills/**/SKILL.md</code> resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.</li>
|
|
||||||
<li>Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example <code>env sh -c ...</code>) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Security/fs-safe write hardening: make <code>writeFileWithinRoot</code> use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.</li>
|
|
||||||
<li>Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.</li>
|
|
||||||
<li>Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like <code>[System Message]</code> and line-leading <code>System:</code> in untrusted message content. (#30448)</li>
|
|
||||||
<li>Sandbox/Docker setup command parsing: accept <code>agents.*.sandbox.docker.setupCommand</code> as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction <code>AGENTS.md</code> context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.</li>
|
|
||||||
<li>Agents/Sandbox workdir mapping: map container workdir paths (for example <code>/workspace</code>) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Docker/Sandbox bootstrap hardening: make <code>OPENCLAW_SANDBOX</code> opt-in parsing explicit (<code>1|true|yes|on</code>), support custom Docker socket paths via <code>OPENCLAW_DOCKER_SOCKET</code>, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to <code>off</code> when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.</li>
|
|
||||||
<li>Hooks/webhook ACK compatibility: return <code>200</code> (instead of <code>202</code>) for successful <code>/hooks/agent</code> requests so providers that require <code>200</code> (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.</li>
|
|
||||||
<li>Feishu/Run channel fallback: prefer <code>Provider</code> over <code>Surface</code> when inferring queued run <code>messageProvider</code> fallback (when <code>OriginatingChannel</code> is missing), preventing Feishu turns from being mislabeled as <code>webchat</code> in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Skills/sherpa-onnx-tts: run the <code>sherpa-onnx-tts</code> bin under ESM (replace CommonJS <code>require</code> imports) and add regression coverage to prevent <code>require is not defined in ES module scope</code> startup crashes. (#31965) Thanks @bmendonca3.</li>
|
|
||||||
<li>Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.</li>
|
|
||||||
<li>Slack/Channel message subscriptions: register explicit <code>message.channels</code> and <code>message.groups</code> monitor handlers (alongside generic <code>message</code>) so channel/group event subscriptions are consumed even when Slack dispatches typed message event names. Fixes #31674.</li>
|
|
||||||
<li>Hooks/session-scoped memory context: expose ephemeral <code>sessionId</code> in embedded plugin tool contexts and <code>before_tool_call</code>/<code>after_tool_call</code> hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across <code>/new</code> and <code>/reset</code>. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.</li>
|
|
||||||
<li>Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.</li>
|
|
||||||
<li>Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.</li>
|
|
||||||
<li>Feishu/File upload filenames: percent-encode non-ASCII/special-character <code>file_name</code> values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.</li>
|
|
||||||
<li>Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized <code>kindFromMime</code> so mixed-case/parameterized MIME values classify consistently across message channels.</li>
|
|
||||||
<li>WhatsApp/inbound self-message context: propagate inbound <code>fromMe</code> through the web inbox pipeline and annotate direct self messages as <code>(self)</code> in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.</li>
|
|
||||||
<li>Webchat/stream finalization: persist streamed assistant text when final events omit <code>message</code>, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)</li>
|
|
||||||
<li>Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)</li>
|
|
||||||
<li>Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)</li>
|
|
||||||
<li>Feishu/Streaming block fallback: preserve markdown block stream text as final streaming-card content when final payload text is missing, while still suppressing non-card internal block chunk delivery. (#30663)</li>
|
|
||||||
<li>Feishu/Bitable API errors: unify Feishu Bitable tool error handling with structured <code>LarkApiError</code> responses and consistent API/context attribution across wiki/base metadata, field, and record operations. (#31450)</li>
|
|
||||||
<li>Feishu/Missing-scope grant URL fix: rewrite known invalid scope aliases (<code>contact:contact.base:readonly</code>) to valid scope names in permission grant links, so remediation URLs open with correct Feishu consent scopes. (#31943)</li>
|
|
||||||
<li>BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound <code>message_id</code> selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.</li>
|
|
||||||
<li>WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.</li>
|
|
||||||
<li>Feishu/default account resolution: always honor explicit <code>channels.feishu.defaultAccount</code> during outbound account selection (including top-level-credential setups where the preferred id is not present in <code>accounts</code>), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.</li>
|
|
||||||
<li>Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (<code>contact:contact.base:readonly</code>) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)</li>
|
|
||||||
<li>Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)</li>
|
|
||||||
<li>Tests/Sandbox + archive portability: use junction-compatible directory-link setup on Windows and explicit file-symlink platform guards in symlink escape tests where unprivileged file symlinks are unavailable, reducing false Windows CI failures while preserving traversal checks on supported paths. (#28747) Thanks @arosstale.</li>
|
|
||||||
<li>Browser/Extension re-announce reliability: keep relay state in <code>connecting</code> when re-announce forwarding fails and extend debugger re-attach retries after navigation to reduce false attached states and post-nav disconnect loops. (#27630) Thanks @markmusson.</li>
|
|
||||||
<li>Browser/Act request compatibility: accept legacy flattened <code>action="act"</code> params (<code>kind/ref/text/...</code>) in addition to <code>request={...}</code> so browser act calls no longer fail with <code>request required</code>. (#15120) Thanks @vincentkoc.</li>
|
|
||||||
<li>OpenRouter/x-ai compatibility: skip <code>reasoning.effort</code> injection for <code>x-ai/*</code> models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.</li>
|
|
||||||
<li>Models/openai-completions developer-role compatibility: force <code>supportsDeveloperRole=false</code> for non-native endpoints, treat unparseable <code>baseUrl</code> values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.</li>
|
|
||||||
<li>Browser/Profile attach-only override: support <code>browser.profiles.<name>.attachOnly</code> (fallback to global <code>browser.attachOnly</code>) so loopback proxy profiles can skip local launch/port-ownership checks without forcing attach-only mode for every profile. (#20595) Thanks @unblockedgamesstudio and @vincentkoc.</li>
|
|
||||||
<li>Sessions/Lock recovery: detect recycled Linux PIDs by comparing lock-file <code>starttime</code> with <code>/proc/<pid>/stat</code> starttime, so stale <code>.jsonl.lock</code> files are reclaimed immediately in containerized PID-reuse scenarios while preserving compatibility for older lock files. (#26443) Fixes #27252. Thanks @HirokiKobayashi-R and @vincentkoc.</li>
|
|
||||||
<li>Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel <code>resolveDefaultTo</code> fallback) when <code>delivery.to</code> is omitted. (#32364) Thanks @hclsys.</li>
|
|
||||||
<li>OpenAI media capabilities: include <code>audio</code> in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.</li>
|
|
||||||
<li>Browser/Managed tab cap: limit loopback managed <code>openclaw</code> page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.</li>
|
|
||||||
<li>Docker/Image health checks: add Dockerfile <code>HEALTHCHECK</code> that probes gateway <code>GET /healthz</code> so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.</li>
|
|
||||||
<li>Gateway/Node dangerous-command parity: include <code>sms.send</code> in default onboarding node <code>denyCommands</code>, share onboarding deny defaults with the gateway dangerous-command source of truth, and include <code>sms.send</code> in phone-control <code>/phone arm writes</code> handling so SMS follows the same break-glass flow as other dangerous node commands. Thanks @zpbrent.</li>
|
|
||||||
<li>Pairing/AllowFrom account fallback: handle omitted <code>accountId</code> values in <code>readChannelAllowFromStore</code> and <code>readChannelAllowFromStoreSync</code> as <code>default</code>, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.</li>
|
|
||||||
<li>Browser/Remote CDP ownership checks: skip local-process ownership errors for non-loopback remote CDP profiles when HTTP is reachable but the websocket handshake fails, and surface the remote websocket attach/retry path instead. (#15582) Landed from contributor (#28780) Thanks @stubbi, @bsormagec, @unblockedgamesstudio and @vincentkoc.</li>
|
|
||||||
<li>Browser/CDP proxy bypass: force direct loopback agent paths and scoped <code>NO_PROXY</code> expansion for localhost CDP HTTP/WS connections when proxy env vars are set, so browser relay/control still works behind global proxy settings. (#31469) Thanks @widingmarcus-cyber.</li>
|
|
||||||
<li>Sessions/idle reset correctness: preserve existing <code>updatedAt</code> during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.</li>
|
|
||||||
<li>Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing <code>starttime</code> when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.</li>
|
|
||||||
<li>Sessions/store cache invalidation: reload cached session stores when file size changes within the same mtime tick by keying cache validation on a single file-stat snapshot (<code>mtimeMs</code> + <code>sizeBytes</code>), with regression coverage for same-tick rewrites. (#32191) Thanks @jalehman.</li>
|
|
||||||
<li>Agents/Subagents <code>sessions_spawn</code>: reject malformed <code>agentId</code> inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.</li>
|
|
||||||
<li>CLI/installer Node preflight: enforce Node.js <code>v22.12+</code> consistently in both <code>openclaw.mjs</code> runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.</li>
|
|
||||||
<li>Web UI/config form: support SecretInput string-or-secret-ref unions in map <code>additionalProperties</code>, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.</li>
|
|
||||||
<li>Auto-reply/inline command cleanup: preserve newline structure when stripping inline <code>/status</code> and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.</li>
|
|
||||||
<li>Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like <code>source</code>/<code>provider</code>), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.</li>
|
|
||||||
<li>Hooks/runtime stability: keep the internal hook handler registry on a <code>globalThis</code> singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.</li>
|
|
||||||
<li>Hooks/after_tool_call: include embedded session context (<code>sessionKey</code>, <code>agentId</code>) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.</li>
|
|
||||||
<li>Hooks/tool-call correlation: include <code>runId</code> and <code>toolCallId</code> in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in <code>before_tool_call</code> and <code>after_tool_call</code>. (#32360) Thanks @vincentkoc.</li>
|
|
||||||
<li>Plugins/install diagnostics: reject legacy plugin package shapes without <code>openclaw.extensions</code> and return an explicit upgrade hint with troubleshooting docs for repackaging. (#32055) Thanks @liuxiaopai-ai.</li>
|
|
||||||
<li>Hooks/plugin context parity: ensure <code>llm_input</code> hooks in embedded attempts receive the same <code>trigger</code> and <code>channelId</code>-aware <code>hookCtx</code> used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.</li>
|
|
||||||
<li>Plugins/hardlink install compatibility: allow bundled plugin manifests and entry files to load when installed via hardlink-based package managers (<code>pnpm</code>, <code>bun</code>) while keeping hardlink rejection enabled for non-bundled plugin sources. (#32119) Fixes #28175, #28404, #29455. Thanks @markfietje.</li>
|
|
||||||
<li>Cron/session reaper reliability: move cron session reaper sweeps into <code>onTimer</code> <code>finally</code> and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.</li>
|
|
||||||
<li>Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so <code>HEARTBEAT_OK</code> noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.</li>
|
|
||||||
<li>Authentication: classify <code>permission_error</code> as <code>auth_permanent</code> for profile fallback. (#31324) Thanks @Sid-Qin.</li>
|
|
||||||
<li>Agents/host edit reliability: treat host edit-tool throws as success only when on-disk post-check confirms replacement likely happened (<code>newText</code> present and <code>oldText</code> absent), preventing false failure reports while avoiding pre-write false positives. (#32383) Thanks @polooooo.</li>
|
|
||||||
<li>Plugins/install fallback safety: resolve bare install specs to bundled plugin ids before npm lookup (for example <code>diffs</code> -> bundled <code>@openclaw/diffs</code>), keep npm fallback limited to true package-not-found errors, and continue rejecting non-plugin npm packages that fail manifest validation. (#32096) Thanks @scoootscooob.</li>
|
|
||||||
<li>Web UI/inline code copy fidelity: disable forced mid-token wraps on inline <code><code></code> spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.</li>
|
|
||||||
<li>Restart sentinel formatting: avoid duplicate <code>Reason:</code> lines when restart message text already matches <code>stats.reason</code>, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.</li>
|
|
||||||
<li>Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.</li>
|
|
||||||
<li>Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.</li>
|
|
||||||
<li>Failover/error classification: treat HTTP <code>529</code> (provider overloaded, common with Anthropic-compatible APIs) as <code>rate_limit</code> so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.</li>
|
|
||||||
<li>Logging: use local time for logged timestamps instead of UTC, aligning log output with documented local timezone behavior and avoiding confusion during local diagnostics. (#28434) Thanks @liuy.</li>
|
|
||||||
<li>Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.</li>
|
|
||||||
<li>Secrets/exec resolver timeout defaults: use provider <code>timeoutMs</code> as the default inactivity (<code>noOutputTimeoutMs</code>) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.</li>
|
|
||||||
<li>Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.</li>
|
|
||||||
<li>Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing <code>HEARTBEAT_OK</code> from being delivered to users. (#32131) Thanks @adhishthite.</li>
|
|
||||||
<li>Cron/store migration: normalize legacy cron jobs with string <code>schedule</code> and top-level <code>command</code>/<code>timeout</code> fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.</li>
|
|
||||||
<li>Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.</li>
|
|
||||||
<li>Tests/Subagent announce: set <code>OPENCLAW_TEST_FAST=1</code> before importing <code>subagent-announce</code> format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.</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.3.2/OpenClaw-2026.3.2.zip" length="23181513" type="application/octet-stream" sparkle:edSignature="THMgkcoMgz2vv5zse3Po3K7l3Or2RhBKurXZIi8iYVXN76yJy1YXAY6kXi6ovD+dbYn68JKYDIKA1Ya78bO7BQ=="/>
|
|
||||||
<!-- pragma: allowlist secret -->
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
|
|
@ -63,8 +63,8 @@ android {
|
||||||
applicationId = "ai.openclaw.app"
|
applicationId = "ai.openclaw.app"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 202603090
|
versionCode = 202603130
|
||||||
versionName = "2026.3.9"
|
versionName = "2026.3.13"
|
||||||
ndk {
|
ndk {
|
||||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,10 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
runtime.setGatewayToken(value)
|
runtime.setGatewayToken(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayBootstrapToken(value: String) {
|
||||||
|
runtime.setGatewayBootstrapToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setGatewayPassword(value: String) {
|
fun setGatewayPassword(value: String) {
|
||||||
runtime.setGatewayPassword(value)
|
runtime.setGatewayPassword(value)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,7 @@ class NodeRuntime(context: Context) {
|
||||||
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||||
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
||||||
|
fun setGatewayBootstrapToken(value: String) = prefs.setGatewayBootstrapToken(value)
|
||||||
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
fun setGatewayPassword(value: String) = prefs.setGatewayPassword(value)
|
||||||
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
fun setOnboardingCompleted(value: Boolean) = prefs.setOnboardingCompleted(value)
|
||||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||||
|
|
@ -698,10 +699,25 @@ class NodeRuntime(context: Context) {
|
||||||
operatorStatusText = "Connecting…"
|
operatorStatusText = "Connecting…"
|
||||||
updateStatus()
|
updateStatus()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
|
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
operatorSession.connect(
|
||||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildOperatorConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
|
nodeSession.connect(
|
||||||
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildNodeConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
operatorSession.reconnect()
|
operatorSession.reconnect()
|
||||||
nodeSession.reconnect()
|
nodeSession.reconnect()
|
||||||
}
|
}
|
||||||
|
|
@ -726,9 +742,24 @@ class NodeRuntime(context: Context) {
|
||||||
nodeStatusText = "Connecting…"
|
nodeStatusText = "Connecting…"
|
||||||
updateStatus()
|
updateStatus()
|
||||||
val token = prefs.loadGatewayToken()
|
val token = prefs.loadGatewayToken()
|
||||||
|
val bootstrapToken = prefs.loadGatewayBootstrapToken()
|
||||||
val password = prefs.loadGatewayPassword()
|
val password = prefs.loadGatewayPassword()
|
||||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
operatorSession.connect(
|
||||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildOperatorConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
|
nodeSession.connect(
|
||||||
|
endpoint,
|
||||||
|
token,
|
||||||
|
bootstrapToken,
|
||||||
|
password,
|
||||||
|
connectionManager.buildNodeConnectOptions(),
|
||||||
|
tls,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptGatewayTrustPrompt() {
|
fun acceptGatewayTrustPrompt() {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class SecurePrefs(context: Context) {
|
class SecurePrefs(
|
||||||
|
context: Context,
|
||||||
|
private val securePrefsOverride: SharedPreferences? = null,
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
val defaultWakeWords: List<String> = listOf("openclaw", "claude")
|
||||||
private const val displayNameKey = "node.displayName"
|
private const val displayNameKey = "node.displayName"
|
||||||
|
|
@ -35,7 +38,7 @@ class SecurePrefs(context: Context) {
|
||||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
private val securePrefs: SharedPreferences by lazy { createSecurePrefs(appContext, securePrefsName) }
|
private val securePrefs: SharedPreferences by lazy { securePrefsOverride ?: createSecurePrefs(appContext, securePrefsName) }
|
||||||
|
|
||||||
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
private val _instanceId = MutableStateFlow(loadOrCreateInstanceId())
|
||||||
val instanceId: StateFlow<String> = _instanceId
|
val instanceId: StateFlow<String> = _instanceId
|
||||||
|
|
@ -76,6 +79,9 @@ class SecurePrefs(context: Context) {
|
||||||
private val _gatewayToken = MutableStateFlow("")
|
private val _gatewayToken = MutableStateFlow("")
|
||||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||||
|
|
||||||
|
private val _gatewayBootstrapToken = MutableStateFlow("")
|
||||||
|
val gatewayBootstrapToken: StateFlow<String> = _gatewayBootstrapToken
|
||||||
|
|
||||||
private val _onboardingCompleted =
|
private val _onboardingCompleted =
|
||||||
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
MutableStateFlow(plainPrefs.getBoolean("onboarding.completed", false))
|
||||||
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
val onboardingCompleted: StateFlow<Boolean> = _onboardingCompleted
|
||||||
|
|
@ -165,6 +171,10 @@ class SecurePrefs(context: Context) {
|
||||||
saveGatewayPassword(value)
|
saveGatewayPassword(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGatewayBootstrapToken(value: String) {
|
||||||
|
saveGatewayBootstrapToken(value)
|
||||||
|
}
|
||||||
|
|
||||||
fun setOnboardingCompleted(value: Boolean) {
|
fun setOnboardingCompleted(value: Boolean) {
|
||||||
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
plainPrefs.edit { putBoolean("onboarding.completed", value) }
|
||||||
_onboardingCompleted.value = value
|
_onboardingCompleted.value = value
|
||||||
|
|
@ -193,6 +203,26 @@ class SecurePrefs(context: Context) {
|
||||||
securePrefs.edit { putString(key, token.trim()) }
|
securePrefs.edit { putString(key, token.trim()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadGatewayBootstrapToken(): String? {
|
||||||
|
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||||
|
val stored =
|
||||||
|
_gatewayBootstrapToken.value.trim().ifEmpty {
|
||||||
|
val persisted = securePrefs.getString(key, null)?.trim().orEmpty()
|
||||||
|
if (persisted.isNotEmpty()) {
|
||||||
|
_gatewayBootstrapToken.value = persisted
|
||||||
|
}
|
||||||
|
persisted
|
||||||
|
}
|
||||||
|
return stored.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveGatewayBootstrapToken(token: String) {
|
||||||
|
val key = "gateway.bootstrapToken.${_instanceId.value}"
|
||||||
|
val trimmed = token.trim()
|
||||||
|
securePrefs.edit { putString(key, trimmed) }
|
||||||
|
_gatewayBootstrapToken.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
fun loadGatewayPassword(): String? {
|
fun loadGatewayPassword(): String? {
|
||||||
val key = "gateway.password.${_instanceId.value}"
|
val key = "gateway.password.${_instanceId.value}"
|
||||||
val stored = securePrefs.getString(key, null)?.trim()
|
val stored = securePrefs.getString(key, null)?.trim()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import ai.openclaw.app.SecurePrefs
|
||||||
interface DeviceAuthTokenStore {
|
interface DeviceAuthTokenStore {
|
||||||
fun loadToken(deviceId: String, role: String): String?
|
fun loadToken(deviceId: String, role: String): String?
|
||||||
fun saveToken(deviceId: String, role: String, token: String)
|
fun saveToken(deviceId: String, role: String, token: String)
|
||||||
|
fun clearToken(deviceId: String, role: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||||
|
|
@ -18,7 +19,7 @@ class DeviceAuthStore(private val prefs: SecurePrefs) : DeviceAuthTokenStore {
|
||||||
prefs.putString(key, token.trim())
|
prefs.putString(key, token.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearToken(deviceId: String, role: String) {
|
override fun clearToken(deviceId: String, role: String) {
|
||||||
val key = tokenKey(deviceId, role)
|
val key = tokenKey(deviceId, role)
|
||||||
prefs.remove(key)
|
prefs.remove(key)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,33 @@ data class GatewayConnectOptions(
|
||||||
val userAgent: String? = null,
|
val userAgent: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private enum class GatewayConnectAuthSource {
|
||||||
|
DEVICE_TOKEN,
|
||||||
|
SHARED_TOKEN,
|
||||||
|
BOOTSTRAP_TOKEN,
|
||||||
|
PASSWORD,
|
||||||
|
NONE,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class GatewayConnectErrorDetails(
|
||||||
|
val code: String?,
|
||||||
|
val canRetryWithDeviceToken: Boolean,
|
||||||
|
val recommendedNextStep: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class SelectedConnectAuth(
|
||||||
|
val authToken: String?,
|
||||||
|
val authBootstrapToken: String?,
|
||||||
|
val authDeviceToken: String?,
|
||||||
|
val authPassword: String?,
|
||||||
|
val signatureToken: String?,
|
||||||
|
val authSource: GatewayConnectAuthSource,
|
||||||
|
val attemptedDeviceTokenRetry: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class GatewayConnectFailure(val gatewayError: GatewaySession.ErrorShape) :
|
||||||
|
IllegalStateException(gatewayError.message)
|
||||||
|
|
||||||
class GatewaySession(
|
class GatewaySession(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val identityStore: DeviceIdentityStore,
|
private val identityStore: DeviceIdentityStore,
|
||||||
|
|
@ -83,7 +110,11 @@ class GatewaySession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class ErrorShape(val code: String, val message: String)
|
data class ErrorShape(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val details: GatewayConnectErrorDetails? = null,
|
||||||
|
)
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val writeLock = Mutex()
|
private val writeLock = Mutex()
|
||||||
|
|
@ -95,6 +126,7 @@ class GatewaySession(
|
||||||
private data class DesiredConnection(
|
private data class DesiredConnection(
|
||||||
val endpoint: GatewayEndpoint,
|
val endpoint: GatewayEndpoint,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
|
val bootstrapToken: String?,
|
||||||
val password: String?,
|
val password: String?,
|
||||||
val options: GatewayConnectOptions,
|
val options: GatewayConnectOptions,
|
||||||
val tls: GatewayTlsParams?,
|
val tls: GatewayTlsParams?,
|
||||||
|
|
@ -103,15 +135,22 @@ class GatewaySession(
|
||||||
private var desired: DesiredConnection? = null
|
private var desired: DesiredConnection? = null
|
||||||
private var job: Job? = null
|
private var job: Job? = null
|
||||||
@Volatile private var currentConnection: Connection? = null
|
@Volatile private var currentConnection: Connection? = null
|
||||||
|
@Volatile private var pendingDeviceTokenRetry = false
|
||||||
|
@Volatile private var deviceTokenRetryBudgetUsed = false
|
||||||
|
@Volatile private var reconnectPausedForAuthFailure = false
|
||||||
|
|
||||||
fun connect(
|
fun connect(
|
||||||
endpoint: GatewayEndpoint,
|
endpoint: GatewayEndpoint,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
options: GatewayConnectOptions,
|
options: GatewayConnectOptions,
|
||||||
tls: GatewayTlsParams? = null,
|
tls: GatewayTlsParams? = null,
|
||||||
) {
|
) {
|
||||||
desired = DesiredConnection(endpoint, token, password, options, tls)
|
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
|
||||||
|
pendingDeviceTokenRetry = false
|
||||||
|
deviceTokenRetryBudgetUsed = false
|
||||||
|
reconnectPausedForAuthFailure = false
|
||||||
if (job == null) {
|
if (job == null) {
|
||||||
job = scope.launch(Dispatchers.IO) { runLoop() }
|
job = scope.launch(Dispatchers.IO) { runLoop() }
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +158,9 @@ class GatewaySession(
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
desired = null
|
desired = null
|
||||||
|
pendingDeviceTokenRetry = false
|
||||||
|
deviceTokenRetryBudgetUsed = false
|
||||||
|
reconnectPausedForAuthFailure = false
|
||||||
currentConnection?.closeQuietly()
|
currentConnection?.closeQuietly()
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
job?.cancelAndJoin()
|
job?.cancelAndJoin()
|
||||||
|
|
@ -130,6 +172,7 @@ class GatewaySession(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reconnect() {
|
fun reconnect() {
|
||||||
|
reconnectPausedForAuthFailure = false
|
||||||
currentConnection?.closeQuietly()
|
currentConnection?.closeQuietly()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,6 +262,7 @@ class GatewaySession(
|
||||||
private inner class Connection(
|
private inner class Connection(
|
||||||
private val endpoint: GatewayEndpoint,
|
private val endpoint: GatewayEndpoint,
|
||||||
private val token: String?,
|
private val token: String?,
|
||||||
|
private val bootstrapToken: String?,
|
||||||
private val password: String?,
|
private val password: String?,
|
||||||
private val options: GatewayConnectOptions,
|
private val options: GatewayConnectOptions,
|
||||||
private val tls: GatewayTlsParams?,
|
private val tls: GatewayTlsParams?,
|
||||||
|
|
@ -344,15 +388,48 @@ class GatewaySession(
|
||||||
|
|
||||||
private suspend fun sendConnect(connectNonce: String) {
|
private suspend fun sendConnect(connectNonce: String) {
|
||||||
val identity = identityStore.loadOrCreate()
|
val identity = identityStore.loadOrCreate()
|
||||||
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)
|
val storedToken = deviceAuthStore.loadToken(identity.deviceId, options.role)?.trim()
|
||||||
val trimmedToken = token?.trim().orEmpty()
|
val selectedAuth =
|
||||||
// QR/setup/manual shared token must take precedence; stale role tokens can survive re-onboarding.
|
selectConnectAuth(
|
||||||
val authToken = if (trimmedToken.isNotBlank()) trimmedToken else storedToken.orEmpty()
|
endpoint = endpoint,
|
||||||
val payload = buildConnectParams(identity, connectNonce, authToken, password?.trim())
|
tls = tls,
|
||||||
|
role = options.role,
|
||||||
|
explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
explicitBootstrapToken = bootstrapToken?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
explicitPassword = password?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
storedToken = storedToken?.takeIf { it.isNotEmpty() },
|
||||||
|
)
|
||||||
|
if (selectedAuth.attemptedDeviceTokenRetry) {
|
||||||
|
pendingDeviceTokenRetry = false
|
||||||
|
}
|
||||||
|
val payload =
|
||||||
|
buildConnectParams(
|
||||||
|
identity = identity,
|
||||||
|
connectNonce = connectNonce,
|
||||||
|
selectedAuth = selectedAuth,
|
||||||
|
)
|
||||||
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
|
val res = request("connect", payload, timeoutMs = CONNECT_RPC_TIMEOUT_MS)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
val msg = res.error?.message ?: "connect failed"
|
val error = res.error ?: ErrorShape("UNAVAILABLE", "connect failed")
|
||||||
throw IllegalStateException(msg)
|
val shouldRetryWithDeviceToken =
|
||||||
|
shouldRetryWithStoredDeviceToken(
|
||||||
|
error = error,
|
||||||
|
explicitGatewayToken = token?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
storedToken = storedToken?.takeIf { it.isNotEmpty() },
|
||||||
|
attemptedDeviceTokenRetry = selectedAuth.attemptedDeviceTokenRetry,
|
||||||
|
endpoint = endpoint,
|
||||||
|
tls = tls,
|
||||||
|
)
|
||||||
|
if (shouldRetryWithDeviceToken) {
|
||||||
|
pendingDeviceTokenRetry = true
|
||||||
|
deviceTokenRetryBudgetUsed = true
|
||||||
|
} else if (
|
||||||
|
selectedAuth.attemptedDeviceTokenRetry &&
|
||||||
|
shouldClearStoredDeviceTokenAfterRetry(error)
|
||||||
|
) {
|
||||||
|
deviceAuthStore.clearToken(identity.deviceId, options.role)
|
||||||
|
}
|
||||||
|
throw GatewayConnectFailure(error)
|
||||||
}
|
}
|
||||||
handleConnectSuccess(res, identity.deviceId)
|
handleConnectSuccess(res, identity.deviceId)
|
||||||
connectDeferred.complete(Unit)
|
connectDeferred.complete(Unit)
|
||||||
|
|
@ -361,6 +438,9 @@ class GatewaySession(
|
||||||
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
|
private fun handleConnectSuccess(res: RpcResponse, deviceId: String) {
|
||||||
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
val payloadJson = res.payloadJson ?: throw IllegalStateException("connect failed: missing payload")
|
||||||
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
val obj = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: throw IllegalStateException("connect failed")
|
||||||
|
pendingDeviceTokenRetry = false
|
||||||
|
deviceTokenRetryBudgetUsed = false
|
||||||
|
reconnectPausedForAuthFailure = false
|
||||||
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
|
||||||
val authObj = obj["auth"].asObjectOrNull()
|
val authObj = obj["auth"].asObjectOrNull()
|
||||||
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
|
||||||
|
|
@ -380,8 +460,7 @@ class GatewaySession(
|
||||||
private fun buildConnectParams(
|
private fun buildConnectParams(
|
||||||
identity: DeviceIdentity,
|
identity: DeviceIdentity,
|
||||||
connectNonce: String,
|
connectNonce: String,
|
||||||
authToken: String,
|
selectedAuth: SelectedConnectAuth,
|
||||||
authPassword: String?,
|
|
||||||
): JsonObject {
|
): JsonObject {
|
||||||
val client = options.client
|
val client = options.client
|
||||||
val locale = Locale.getDefault().toLanguageTag()
|
val locale = Locale.getDefault().toLanguageTag()
|
||||||
|
|
@ -397,16 +476,20 @@ class GatewaySession(
|
||||||
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
client.modelIdentifier?.let { put("modelIdentifier", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val password = authPassword?.trim().orEmpty()
|
|
||||||
val authJson =
|
val authJson =
|
||||||
when {
|
when {
|
||||||
authToken.isNotEmpty() ->
|
selectedAuth.authToken != null ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("token", JsonPrimitive(authToken))
|
put("token", JsonPrimitive(selectedAuth.authToken))
|
||||||
|
selectedAuth.authDeviceToken?.let { put("deviceToken", JsonPrimitive(it)) }
|
||||||
}
|
}
|
||||||
password.isNotEmpty() ->
|
selectedAuth.authBootstrapToken != null ->
|
||||||
buildJsonObject {
|
buildJsonObject {
|
||||||
put("password", JsonPrimitive(password))
|
put("bootstrapToken", JsonPrimitive(selectedAuth.authBootstrapToken))
|
||||||
|
}
|
||||||
|
selectedAuth.authPassword != null ->
|
||||||
|
buildJsonObject {
|
||||||
|
put("password", JsonPrimitive(selectedAuth.authPassword))
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
@ -420,7 +503,7 @@ class GatewaySession(
|
||||||
role = options.role,
|
role = options.role,
|
||||||
scopes = options.scopes,
|
scopes = options.scopes,
|
||||||
signedAtMs = signedAtMs,
|
signedAtMs = signedAtMs,
|
||||||
token = if (authToken.isNotEmpty()) authToken else null,
|
token = selectedAuth.signatureToken,
|
||||||
nonce = connectNonce,
|
nonce = connectNonce,
|
||||||
platform = client.platform,
|
platform = client.platform,
|
||||||
deviceFamily = client.deviceFamily,
|
deviceFamily = client.deviceFamily,
|
||||||
|
|
@ -483,7 +566,16 @@ class GatewaySession(
|
||||||
frame["error"]?.asObjectOrNull()?.let { obj ->
|
frame["error"]?.asObjectOrNull()?.let { obj ->
|
||||||
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
val code = obj["code"].asStringOrNull() ?: "UNAVAILABLE"
|
||||||
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
val msg = obj["message"].asStringOrNull() ?: "request failed"
|
||||||
ErrorShape(code, msg)
|
val detailObj = obj["details"].asObjectOrNull()
|
||||||
|
val details =
|
||||||
|
detailObj?.let {
|
||||||
|
GatewayConnectErrorDetails(
|
||||||
|
code = it["code"].asStringOrNull(),
|
||||||
|
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
|
||||||
|
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ErrorShape(code, msg, details)
|
||||||
}
|
}
|
||||||
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
pending.remove(id)?.complete(RpcResponse(id, ok, payloadJson, error))
|
||||||
}
|
}
|
||||||
|
|
@ -607,6 +699,10 @@ class GatewaySession(
|
||||||
delay(250)
|
delay(250)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (reconnectPausedForAuthFailure) {
|
||||||
|
delay(250)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
onDisconnected(if (attempt == 0) "Connecting…" else "Reconnecting…")
|
||||||
|
|
@ -615,6 +711,13 @@ class GatewaySession(
|
||||||
} catch (err: Throwable) {
|
} catch (err: Throwable) {
|
||||||
attempt += 1
|
attempt += 1
|
||||||
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
onDisconnected("Gateway error: ${err.message ?: err::class.java.simpleName}")
|
||||||
|
if (
|
||||||
|
err is GatewayConnectFailure &&
|
||||||
|
shouldPauseReconnectAfterAuthFailure(err.gatewayError)
|
||||||
|
) {
|
||||||
|
reconnectPausedForAuthFailure = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
val sleepMs = minOf(8_000L, (350.0 * Math.pow(1.7, attempt.toDouble())).toLong())
|
||||||
delay(sleepMs)
|
delay(sleepMs)
|
||||||
}
|
}
|
||||||
|
|
@ -622,7 +725,15 @@ class GatewaySession(
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
private suspend fun connectOnce(target: DesiredConnection) = withContext(Dispatchers.IO) {
|
||||||
val conn = Connection(target.endpoint, target.token, target.password, target.options, target.tls)
|
val conn =
|
||||||
|
Connection(
|
||||||
|
target.endpoint,
|
||||||
|
target.token,
|
||||||
|
target.bootstrapToken,
|
||||||
|
target.password,
|
||||||
|
target.options,
|
||||||
|
target.tls,
|
||||||
|
)
|
||||||
currentConnection = conn
|
currentConnection = conn
|
||||||
try {
|
try {
|
||||||
conn.connect()
|
conn.connect()
|
||||||
|
|
@ -698,6 +809,100 @@ class GatewaySession(
|
||||||
if (host == "0.0.0.0" || host == "::") return true
|
if (host == "0.0.0.0" || host == "::") return true
|
||||||
return host.startsWith("127.")
|
return host.startsWith("127.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun selectConnectAuth(
|
||||||
|
endpoint: GatewayEndpoint,
|
||||||
|
tls: GatewayTlsParams?,
|
||||||
|
role: String,
|
||||||
|
explicitGatewayToken: String?,
|
||||||
|
explicitBootstrapToken: String?,
|
||||||
|
explicitPassword: String?,
|
||||||
|
storedToken: String?,
|
||||||
|
): SelectedConnectAuth {
|
||||||
|
val shouldUseDeviceRetryToken =
|
||||||
|
pendingDeviceTokenRetry &&
|
||||||
|
explicitGatewayToken != null &&
|
||||||
|
storedToken != null &&
|
||||||
|
isTrustedDeviceRetryEndpoint(endpoint, tls)
|
||||||
|
val authToken =
|
||||||
|
explicitGatewayToken
|
||||||
|
?: if (
|
||||||
|
explicitPassword == null &&
|
||||||
|
(explicitBootstrapToken == null || storedToken != null)
|
||||||
|
) {
|
||||||
|
storedToken
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val authDeviceToken = if (shouldUseDeviceRetryToken) storedToken else null
|
||||||
|
val authBootstrapToken = if (authToken == null) explicitBootstrapToken else null
|
||||||
|
val authSource =
|
||||||
|
when {
|
||||||
|
authDeviceToken != null || (explicitGatewayToken == null && authToken != null) ->
|
||||||
|
GatewayConnectAuthSource.DEVICE_TOKEN
|
||||||
|
authToken != null -> GatewayConnectAuthSource.SHARED_TOKEN
|
||||||
|
authBootstrapToken != null -> GatewayConnectAuthSource.BOOTSTRAP_TOKEN
|
||||||
|
explicitPassword != null -> GatewayConnectAuthSource.PASSWORD
|
||||||
|
else -> GatewayConnectAuthSource.NONE
|
||||||
|
}
|
||||||
|
return SelectedConnectAuth(
|
||||||
|
authToken = authToken,
|
||||||
|
authBootstrapToken = authBootstrapToken,
|
||||||
|
authDeviceToken = authDeviceToken,
|
||||||
|
authPassword = explicitPassword,
|
||||||
|
signatureToken = authToken ?: authBootstrapToken,
|
||||||
|
authSource = authSource,
|
||||||
|
attemptedDeviceTokenRetry = shouldUseDeviceRetryToken,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldRetryWithStoredDeviceToken(
|
||||||
|
error: ErrorShape,
|
||||||
|
explicitGatewayToken: String?,
|
||||||
|
storedToken: String?,
|
||||||
|
attemptedDeviceTokenRetry: Boolean,
|
||||||
|
endpoint: GatewayEndpoint,
|
||||||
|
tls: GatewayTlsParams?,
|
||||||
|
): Boolean {
|
||||||
|
if (deviceTokenRetryBudgetUsed) return false
|
||||||
|
if (attemptedDeviceTokenRetry) return false
|
||||||
|
if (explicitGatewayToken == null || storedToken == null) return false
|
||||||
|
if (!isTrustedDeviceRetryEndpoint(endpoint, tls)) return false
|
||||||
|
val detailCode = error.details?.code
|
||||||
|
val recommendedNextStep = error.details?.recommendedNextStep
|
||||||
|
return error.details?.canRetryWithDeviceToken == true ||
|
||||||
|
recommendedNextStep == "retry_with_device_token" ||
|
||||||
|
detailCode == "AUTH_TOKEN_MISMATCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
|
||||||
|
return when (error.details?.code) {
|
||||||
|
"AUTH_TOKEN_MISSING",
|
||||||
|
"AUTH_BOOTSTRAP_TOKEN_INVALID",
|
||||||
|
"AUTH_PASSWORD_MISSING",
|
||||||
|
"AUTH_PASSWORD_MISMATCH",
|
||||||
|
"AUTH_RATE_LIMITED",
|
||||||
|
"PAIRING_REQUIRED",
|
||||||
|
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
|
||||||
|
"DEVICE_IDENTITY_REQUIRED" -> true
|
||||||
|
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean {
|
||||||
|
return error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTrustedDeviceRetryEndpoint(
|
||||||
|
endpoint: GatewayEndpoint,
|
||||||
|
tls: GatewayTlsParams?,
|
||||||
|
): Boolean {
|
||||||
|
if (isLoopbackHost(endpoint.host)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return tls?.expectedFingerprint?.trim()?.isNotEmpty() == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
|
||||||
|
|
@ -200,8 +200,11 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||||
viewModel.setManualHost(config.host)
|
viewModel.setManualHost(config.host)
|
||||||
viewModel.setManualPort(config.port)
|
viewModel.setManualPort(config.port)
|
||||||
viewModel.setManualTls(config.tls)
|
viewModel.setManualTls(config.tls)
|
||||||
|
viewModel.setGatewayBootstrapToken(config.bootstrapToken)
|
||||||
if (config.token.isNotBlank()) {
|
if (config.token.isNotBlank()) {
|
||||||
viewModel.setGatewayToken(config.token)
|
viewModel.setGatewayToken(config.token)
|
||||||
|
} else if (config.bootstrapToken.isNotBlank()) {
|
||||||
|
viewModel.setGatewayToken("")
|
||||||
}
|
}
|
||||||
viewModel.setGatewayPassword(config.password)
|
viewModel.setGatewayPassword(config.password)
|
||||||
viewModel.connectManual()
|
viewModel.connectManual()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
package ai.openclaw.app.ui
|
package ai.openclaw.app.ui
|
||||||
|
|
||||||
import androidx.core.net.toUri
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.net.URI
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
@ -18,6 +18,7 @@ internal data class GatewayEndpointConfig(
|
||||||
|
|
||||||
internal data class GatewaySetupCode(
|
internal data class GatewaySetupCode(
|
||||||
val url: String,
|
val url: String,
|
||||||
|
val bootstrapToken: String?,
|
||||||
val token: String?,
|
val token: String?,
|
||||||
val password: String?,
|
val password: String?,
|
||||||
)
|
)
|
||||||
|
|
@ -26,6 +27,7 @@ internal data class GatewayConnectConfig(
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val tls: Boolean,
|
val tls: Boolean,
|
||||||
|
val bootstrapToken: String,
|
||||||
val token: String,
|
val token: String,
|
||||||
val password: String,
|
val password: String,
|
||||||
)
|
)
|
||||||
|
|
@ -44,12 +46,26 @@ internal fun resolveGatewayConnectConfig(
|
||||||
if (useSetupCode) {
|
if (useSetupCode) {
|
||||||
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
val setup = decodeGatewaySetupCode(setupCode) ?: return null
|
||||||
val parsed = parseGatewayEndpoint(setup.url) ?: return null
|
val parsed = parseGatewayEndpoint(setup.url) ?: return null
|
||||||
|
val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty()
|
||||||
|
val sharedToken =
|
||||||
|
when {
|
||||||
|
!setup.token.isNullOrBlank() -> setup.token.trim()
|
||||||
|
setupBootstrapToken.isNotEmpty() -> ""
|
||||||
|
else -> fallbackToken.trim()
|
||||||
|
}
|
||||||
|
val sharedPassword =
|
||||||
|
when {
|
||||||
|
!setup.password.isNullOrBlank() -> setup.password.trim()
|
||||||
|
setupBootstrapToken.isNotEmpty() -> ""
|
||||||
|
else -> fallbackPassword.trim()
|
||||||
|
}
|
||||||
return GatewayConnectConfig(
|
return GatewayConnectConfig(
|
||||||
host = parsed.host,
|
host = parsed.host,
|
||||||
port = parsed.port,
|
port = parsed.port,
|
||||||
tls = parsed.tls,
|
tls = parsed.tls,
|
||||||
token = setup.token ?: fallbackToken.trim(),
|
bootstrapToken = setupBootstrapToken,
|
||||||
password = setup.password ?: fallbackPassword.trim(),
|
token = sharedToken,
|
||||||
|
password = sharedPassword,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +75,7 @@ internal fun resolveGatewayConnectConfig(
|
||||||
host = parsed.host,
|
host = parsed.host,
|
||||||
port = parsed.port,
|
port = parsed.port,
|
||||||
tls = parsed.tls,
|
tls = parsed.tls,
|
||||||
|
bootstrapToken = "",
|
||||||
token = fallbackToken.trim(),
|
token = fallbackToken.trim(),
|
||||||
password = fallbackPassword.trim(),
|
password = fallbackPassword.trim(),
|
||||||
)
|
)
|
||||||
|
|
@ -69,7 +86,7 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||||
if (raw.isEmpty()) return null
|
if (raw.isEmpty()) return null
|
||||||
|
|
||||||
val normalized = if (raw.contains("://")) raw else "https://$raw"
|
val normalized = if (raw.contains("://")) raw else "https://$raw"
|
||||||
val uri = normalized.toUri()
|
val uri = runCatching { URI(normalized) }.getOrNull() ?: return null
|
||||||
val host = uri.host?.trim().orEmpty()
|
val host = uri.host?.trim().orEmpty()
|
||||||
if (host.isEmpty()) return null
|
if (host.isEmpty()) return null
|
||||||
|
|
||||||
|
|
@ -104,9 +121,10 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
|
||||||
val obj = parseJsonObject(decoded) ?: return null
|
val obj = parseJsonObject(decoded) ?: return null
|
||||||
val url = jsonField(obj, "url").orEmpty()
|
val url = jsonField(obj, "url").orEmpty()
|
||||||
if (url.isEmpty()) return null
|
if (url.isEmpty()) return null
|
||||||
|
val bootstrapToken = jsonField(obj, "bootstrapToken")
|
||||||
val token = jsonField(obj, "token")
|
val token = jsonField(obj, "token")
|
||||||
val password = jsonField(obj, "password")
|
val password = jsonField(obj, "password")
|
||||||
GatewaySetupCode(url = url, token = token, password = password)
|
GatewaySetupCode(url = url, bootstrapToken = bootstrapToken, token = token, password = password)
|
||||||
} catch (_: IllegalArgumentException) {
|
} catch (_: IllegalArgumentException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -772,8 +772,18 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
gatewayUrl = parsedSetup.url
|
gatewayUrl = parsedSetup.url
|
||||||
parsedSetup.token?.let { viewModel.setGatewayToken(it) }
|
viewModel.setGatewayBootstrapToken(parsedSetup.bootstrapToken.orEmpty())
|
||||||
gatewayPassword = parsedSetup.password.orEmpty()
|
val sharedToken = parsedSetup.token.orEmpty().trim()
|
||||||
|
val password = parsedSetup.password.orEmpty().trim()
|
||||||
|
if (sharedToken.isNotEmpty()) {
|
||||||
|
viewModel.setGatewayToken(sharedToken)
|
||||||
|
} else if (!parsedSetup.bootstrapToken.isNullOrBlank()) {
|
||||||
|
viewModel.setGatewayToken("")
|
||||||
|
}
|
||||||
|
gatewayPassword = password
|
||||||
|
if (password.isEmpty() && !parsedSetup.bootstrapToken.isNullOrBlank()) {
|
||||||
|
viewModel.setGatewayPassword("")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
val manualUrl = composeGatewayManualUrl(manualHost, manualPort, manualTls)
|
||||||
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
val parsedGateway = manualUrl?.let(::parseGatewayEndpoint)
|
||||||
|
|
@ -782,6 +792,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
return@Button
|
return@Button
|
||||||
}
|
}
|
||||||
gatewayUrl = parsedGateway.displayUrl
|
gatewayUrl = parsedGateway.displayUrl
|
||||||
|
viewModel.setGatewayBootstrapToken("")
|
||||||
}
|
}
|
||||||
step = OnboardingStep.Permissions
|
step = OnboardingStep.Permissions
|
||||||
},
|
},
|
||||||
|
|
@ -850,8 +861,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||||
viewModel.setManualHost(parsed.host)
|
viewModel.setManualHost(parsed.host)
|
||||||
viewModel.setManualPort(parsed.port)
|
viewModel.setManualPort(parsed.port)
|
||||||
viewModel.setManualTls(parsed.tls)
|
viewModel.setManualTls(parsed.tls)
|
||||||
|
if (gatewayInputMode == GatewayInputMode.Manual) {
|
||||||
|
viewModel.setGatewayBootstrapToken("")
|
||||||
|
}
|
||||||
if (token.isNotEmpty()) {
|
if (token.isNotEmpty()) {
|
||||||
viewModel.setGatewayToken(token)
|
viewModel.setGatewayToken(token)
|
||||||
|
} else {
|
||||||
|
viewModel.setGatewayToken("")
|
||||||
}
|
}
|
||||||
viewModel.setGatewayPassword(password)
|
viewModel.setGatewayPassword(password)
|
||||||
viewModel.connectManual()
|
viewModel.connectManual()
|
||||||
|
|
|
||||||
|
|
@ -20,4 +20,19 @@ class SecurePrefsTest {
|
||||||
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
|
assertEquals(LocationMode.WhileUsing, prefs.locationMode.value)
|
||||||
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
|
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||||
|
val context = RuntimeEnvironment.getApplication()
|
||||||
|
val securePrefs = context.getSharedPreferences("openclaw.node.secure.test", Context.MODE_PRIVATE)
|
||||||
|
securePrefs.edit().clear().commit()
|
||||||
|
val prefs = SecurePrefs(context, securePrefsOverride = securePrefs)
|
||||||
|
|
||||||
|
prefs.setGatewayToken("shared-token")
|
||||||
|
prefs.setGatewayBootstrapToken("bootstrap-token")
|
||||||
|
|
||||||
|
assertEquals("shared-token", prefs.loadGatewayToken())
|
||||||
|
assertEquals("bootstrap-token", prefs.loadGatewayBootstrapToken())
|
||||||
|
assertEquals("bootstrap-token", prefs.gatewayBootstrapToken.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
import org.robolectric.RuntimeEnvironment
|
import org.robolectric.RuntimeEnvironment
|
||||||
import org.robolectric.annotation.Config
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
private const val TEST_TIMEOUT_MS = 8_000L
|
private const val TEST_TIMEOUT_MS = 8_000L
|
||||||
|
|
@ -41,11 +42,16 @@ private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||||
override fun saveToken(deviceId: String, role: String, token: String) {
|
override fun saveToken(deviceId: String, role: String, token: String) {
|
||||||
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
tokens["${deviceId.trim()}|${role.trim()}"] = token.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearToken(deviceId: String, role: String) {
|
||||||
|
tokens.remove("${deviceId.trim()}|${role.trim()}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class NodeHarness(
|
private data class NodeHarness(
|
||||||
val session: GatewaySession,
|
val session: GatewaySession,
|
||||||
val sessionJob: Job,
|
val sessionJob: Job,
|
||||||
|
val deviceAuthStore: InMemoryDeviceAuthStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class InvokeScenarioResult(
|
private data class InvokeScenarioResult(
|
||||||
|
|
@ -56,6 +62,157 @@ private data class InvokeScenarioResult(
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
@Config(sdk = [34])
|
@Config(sdk = [34])
|
||||||
class GatewaySessionInvokeTest {
|
class GatewaySessionInvokeTest {
|
||||||
|
@Test
|
||||||
|
fun connect_usesBootstrapTokenWhenSharedAndDeviceTokensAreAbsent() = runBlocking {
|
||||||
|
val json = testJson()
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val connectAuth = CompletableDeferred<JsonObject?>()
|
||||||
|
val lastDisconnect = AtomicReference("")
|
||||||
|
val server =
|
||||||
|
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||||
|
when (method) {
|
||||||
|
"connect" -> {
|
||||||
|
if (!connectAuth.isCompleted) {
|
||||||
|
connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
|
||||||
|
}
|
||||||
|
webSocket.send(connectResponseFrame(id))
|
||||||
|
webSocket.close(1000, "done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val harness =
|
||||||
|
createNodeHarness(
|
||||||
|
connected = connected,
|
||||||
|
lastDisconnect = lastDisconnect,
|
||||||
|
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||||
|
|
||||||
|
try {
|
||||||
|
connectNodeSession(
|
||||||
|
session = harness.session,
|
||||||
|
port = server.port,
|
||||||
|
token = null,
|
||||||
|
bootstrapToken = "bootstrap-token",
|
||||||
|
)
|
||||||
|
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||||
|
|
||||||
|
val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
|
||||||
|
assertEquals("bootstrap-token", auth?.get("bootstrapToken")?.jsonPrimitive?.content)
|
||||||
|
assertNull(auth?.get("token"))
|
||||||
|
} finally {
|
||||||
|
shutdownHarness(harness, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connect_prefersStoredDeviceTokenOverBootstrapToken() = runBlocking {
|
||||||
|
val json = testJson()
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val connectAuth = CompletableDeferred<JsonObject?>()
|
||||||
|
val lastDisconnect = AtomicReference("")
|
||||||
|
val server =
|
||||||
|
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||||
|
when (method) {
|
||||||
|
"connect" -> {
|
||||||
|
if (!connectAuth.isCompleted) {
|
||||||
|
connectAuth.complete(frame["params"]?.jsonObject?.get("auth")?.jsonObject)
|
||||||
|
}
|
||||||
|
webSocket.send(connectResponseFrame(id))
|
||||||
|
webSocket.close(1000, "done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val harness =
|
||||||
|
createNodeHarness(
|
||||||
|
connected = connected,
|
||||||
|
lastDisconnect = lastDisconnect,
|
||||||
|
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
|
||||||
|
harness.deviceAuthStore.saveToken(deviceId, "node", "device-token")
|
||||||
|
|
||||||
|
connectNodeSession(
|
||||||
|
session = harness.session,
|
||||||
|
port = server.port,
|
||||||
|
token = null,
|
||||||
|
bootstrapToken = "bootstrap-token",
|
||||||
|
)
|
||||||
|
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||||
|
|
||||||
|
val auth = withTimeout(TEST_TIMEOUT_MS) { connectAuth.await() }
|
||||||
|
assertEquals("device-token", auth?.get("token")?.jsonPrimitive?.content)
|
||||||
|
assertNull(auth?.get("bootstrapToken"))
|
||||||
|
} finally {
|
||||||
|
shutdownHarness(harness, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun connect_retriesWithStoredDeviceTokenAfterSharedTokenMismatch() = runBlocking {
|
||||||
|
val json = testJson()
|
||||||
|
val connected = CompletableDeferred<Unit>()
|
||||||
|
val firstConnectAuth = CompletableDeferred<JsonObject?>()
|
||||||
|
val secondConnectAuth = CompletableDeferred<JsonObject?>()
|
||||||
|
val connectAttempts = AtomicInteger(0)
|
||||||
|
val lastDisconnect = AtomicReference("")
|
||||||
|
val server =
|
||||||
|
startGatewayServer(json) { webSocket, id, method, frame ->
|
||||||
|
when (method) {
|
||||||
|
"connect" -> {
|
||||||
|
val auth = frame["params"]?.jsonObject?.get("auth")?.jsonObject
|
||||||
|
when (connectAttempts.incrementAndGet()) {
|
||||||
|
1 -> {
|
||||||
|
if (!firstConnectAuth.isCompleted) {
|
||||||
|
firstConnectAuth.complete(auth)
|
||||||
|
}
|
||||||
|
webSocket.send(
|
||||||
|
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"unauthorized","details":{"code":"AUTH_TOKEN_MISMATCH","canRetryWithDeviceToken":true,"recommendedNextStep":"retry_with_device_token"}}}""",
|
||||||
|
)
|
||||||
|
webSocket.close(1000, "retry")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (!secondConnectAuth.isCompleted) {
|
||||||
|
secondConnectAuth.complete(auth)
|
||||||
|
}
|
||||||
|
webSocket.send(connectResponseFrame(id))
|
||||||
|
webSocket.close(1000, "done")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val harness =
|
||||||
|
createNodeHarness(
|
||||||
|
connected = connected,
|
||||||
|
lastDisconnect = lastDisconnect,
|
||||||
|
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val deviceId = DeviceIdentityStore(RuntimeEnvironment.getApplication()).loadOrCreate().deviceId
|
||||||
|
harness.deviceAuthStore.saveToken(deviceId, "node", "stored-device-token")
|
||||||
|
|
||||||
|
connectNodeSession(
|
||||||
|
session = harness.session,
|
||||||
|
port = server.port,
|
||||||
|
token = "shared-auth-token",
|
||||||
|
bootstrapToken = null,
|
||||||
|
)
|
||||||
|
awaitConnectedOrThrow(connected, lastDisconnect, server)
|
||||||
|
|
||||||
|
val firstAuth = withTimeout(TEST_TIMEOUT_MS) { firstConnectAuth.await() }
|
||||||
|
val secondAuth = withTimeout(TEST_TIMEOUT_MS) { secondConnectAuth.await() }
|
||||||
|
assertEquals("shared-auth-token", firstAuth?.get("token")?.jsonPrimitive?.content)
|
||||||
|
assertNull(firstAuth?.get("deviceToken"))
|
||||||
|
assertEquals("shared-auth-token", secondAuth?.get("token")?.jsonPrimitive?.content)
|
||||||
|
assertEquals("stored-device-token", secondAuth?.get("deviceToken")?.jsonPrimitive?.content)
|
||||||
|
} finally {
|
||||||
|
shutdownHarness(harness, server)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
fun nodeInvokeRequest_roundTripsInvokeResult() = runBlocking {
|
||||||
val handshakeOrigin = AtomicReference<String?>(null)
|
val handshakeOrigin = AtomicReference<String?>(null)
|
||||||
|
|
@ -182,11 +339,12 @@ class GatewaySessionInvokeTest {
|
||||||
): NodeHarness {
|
): NodeHarness {
|
||||||
val app = RuntimeEnvironment.getApplication()
|
val app = RuntimeEnvironment.getApplication()
|
||||||
val sessionJob = SupervisorJob()
|
val sessionJob = SupervisorJob()
|
||||||
|
val deviceAuthStore = InMemoryDeviceAuthStore()
|
||||||
val session =
|
val session =
|
||||||
GatewaySession(
|
GatewaySession(
|
||||||
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
scope = CoroutineScope(sessionJob + Dispatchers.Default),
|
||||||
identityStore = DeviceIdentityStore(app),
|
identityStore = DeviceIdentityStore(app),
|
||||||
deviceAuthStore = InMemoryDeviceAuthStore(),
|
deviceAuthStore = deviceAuthStore,
|
||||||
onConnected = { _, _, _ ->
|
onConnected = { _, _, _ ->
|
||||||
if (!connected.isCompleted) connected.complete(Unit)
|
if (!connected.isCompleted) connected.complete(Unit)
|
||||||
},
|
},
|
||||||
|
|
@ -197,10 +355,15 @@ class GatewaySessionInvokeTest {
|
||||||
onInvoke = onInvoke,
|
onInvoke = onInvoke,
|
||||||
)
|
)
|
||||||
|
|
||||||
return NodeHarness(session = session, sessionJob = sessionJob)
|
return NodeHarness(session = session, sessionJob = sessionJob, deviceAuthStore = deviceAuthStore)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun connectNodeSession(session: GatewaySession, port: Int) {
|
private suspend fun connectNodeSession(
|
||||||
|
session: GatewaySession,
|
||||||
|
port: Int,
|
||||||
|
token: String? = "test-token",
|
||||||
|
bootstrapToken: String? = null,
|
||||||
|
) {
|
||||||
session.connect(
|
session.connect(
|
||||||
endpoint =
|
endpoint =
|
||||||
GatewayEndpoint(
|
GatewayEndpoint(
|
||||||
|
|
@ -210,7 +373,8 @@ class GatewaySessionInvokeTest {
|
||||||
port = port,
|
port = port,
|
||||||
tlsEnabled = false,
|
tlsEnabled = false,
|
||||||
),
|
),
|
||||||
token = "test-token",
|
token = token,
|
||||||
|
bootstrapToken = bootstrapToken,
|
||||||
password = null,
|
password = null,
|
||||||
options =
|
options =
|
||||||
GatewayConnectOptions(
|
GatewayConnectOptions(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import org.junit.Test
|
||||||
class GatewayConfigResolverTest {
|
class GatewayConfigResolverTest {
|
||||||
@Test
|
@Test
|
||||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||||
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","token":"token-1"}""")
|
val setupCode =
|
||||||
|
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||||
|
|
||||||
val resolved = resolveScannedSetupCode(setupCode)
|
val resolved = resolveScannedSetupCode(setupCode)
|
||||||
|
|
||||||
|
|
@ -17,7 +18,8 @@ class GatewayConfigResolverTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
|
||||||
val setupCode = encodeSetupCode("""{"url":"wss://gateway.example:18789","password":"pw-1"}""")
|
val setupCode =
|
||||||
|
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||||
val qrJson =
|
val qrJson =
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
|
|
@ -53,6 +55,43 @@ class GatewayConfigResolverTest {
|
||||||
assertNull(resolved)
|
assertNull(resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decodeGatewaySetupCodeParsesBootstrapToken() {
|
||||||
|
val setupCode =
|
||||||
|
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||||
|
|
||||||
|
val decoded = decodeGatewaySetupCode(setupCode)
|
||||||
|
|
||||||
|
assertEquals("wss://gateway.example:18789", decoded?.url)
|
||||||
|
assertEquals("bootstrap-1", decoded?.bootstrapToken)
|
||||||
|
assertNull(decoded?.token)
|
||||||
|
assertNull(decoded?.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() {
|
||||||
|
val setupCode =
|
||||||
|
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
|
||||||
|
|
||||||
|
val resolved =
|
||||||
|
resolveGatewayConnectConfig(
|
||||||
|
useSetupCode = true,
|
||||||
|
setupCode = setupCode,
|
||||||
|
manualHost = "",
|
||||||
|
manualPort = "",
|
||||||
|
manualTls = true,
|
||||||
|
fallbackToken = "shared-token",
|
||||||
|
fallbackPassword = "shared-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("gateway.example", resolved?.host)
|
||||||
|
assertEquals(18789, resolved?.port)
|
||||||
|
assertEquals(true, resolved?.tls)
|
||||||
|
assertEquals("bootstrap-1", resolved?.bootstrapToken)
|
||||||
|
assertNull(resolved?.token?.takeIf { it.isNotEmpty() })
|
||||||
|
assertNull(resolved?.password?.takeIf { it.isNotEmpty() })
|
||||||
|
}
|
||||||
|
|
||||||
private fun encodeSetupCode(payloadJson: String): String {
|
private fun encodeSetupCode(payloadJson: String): String {
|
||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>XPC!</string>
|
<string>XPC!</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ struct OpenClawLiveActivity: Widget {
|
||||||
Spacer()
|
Spacer()
|
||||||
trailingView(state: context.state)
|
trailingView(state: context.state)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// Shared iOS signing defaults for local development + CI.
|
// Shared iOS signing defaults for local development + CI.
|
||||||
|
#include "Version.xcconfig"
|
||||||
|
|
||||||
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
OPENCLAW_IOS_DEFAULT_TEAM = Y5PE65HELJ
|
||||||
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
|
||||||
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
|
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.client
|
||||||
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
|
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.client.watchkitapp
|
||||||
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
|
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.client.watchkitapp.extension
|
||||||
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
|
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.client.activitywidget
|
||||||
|
|
||||||
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
// Local contributors can override this by running scripts/ios-configure-signing.sh.
|
||||||
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
// Shared iOS version defaults.
|
||||||
|
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||||
|
|
||||||
|
OPENCLAW_GATEWAY_VERSION = 0.0.0
|
||||||
|
OPENCLAW_MARKETING_VERSION = 0.0.0
|
||||||
|
OPENCLAW_BUILD_VERSION = 0
|
||||||
|
|
||||||
|
#include? "../build/Version.xcconfig"
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
# OpenClaw iOS (Super Alpha)
|
# OpenClaw iOS (Super Alpha)
|
||||||
|
|
||||||
NO TEST FLIGHT AVAILABLE AT THIS POINT
|
|
||||||
|
|
||||||
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
|
This iPhone app is super-alpha and internal-use only. It connects to an OpenClaw Gateway as a `role: node`.
|
||||||
|
|
||||||
## Distribution Status
|
## Distribution Status
|
||||||
|
|
||||||
NO TEST FLIGHT AVAILABLE AT THIS POINT
|
- Public distribution: not available.
|
||||||
|
- Internal beta distribution: local archive + TestFlight upload via Fastlane.
|
||||||
- Current distribution: local/manual deploy from source via Xcode.
|
- Local/manual deploy from source via Xcode remains the default development path.
|
||||||
- App Store flow is not part of the current internal development path.
|
|
||||||
|
|
||||||
## Super-Alpha Disclaimer
|
## Super-Alpha Disclaimer
|
||||||
|
|
||||||
|
|
@ -50,14 +47,93 @@ Shortcut command (same flow + open project):
|
||||||
pnpm ios:open
|
pnpm ios:open
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Local Beta Release Flow
|
||||||
|
|
||||||
|
Prereqs:
|
||||||
|
|
||||||
|
- Xcode 16+
|
||||||
|
- `pnpm`
|
||||||
|
- `xcodegen`
|
||||||
|
- `fastlane`
|
||||||
|
- Apple account signed into Xcode for automatic signing/provisioning
|
||||||
|
- App Store Connect API key set up in Keychain via `scripts/ios-asc-keychain-setup.sh` when auto-resolving a beta build number or uploading to TestFlight
|
||||||
|
|
||||||
|
Release behavior:
|
||||||
|
|
||||||
|
- Local development keeps using unique per-developer bundle IDs from `scripts/ios-configure-signing.sh`.
|
||||||
|
- Beta release uses canonical `ai.openclaw.client*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/BetaRelease.xcconfig`.
|
||||||
|
- Beta release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, and `OpenClawPushAPNsEnvironment=production`.
|
||||||
|
- The beta flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
|
||||||
|
- Root `package.json.version` is the only version source for iOS.
|
||||||
|
- A root version like `2026.3.13-beta.1` becomes:
|
||||||
|
- `CFBundleShortVersionString = 2026.3.13`
|
||||||
|
- `CFBundleVersion = next TestFlight build number for 2026.3.13`
|
||||||
|
|
||||||
|
Required env for beta builds:
|
||||||
|
|
||||||
|
- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
|
||||||
|
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
|
||||||
|
|
||||||
|
Archive without upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:beta:archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Archive and upload to TestFlight:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:beta
|
||||||
|
```
|
||||||
|
|
||||||
|
If you need to force a specific build number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:beta -- --build-number 7
|
||||||
|
```
|
||||||
|
|
||||||
## APNs Expectations For Local/Manual Builds
|
## APNs Expectations For Local/Manual Builds
|
||||||
|
|
||||||
- The app calls `registerForRemoteNotifications()` at launch.
|
- The app calls `registerForRemoteNotifications()` at launch.
|
||||||
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
- `apps/ios/Sources/OpenClaw.entitlements` sets `aps-environment` to `development`.
|
||||||
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
|
||||||
|
- Local/manual builds default to `OpenClawPushTransport=direct` and `OpenClawPushDistribution=local`.
|
||||||
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
|
||||||
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
|
||||||
- Debug builds register as APNs sandbox; Release builds use production.
|
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
|
||||||
|
|
||||||
|
## APNs Expectations For Official Builds
|
||||||
|
|
||||||
|
- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway.
|
||||||
|
- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, relay origin metadata, and installation metadata instead of the raw APNs token.
|
||||||
|
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
|
||||||
|
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
|
||||||
|
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
|
||||||
|
- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.
|
||||||
|
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
|
||||||
|
|
||||||
|
## Official Build Relay Trust Model
|
||||||
|
|
||||||
|
- `iOS -> gateway`
|
||||||
|
- The app must pair with the gateway and establish both node and operator sessions.
|
||||||
|
- The operator session is used to fetch `gateway.identity.get`.
|
||||||
|
- `iOS -> relay`
|
||||||
|
- The app registers with the relay over HTTPS using App Attest plus the app receipt.
|
||||||
|
- The relay requires the official production/TestFlight distribution path, which is why local
|
||||||
|
Xcode/dev installs cannot use the hosted relay.
|
||||||
|
- `gateway delegation`
|
||||||
|
- The app includes the gateway identity in relay registration.
|
||||||
|
- The relay returns a relay handle and registration-scoped send grant delegated to that gateway.
|
||||||
|
- `gateway -> relay`
|
||||||
|
- The gateway signs relay send requests with its own device identity.
|
||||||
|
- The relay verifies both the delegated send grant and the gateway signature before it sends to
|
||||||
|
APNs.
|
||||||
|
- `relay -> APNs`
|
||||||
|
- Production APNs credentials and raw official-build APNs tokens stay in the relay deployment,
|
||||||
|
not on the gateway.
|
||||||
|
|
||||||
|
This exists to keep the hosted relay limited to genuine OpenClaw official builds and to ensure a
|
||||||
|
gateway can only send pushes for iOS devices that paired with that gateway.
|
||||||
|
|
||||||
## What Works Now (Concrete)
|
## What Works Now (Concrete)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>XPC!</string>
|
<string>XPC!</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
|
// Auto-selected local team overrides live in .local-signing.xcconfig (git-ignored).
|
||||||
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
|
// Manual local overrides can go in LocalSigning.xcconfig (git-ignored).
|
||||||
|
|
||||||
|
#include "Config/Version.xcconfig"
|
||||||
|
|
||||||
OPENCLAW_CODE_SIGN_STYLE = Manual
|
OPENCLAW_CODE_SIGN_STYLE = Manual
|
||||||
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
OPENCLAW_DEVELOPMENT_TEAM = Y5PE65HELJ
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||||
// (chat.subscribe is a node event, not an operator RPC method.)
|
// (chat.subscribe is a node event, not an operator RPC method.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetSession(sessionKey: String) async throws {
|
||||||
|
struct Params: Codable { var key: String }
|
||||||
|
let data = try JSONEncoder().encode(Params(key: sessionKey))
|
||||||
|
let json = String(data: data, encoding: .utf8)
|
||||||
|
_ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10)
|
||||||
|
}
|
||||||
|
|
||||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||||
struct Params: Codable { var sessionKey: String }
|
struct Params: Codable { var sessionKey: String }
|
||||||
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ struct GatewayConnectConfig: Sendable {
|
||||||
let stableID: String
|
let stableID: String
|
||||||
let tls: GatewayTLSParams?
|
let tls: GatewayTLSParams?
|
||||||
let token: String?
|
let token: String?
|
||||||
|
let bootstrapToken: String?
|
||||||
let password: String?
|
let password: String?
|
||||||
let nodeOptions: GatewayConnectOptions
|
let nodeOptions: GatewayConnectOptions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ final class GatewayConnectionController {
|
||||||
return "Missing instanceId (node.instanceId). Try restarting the app."
|
return "Missing instanceId (node.instanceId). Try restarting the app."
|
||||||
}
|
}
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
|
||||||
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
||||||
|
|
@ -151,6 +152,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +165,7 @@ final class GatewayConnectionController {
|
||||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
let resolvedUseTLS = self.resolveManualUseTLS(host: host, useTLS: useTLS)
|
||||||
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
guard let resolvedPort = self.resolveManualPort(host: host, port: port, useTLS: resolvedUseTLS)
|
||||||
|
|
@ -203,6 +206,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,6 +233,7 @@ final class GatewayConnectionController {
|
||||||
stableID: cfg.stableID,
|
stableID: cfg.stableID,
|
||||||
tls: cfg.tls,
|
tls: cfg.tls,
|
||||||
token: cfg.token,
|
token: cfg.token,
|
||||||
|
bootstrapToken: cfg.bootstrapToken,
|
||||||
password: cfg.password,
|
password: cfg.password,
|
||||||
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
nodeOptions: self.makeConnectOptions(stableID: cfg.stableID))
|
||||||
appModel.applyGatewayConnectConfig(refreshedConfig)
|
appModel.applyGatewayConnectConfig(refreshedConfig)
|
||||||
|
|
@ -261,6 +266,7 @@ final class GatewayConnectionController {
|
||||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
let tlsParams = GatewayTLSParams(
|
let tlsParams = GatewayTLSParams(
|
||||||
required: true,
|
required: true,
|
||||||
|
|
@ -274,6 +280,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: pending.stableID,
|
gatewayStableID: pending.stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,6 +326,7 @@ final class GatewayConnectionController {
|
||||||
guard !instanceId.isEmpty else { return }
|
guard !instanceId.isEmpty else { return }
|
||||||
|
|
||||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||||
|
let bootstrapToken = GatewaySettingsStore.loadGatewayBootstrapToken(instanceId: instanceId)
|
||||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||||
|
|
||||||
if manualEnabled {
|
if manualEnabled {
|
||||||
|
|
@ -353,6 +361,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -379,6 +388,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: stableID,
|
gatewayStableID: stableID,
|
||||||
tls: tlsParams,
|
tls: tlsParams,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password)
|
password: password)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +458,7 @@ final class GatewayConnectionController {
|
||||||
gatewayStableID: String,
|
gatewayStableID: String,
|
||||||
tls: GatewayTLSParams?,
|
tls: GatewayTLSParams?,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?)
|
password: String?)
|
||||||
{
|
{
|
||||||
guard let appModel else { return }
|
guard let appModel else { return }
|
||||||
|
|
@ -463,6 +474,7 @@ final class GatewayConnectionController {
|
||||||
stableID: gatewayStableID,
|
stableID: gatewayStableID,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions)
|
nodeOptions: connectOptions)
|
||||||
appModel.applyGatewayConnectConfig(cfg)
|
appModel.applyGatewayConnectConfig(cfg)
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,21 @@ enum GatewaySettingsStore {
|
||||||
account: self.gatewayTokenAccount(instanceId: instanceId))
|
account: self.gatewayTokenAccount(instanceId: instanceId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func loadGatewayBootstrapToken(instanceId: String) -> String? {
|
||||||
|
let account = self.gatewayBootstrapTokenAccount(instanceId: instanceId)
|
||||||
|
let token = KeychainStore.loadString(service: self.gatewayService, account: account)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if token?.isEmpty == false { return token }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveGatewayBootstrapToken(_ token: String, instanceId: String) {
|
||||||
|
_ = KeychainStore.saveString(
|
||||||
|
token,
|
||||||
|
service: self.gatewayService,
|
||||||
|
account: self.gatewayBootstrapTokenAccount(instanceId: instanceId))
|
||||||
|
}
|
||||||
|
|
||||||
static func loadGatewayPassword(instanceId: String) -> String? {
|
static func loadGatewayPassword(instanceId: String) -> String? {
|
||||||
KeychainStore.loadString(
|
KeychainStore.loadString(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
|
|
@ -278,6 +293,9 @@ enum GatewaySettingsStore {
|
||||||
_ = KeychainStore.delete(
|
_ = KeychainStore.delete(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.gatewayTokenAccount(instanceId: trimmed))
|
account: self.gatewayTokenAccount(instanceId: trimmed))
|
||||||
|
_ = KeychainStore.delete(
|
||||||
|
service: self.gatewayService,
|
||||||
|
account: self.gatewayBootstrapTokenAccount(instanceId: trimmed))
|
||||||
_ = KeychainStore.delete(
|
_ = KeychainStore.delete(
|
||||||
service: self.gatewayService,
|
service: self.gatewayService,
|
||||||
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
||||||
|
|
@ -331,6 +349,10 @@ enum GatewaySettingsStore {
|
||||||
"gateway-token.\(instanceId)"
|
"gateway-token.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func gatewayBootstrapTokenAccount(instanceId: String) -> String {
|
||||||
|
"gateway-bootstrap-token.\(instanceId)"
|
||||||
|
}
|
||||||
|
|
||||||
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
private static func gatewayPasswordAccount(instanceId: String) -> String {
|
||||||
"gateway-password.\(instanceId)"
|
"gateway-password.\(instanceId)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ struct GatewaySetupPayload: Codable {
|
||||||
var host: String?
|
var host: String?
|
||||||
var port: Int?
|
var port: Int?
|
||||||
var tls: Bool?
|
var tls: Bool?
|
||||||
|
var bootstrapToken: String?
|
||||||
var token: String?
|
var token: String?
|
||||||
var password: String?
|
var password: String?
|
||||||
}
|
}
|
||||||
|
|
@ -39,4 +40,3 @@ enum GatewaySetupCode {
|
||||||
return String(data: data, encoding: .utf8)
|
return String(data: data, encoding: .utf8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
|
|
@ -66,6 +66,14 @@
|
||||||
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
<string>OpenClaw uses on-device speech recognition for voice wake.</string>
|
||||||
<key>NSSupportsLiveActivities</key>
|
<key>NSSupportsLiveActivities</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>OpenClawPushAPNsEnvironment</key>
|
||||||
|
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
|
||||||
|
<key>OpenClawPushDistribution</key>
|
||||||
|
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
|
||||||
|
<key>OpenClawPushRelayBaseURL</key>
|
||||||
|
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
|
||||||
|
<key>OpenClawPushTransport</key>
|
||||||
|
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@ import UserNotifications
|
||||||
private struct NotificationCallError: Error, Sendable {
|
private struct NotificationCallError: Error, Sendable {
|
||||||
let message: String
|
let message: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct GatewayRelayIdentityResponse: Decodable {
|
||||||
|
let deviceId: String
|
||||||
|
let publicKey: String
|
||||||
|
}
|
||||||
|
|
||||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
|
@ -140,6 +146,7 @@ final class NodeAppModel {
|
||||||
private var shareDeliveryTo: String?
|
private var shareDeliveryTo: String?
|
||||||
private var apnsDeviceTokenHex: String?
|
private var apnsDeviceTokenHex: String?
|
||||||
private var apnsLastRegisteredTokenHex: String?
|
private var apnsLastRegisteredTokenHex: String?
|
||||||
|
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
|
||||||
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
var gatewaySession: GatewayNodeSession { self.nodeGateway }
|
||||||
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
var operatorSession: GatewayNodeSession { self.operatorGateway }
|
||||||
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
private(set) var activeGatewayConnectConfig: GatewayConnectConfig?
|
||||||
|
|
@ -528,13 +535,6 @@ final class NodeAppModel {
|
||||||
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
|
||||||
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
|
||||||
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
|
||||||
private static var apnsEnvironment: String {
|
|
||||||
#if DEBUG
|
|
||||||
"sandbox"
|
|
||||||
#else
|
|
||||||
"production"
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshBrandingFromGateway() async {
|
private func refreshBrandingFromGateway() async {
|
||||||
do {
|
do {
|
||||||
|
|
@ -1189,7 +1189,15 @@ final class NodeAppModel {
|
||||||
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
_ = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
}
|
}
|
||||||
|
|
||||||
return await self.notificationAuthorizationStatus()
|
let updatedStatus = await self.notificationAuthorizationStatus()
|
||||||
|
if Self.isNotificationAuthorizationAllowed(updatedStatus) {
|
||||||
|
// Refresh APNs registration immediately after the first permission grant so the
|
||||||
|
// gateway can receive a push registration without requiring an app relaunch.
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updatedStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
private func notificationAuthorizationStatus() async -> NotificationAuthorizationStatus {
|
||||||
|
|
@ -1204,6 +1212,17 @@ final class NodeAppModel {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func isNotificationAuthorizationAllowed(
|
||||||
|
_ status: NotificationAuthorizationStatus
|
||||||
|
) -> Bool {
|
||||||
|
switch status {
|
||||||
|
case .authorized, .provisional, .ephemeral:
|
||||||
|
true
|
||||||
|
case .denied, .notDetermined:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func runNotificationCall<T: Sendable>(
|
private func runNotificationCall<T: Sendable>(
|
||||||
timeoutSeconds: Double,
|
timeoutSeconds: Double,
|
||||||
operation: @escaping @Sendable () async throws -> T
|
operation: @escaping @Sendable () async throws -> T
|
||||||
|
|
@ -1661,6 +1680,7 @@ extension NodeAppModel {
|
||||||
gatewayStableID: String,
|
gatewayStableID: String,
|
||||||
tls: GatewayTLSParams?,
|
tls: GatewayTLSParams?,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
connectOptions: GatewayConnectOptions)
|
connectOptions: GatewayConnectOptions)
|
||||||
{
|
{
|
||||||
|
|
@ -1673,6 +1693,7 @@ extension NodeAppModel {
|
||||||
stableID: stableID,
|
stableID: stableID,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions)
|
nodeOptions: connectOptions)
|
||||||
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
|
||||||
|
|
@ -1680,6 +1701,7 @@ extension NodeAppModel {
|
||||||
url: url,
|
url: url,
|
||||||
stableID: effectiveStableID,
|
stableID: effectiveStableID,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions,
|
nodeOptions: connectOptions,
|
||||||
sessionBox: sessionBox)
|
sessionBox: sessionBox)
|
||||||
|
|
@ -1687,6 +1709,7 @@ extension NodeAppModel {
|
||||||
url: url,
|
url: url,
|
||||||
stableID: effectiveStableID,
|
stableID: effectiveStableID,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
nodeOptions: connectOptions,
|
nodeOptions: connectOptions,
|
||||||
sessionBox: sessionBox)
|
sessionBox: sessionBox)
|
||||||
|
|
@ -1702,6 +1725,7 @@ extension NodeAppModel {
|
||||||
gatewayStableID: cfg.stableID,
|
gatewayStableID: cfg.stableID,
|
||||||
tls: cfg.tls,
|
tls: cfg.tls,
|
||||||
token: cfg.token,
|
token: cfg.token,
|
||||||
|
bootstrapToken: cfg.bootstrapToken,
|
||||||
password: cfg.password,
|
password: cfg.password,
|
||||||
connectOptions: cfg.nodeOptions)
|
connectOptions: cfg.nodeOptions)
|
||||||
}
|
}
|
||||||
|
|
@ -1782,6 +1806,7 @@ private extension NodeAppModel {
|
||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
nodeOptions: GatewayConnectOptions,
|
nodeOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?)
|
sessionBox: WebSocketSessionBox?)
|
||||||
|
|
@ -1819,6 +1844,7 @@ private extension NodeAppModel {
|
||||||
try await self.operatorGateway.connect(
|
try await self.operatorGateway.connect(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: operatorOptions,
|
connectOptions: operatorOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
|
|
@ -1834,6 +1860,7 @@ private extension NodeAppModel {
|
||||||
await self.refreshBrandingFromGateway()
|
await self.refreshBrandingFromGateway()
|
||||||
await self.refreshAgentsFromGateway()
|
await self.refreshAgentsFromGateway()
|
||||||
await self.refreshShareRouteFromGateway()
|
await self.refreshShareRouteFromGateway()
|
||||||
|
await self.registerAPNsTokenIfNeeded()
|
||||||
await self.startVoiceWakeSync()
|
await self.startVoiceWakeSync()
|
||||||
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
|
await MainActor.run { LiveActivityManager.shared.handleReconnect() }
|
||||||
await MainActor.run { self.startGatewayHealthMonitor() }
|
await MainActor.run { self.startGatewayHealthMonitor() }
|
||||||
|
|
@ -1876,6 +1903,7 @@ private extension NodeAppModel {
|
||||||
url: URL,
|
url: URL,
|
||||||
stableID: String,
|
stableID: String,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
nodeOptions: GatewayConnectOptions,
|
nodeOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?)
|
sessionBox: WebSocketSessionBox?)
|
||||||
|
|
@ -1924,6 +1952,7 @@ private extension NodeAppModel {
|
||||||
try await self.nodeGateway.connect(
|
try await self.nodeGateway.connect(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
connectOptions: currentOptions,
|
connectOptions: currentOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
|
|
@ -2255,8 +2284,7 @@ extension NodeAppModel {
|
||||||
from: payload)
|
from: payload)
|
||||||
guard !decoded.actions.isEmpty else { return }
|
guard !decoded.actions.isEmpty else { return }
|
||||||
self.pendingActionLogger.info(
|
self.pendingActionLogger.info(
|
||||||
"Pending actions pulled trigger=\(trigger, privacy: .public) "
|
"Pending actions pulled trigger=\(trigger, privacy: .public) count=\(decoded.actions.count, privacy: .public)")
|
||||||
+ "count=\(decoded.actions.count, privacy: .public)")
|
|
||||||
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
await self.applyPendingForegroundNodeActions(decoded.actions, trigger: trigger)
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort only.
|
// Best-effort only.
|
||||||
|
|
@ -2279,9 +2307,7 @@ extension NodeAppModel {
|
||||||
paramsJSON: action.paramsJSON)
|
paramsJSON: action.paramsJSON)
|
||||||
let result = await self.handleInvoke(req)
|
let result = await self.handleInvoke(req)
|
||||||
self.pendingActionLogger.info(
|
self.pendingActionLogger.info(
|
||||||
"Pending action replay trigger=\(trigger, privacy: .public) "
|
"Pending action replay trigger=\(trigger, privacy: .public) id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) ok=\(result.ok, privacy: .public)")
|
||||||
+ "id=\(action.id, privacy: .public) command=\(action.command, privacy: .public) "
|
|
||||||
+ "ok=\(result.ok, privacy: .public)")
|
|
||||||
guard result.ok else { return }
|
guard result.ok else { return }
|
||||||
let acked = await self.ackPendingForegroundNodeAction(
|
let acked = await self.ackPendingForegroundNodeAction(
|
||||||
id: action.id,
|
id: action.id,
|
||||||
|
|
@ -2306,9 +2332,7 @@ extension NodeAppModel {
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
self.pendingActionLogger.error(
|
self.pendingActionLogger.error(
|
||||||
"Pending action ack failed trigger=\(trigger, privacy: .public) "
|
"Pending action ack failed trigger=\(trigger, privacy: .public) id=\(id, privacy: .public) command=\(command, privacy: .public) error=\(String(describing: error), privacy: .public)")
|
||||||
+ "id=\(id, privacy: .public) command=\(command, privacy: .public) "
|
|
||||||
+ "error=\(String(describing: error), privacy: .public)")
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2484,7 +2508,8 @@ extension NodeAppModel {
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token == self.apnsLastRegisteredTokenHex {
|
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||||
|
if !usesRelayTransport && token == self.apnsLastRegisteredTokenHex {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
guard let topic = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
|
@ -2493,25 +2518,40 @@ extension NodeAppModel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PushRegistrationPayload: Codable {
|
|
||||||
var token: String
|
|
||||||
var topic: String
|
|
||||||
var environment: String
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = PushRegistrationPayload(
|
|
||||||
token: token,
|
|
||||||
topic: topic,
|
|
||||||
environment: Self.apnsEnvironment)
|
|
||||||
do {
|
do {
|
||||||
let json = try Self.encodePayload(payload)
|
let gatewayIdentity: PushRelayGatewayIdentity?
|
||||||
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: json)
|
if usesRelayTransport {
|
||||||
|
guard self.operatorConnected else { return }
|
||||||
|
gatewayIdentity = try await self.fetchPushRelayGatewayIdentity()
|
||||||
|
} else {
|
||||||
|
gatewayIdentity = nil
|
||||||
|
}
|
||||||
|
let payloadJSON = try await self.pushRegistrationManager.makeGatewayRegistrationPayload(
|
||||||
|
apnsTokenHex: token,
|
||||||
|
topic: topic,
|
||||||
|
gatewayIdentity: gatewayIdentity)
|
||||||
|
await self.nodeGateway.sendEvent(event: "push.apns.register", payloadJSON: payloadJSON)
|
||||||
self.apnsLastRegisteredTokenHex = token
|
self.apnsLastRegisteredTokenHex = token
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort only.
|
self.pushWakeLogger.error(
|
||||||
|
"APNs registration publish failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
|
||||||
|
let response = try await self.operatorGateway.request(
|
||||||
|
method: "gateway.identity.get",
|
||||||
|
paramsJSON: "{}",
|
||||||
|
timeoutSeconds: 8)
|
||||||
|
let decoded = try JSONDecoder().decode(GatewayRelayIdentityResponse.self, from: response)
|
||||||
|
let deviceId = decoded.deviceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let publicKey = decoded.publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !deviceId.isEmpty, !publicKey.isEmpty else {
|
||||||
|
throw PushRelayError.relayMisconfigured("Gateway identity response missing required fields")
|
||||||
|
}
|
||||||
|
return PushRelayGatewayIdentity(deviceId: deviceId, publicKey: publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
|
private static func isSilentPushPayload(_ userInfo: [AnyHashable: Any]) -> Bool {
|
||||||
guard let apsAny = userInfo["aps"] else { return false }
|
guard let apsAny = userInfo["aps"] else { return false }
|
||||||
if let aps = apsAny as? [AnyHashable: Any] {
|
if let aps = apsAny as? [AnyHashable: Any] {
|
||||||
|
|
|
||||||
|
|
@ -275,9 +275,21 @@ private struct ManualEntryStep: View {
|
||||||
|
|
||||||
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.manualToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||||
|
self.manualToken = ""
|
||||||
}
|
}
|
||||||
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.manualPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
} else if payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
|
||||||
|
self.manualPassword = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmedInstanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
let trimmedBootstrapToken =
|
||||||
|
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.setupStatusText = "Setup code applied."
|
self.setupStatusText = "Setup code applied."
|
||||||
|
|
|
||||||
|
|
@ -642,11 +642,17 @@ struct OnboardingWizardView: View {
|
||||||
self.manualHost = link.host
|
self.manualHost = link.host
|
||||||
self.manualPort = link.port
|
self.manualPort = link.port
|
||||||
self.manualTLS = link.tls
|
self.manualTLS = link.tls
|
||||||
if let token = link.token {
|
let trimmedBootstrapToken = link.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.saveGatewayBootstrapToken(trimmedBootstrapToken)
|
||||||
|
if let token = link.token?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
||||||
self.gatewayToken = token
|
self.gatewayToken = token
|
||||||
|
} else if trimmedBootstrapToken?.isEmpty == false {
|
||||||
|
self.gatewayToken = ""
|
||||||
}
|
}
|
||||||
if let password = link.password {
|
if let password = link.password?.trimmingCharacters(in: .whitespacesAndNewlines), !password.isEmpty {
|
||||||
self.gatewayPassword = password
|
self.gatewayPassword = password
|
||||||
|
} else if trimmedBootstrapToken?.isEmpty == false {
|
||||||
|
self.gatewayPassword = ""
|
||||||
}
|
}
|
||||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||||
self.showQRScanner = false
|
self.showQRScanner = false
|
||||||
|
|
@ -794,6 +800,13 @@ struct OnboardingWizardView: View {
|
||||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func saveGatewayBootstrapToken(_ token: String?) {
|
||||||
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedInstanceId.isEmpty else { return }
|
||||||
|
let trimmedToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
|
|
||||||
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||||
self.connectingGatewayID = gateway.id
|
self.connectingGatewayID = gateway.id
|
||||||
self.issue = .none
|
self.issue = .none
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,13 @@ enum WatchPromptNotificationBridge {
|
||||||
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
if !granted { return false }
|
if !granted { return false }
|
||||||
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
let updatedStatus = await self.notificationAuthorizationStatus(center: center)
|
||||||
|
if self.isAuthorizationStatusAllowed(updatedStatus) {
|
||||||
|
// Refresh APNs registration immediately after the first permission grant so the
|
||||||
|
// gateway can receive a push registration without requiring an app relaunch.
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
return self.isAuthorizationStatusAllowed(updatedStatus)
|
return self.isAuthorizationStatusAllowed(updatedStatus)
|
||||||
case .denied:
|
case .denied:
|
||||||
return false
|
return false
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum PushTransportMode: String {
|
||||||
|
case direct
|
||||||
|
case relay
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PushDistributionMode: String {
|
||||||
|
case local
|
||||||
|
case official
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PushAPNsEnvironment: String {
|
||||||
|
case sandbox
|
||||||
|
case production
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushBuildConfig {
|
||||||
|
let transport: PushTransportMode
|
||||||
|
let distribution: PushDistributionMode
|
||||||
|
let relayBaseURL: URL?
|
||||||
|
let apnsEnvironment: PushAPNsEnvironment
|
||||||
|
|
||||||
|
static let current = PushBuildConfig()
|
||||||
|
|
||||||
|
init(bundle: Bundle = .main) {
|
||||||
|
self.transport = Self.readEnum(
|
||||||
|
bundle: bundle,
|
||||||
|
key: "OpenClawPushTransport",
|
||||||
|
fallback: .direct)
|
||||||
|
self.distribution = Self.readEnum(
|
||||||
|
bundle: bundle,
|
||||||
|
key: "OpenClawPushDistribution",
|
||||||
|
fallback: .local)
|
||||||
|
self.apnsEnvironment = Self.readEnum(
|
||||||
|
bundle: bundle,
|
||||||
|
key: "OpenClawPushAPNsEnvironment",
|
||||||
|
fallback: Self.defaultAPNsEnvironment)
|
||||||
|
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
|
||||||
|
}
|
||||||
|
|
||||||
|
var usesRelay: Bool {
|
||||||
|
self.transport == .relay
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readURL(bundle: Bundle, key: String) -> URL? {
|
||||||
|
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let components = URLComponents(string: trimmed),
|
||||||
|
components.scheme?.lowercased() == "https",
|
||||||
|
let host = components.host,
|
||||||
|
!host.isEmpty,
|
||||||
|
components.user == nil,
|
||||||
|
components.password == nil,
|
||||||
|
components.query == nil,
|
||||||
|
components.fragment == nil
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readEnum<T: RawRepresentable>(
|
||||||
|
bundle: Bundle,
|
||||||
|
key: String,
|
||||||
|
fallback: T)
|
||||||
|
-> T where T.RawValue == String {
|
||||||
|
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return T(rawValue: trimmed) ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private struct DirectGatewayPushRegistrationPayload: Encodable {
|
||||||
|
var transport: String = PushTransportMode.direct.rawValue
|
||||||
|
var token: String
|
||||||
|
var topic: String
|
||||||
|
var environment: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RelayGatewayPushRegistrationPayload: Encodable {
|
||||||
|
var transport: String = PushTransportMode.relay.rawValue
|
||||||
|
var relayHandle: String
|
||||||
|
var sendGrant: String
|
||||||
|
var gatewayDeviceId: String
|
||||||
|
var installationId: String
|
||||||
|
var topic: String
|
||||||
|
var environment: String
|
||||||
|
var distribution: String
|
||||||
|
var tokenDebugSuffix: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushRelayGatewayIdentity: Codable {
|
||||||
|
var deviceId: String
|
||||||
|
var publicKey: String
|
||||||
|
}
|
||||||
|
|
||||||
|
actor PushRegistrationManager {
|
||||||
|
private let buildConfig: PushBuildConfig
|
||||||
|
private let relayClient: PushRelayClient?
|
||||||
|
|
||||||
|
var usesRelayTransport: Bool {
|
||||||
|
self.buildConfig.transport == .relay
|
||||||
|
}
|
||||||
|
|
||||||
|
init(buildConfig: PushBuildConfig = .current) {
|
||||||
|
self.buildConfig = buildConfig
|
||||||
|
self.relayClient = buildConfig.relayBaseURL.map { PushRelayClient(baseURL: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeGatewayRegistrationPayload(
|
||||||
|
apnsTokenHex: String,
|
||||||
|
topic: String,
|
||||||
|
gatewayIdentity: PushRelayGatewayIdentity?)
|
||||||
|
async throws -> String {
|
||||||
|
switch self.buildConfig.transport {
|
||||||
|
case .direct:
|
||||||
|
return try Self.encodePayload(
|
||||||
|
DirectGatewayPushRegistrationPayload(
|
||||||
|
token: apnsTokenHex,
|
||||||
|
topic: topic,
|
||||||
|
environment: self.buildConfig.apnsEnvironment.rawValue))
|
||||||
|
case .relay:
|
||||||
|
guard let gatewayIdentity else {
|
||||||
|
throw PushRelayError.relayMisconfigured("Missing gateway identity for relay registration")
|
||||||
|
}
|
||||||
|
return try await self.makeRelayPayload(
|
||||||
|
apnsTokenHex: apnsTokenHex,
|
||||||
|
topic: topic,
|
||||||
|
gatewayIdentity: gatewayIdentity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRelayPayload(
|
||||||
|
apnsTokenHex: String,
|
||||||
|
topic: String,
|
||||||
|
gatewayIdentity: PushRelayGatewayIdentity)
|
||||||
|
async throws -> String {
|
||||||
|
guard self.buildConfig.distribution == .official else {
|
||||||
|
throw PushRelayError.relayMisconfigured(
|
||||||
|
"Relay transport requires OpenClawPushDistribution=official")
|
||||||
|
}
|
||||||
|
guard self.buildConfig.apnsEnvironment == .production else {
|
||||||
|
throw PushRelayError.relayMisconfigured(
|
||||||
|
"Relay transport requires OpenClawPushAPNsEnvironment=production")
|
||||||
|
}
|
||||||
|
guard let relayClient = self.relayClient else {
|
||||||
|
throw PushRelayError.relayBaseURLMissing
|
||||||
|
}
|
||||||
|
guard let bundleId = Bundle.main.bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!bundleId.isEmpty
|
||||||
|
else {
|
||||||
|
throw PushRelayError.relayMisconfigured("Missing bundle identifier for relay registration")
|
||||||
|
}
|
||||||
|
guard let installationId = GatewaySettingsStore.loadStableInstanceID()?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!installationId.isEmpty
|
||||||
|
else {
|
||||||
|
throw PushRelayError.relayMisconfigured("Missing stable installation ID for relay registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokenHashHex = Self.sha256Hex(apnsTokenHex)
|
||||||
|
let relayOrigin = relayClient.normalizedBaseURLString
|
||||||
|
if let stored = PushRelayRegistrationStore.loadRegistrationState(),
|
||||||
|
stored.installationId == installationId,
|
||||||
|
stored.gatewayDeviceId == gatewayIdentity.deviceId,
|
||||||
|
stored.relayOrigin == relayOrigin,
|
||||||
|
stored.lastAPNsTokenHashHex == tokenHashHex,
|
||||||
|
!Self.isExpired(stored.relayHandleExpiresAtMs)
|
||||||
|
{
|
||||||
|
return try Self.encodePayload(
|
||||||
|
RelayGatewayPushRegistrationPayload(
|
||||||
|
relayHandle: stored.relayHandle,
|
||||||
|
sendGrant: stored.sendGrant,
|
||||||
|
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||||
|
installationId: installationId,
|
||||||
|
topic: topic,
|
||||||
|
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||||
|
distribution: self.buildConfig.distribution.rawValue,
|
||||||
|
tokenDebugSuffix: stored.tokenDebugSuffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = try await relayClient.register(
|
||||||
|
installationId: installationId,
|
||||||
|
bundleId: bundleId,
|
||||||
|
appVersion: DeviceInfoHelper.appVersion(),
|
||||||
|
environment: self.buildConfig.apnsEnvironment,
|
||||||
|
distribution: self.buildConfig.distribution,
|
||||||
|
apnsTokenHex: apnsTokenHex,
|
||||||
|
gatewayIdentity: gatewayIdentity)
|
||||||
|
let registrationState = PushRelayRegistrationStore.RegistrationState(
|
||||||
|
relayHandle: response.relayHandle,
|
||||||
|
sendGrant: response.sendGrant,
|
||||||
|
relayOrigin: relayOrigin,
|
||||||
|
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||||
|
relayHandleExpiresAtMs: response.expiresAtMs,
|
||||||
|
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
|
||||||
|
lastAPNsTokenHashHex: tokenHashHex,
|
||||||
|
installationId: installationId,
|
||||||
|
lastTransport: self.buildConfig.transport.rawValue)
|
||||||
|
_ = PushRelayRegistrationStore.saveRegistrationState(registrationState)
|
||||||
|
return try Self.encodePayload(
|
||||||
|
RelayGatewayPushRegistrationPayload(
|
||||||
|
relayHandle: response.relayHandle,
|
||||||
|
sendGrant: response.sendGrant,
|
||||||
|
gatewayDeviceId: gatewayIdentity.deviceId,
|
||||||
|
installationId: installationId,
|
||||||
|
topic: topic,
|
||||||
|
environment: self.buildConfig.apnsEnvironment.rawValue,
|
||||||
|
distribution: self.buildConfig.distribution.rawValue,
|
||||||
|
tokenDebugSuffix: registrationState.tokenDebugSuffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isExpired(_ expiresAtMs: Int64?) -> Bool {
|
||||||
|
guard let expiresAtMs else { return true }
|
||||||
|
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
// Refresh shortly before expiry so reconnect-path republishes a live handle.
|
||||||
|
return expiresAtMs <= nowMs + 60_000
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sha256Hex(_ value: String) -> String {
|
||||||
|
let digest = SHA256.hash(data: Data(value.utf8))
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeTokenSuffix(_ value: String?) -> String? {
|
||||||
|
guard let value else { return nil }
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func encodePayload(_ payload: some Encodable) throws -> String {
|
||||||
|
let data = try JSONEncoder().encode(payload)
|
||||||
|
guard let json = String(data: data, encoding: .utf8) else {
|
||||||
|
throw PushRelayError.relayMisconfigured("Failed to encode push registration payload as UTF-8")
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,349 @@
|
||||||
|
import CryptoKit
|
||||||
|
import DeviceCheck
|
||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
enum PushRelayError: LocalizedError {
|
||||||
|
case relayBaseURLMissing
|
||||||
|
case relayMisconfigured(String)
|
||||||
|
case invalidResponse(String)
|
||||||
|
case requestFailed(status: Int, message: String)
|
||||||
|
case unsupportedAppAttest
|
||||||
|
case missingReceipt
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .relayBaseURLMissing:
|
||||||
|
"Push relay base URL missing"
|
||||||
|
case let .relayMisconfigured(message):
|
||||||
|
message
|
||||||
|
case let .invalidResponse(message):
|
||||||
|
message
|
||||||
|
case let .requestFailed(status, message):
|
||||||
|
"Push relay request failed (\(status)): \(message)"
|
||||||
|
case .unsupportedAppAttest:
|
||||||
|
"App Attest unavailable on this device"
|
||||||
|
case .missingReceipt:
|
||||||
|
"App Store receipt missing after refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayChallengeResponse: Decodable {
|
||||||
|
var challengeId: String
|
||||||
|
var challenge: String
|
||||||
|
var expiresAtMs: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayRegisterSignedPayload: Encodable {
|
||||||
|
var challengeId: String
|
||||||
|
var installationId: String
|
||||||
|
var bundleId: String
|
||||||
|
var environment: String
|
||||||
|
var distribution: String
|
||||||
|
var gateway: PushRelayGatewayIdentity
|
||||||
|
var appVersion: String
|
||||||
|
var apnsToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayAppAttestPayload: Encodable {
|
||||||
|
var keyId: String
|
||||||
|
var attestationObject: String?
|
||||||
|
var assertion: String
|
||||||
|
var clientDataHash: String
|
||||||
|
var signedPayloadBase64: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayReceiptPayload: Encodable {
|
||||||
|
var base64: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayRegisterRequest: Encodable {
|
||||||
|
var challengeId: String
|
||||||
|
var installationId: String
|
||||||
|
var bundleId: String
|
||||||
|
var environment: String
|
||||||
|
var distribution: String
|
||||||
|
var gateway: PushRelayGatewayIdentity
|
||||||
|
var appVersion: String
|
||||||
|
var apnsToken: String
|
||||||
|
var appAttest: PushRelayAppAttestPayload
|
||||||
|
var receipt: PushRelayReceiptPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushRelayRegisterResponse: Decodable {
|
||||||
|
var relayHandle: String
|
||||||
|
var sendGrant: String
|
||||||
|
var expiresAtMs: Int64?
|
||||||
|
var tokenSuffix: String?
|
||||||
|
var status: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RelayErrorResponse: Decodable {
|
||||||
|
var error: String?
|
||||||
|
var message: String?
|
||||||
|
var reason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PushRelayReceiptRefreshCoordinator: NSObject, SKRequestDelegate {
|
||||||
|
private var continuation: CheckedContinuation<Void, Error>?
|
||||||
|
private var activeRequest: SKReceiptRefreshRequest?
|
||||||
|
|
||||||
|
func refresh() async throws {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
self.continuation = continuation
|
||||||
|
let request = SKReceiptRefreshRequest()
|
||||||
|
self.activeRequest = request
|
||||||
|
request.delegate = self
|
||||||
|
request.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestDidFinish(_ request: SKRequest) {
|
||||||
|
self.continuation?.resume(returning: ())
|
||||||
|
self.continuation = nil
|
||||||
|
self.activeRequest = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||||
|
self.continuation?.resume(throwing: error)
|
||||||
|
self.continuation = nil
|
||||||
|
self.activeRequest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PushRelayAppAttestProof {
|
||||||
|
var keyId: String
|
||||||
|
var attestationObject: String?
|
||||||
|
var assertion: String
|
||||||
|
var clientDataHash: String
|
||||||
|
var signedPayloadBase64: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PushRelayAppAttestService {
|
||||||
|
func createProof(challenge: String, signedPayload: Data) async throws -> PushRelayAppAttestProof {
|
||||||
|
let service = DCAppAttestService.shared
|
||||||
|
guard service.isSupported else {
|
||||||
|
throw PushRelayError.unsupportedAppAttest
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyID = try await self.loadOrCreateKeyID(using: service)
|
||||||
|
let attestationObject = try await self.attestKeyIfNeeded(
|
||||||
|
service: service,
|
||||||
|
keyID: keyID,
|
||||||
|
challenge: challenge)
|
||||||
|
let signedPayloadHash = Data(SHA256.hash(data: signedPayload))
|
||||||
|
let assertion = try await self.generateAssertion(
|
||||||
|
service: service,
|
||||||
|
keyID: keyID,
|
||||||
|
signedPayloadHash: signedPayloadHash)
|
||||||
|
|
||||||
|
return PushRelayAppAttestProof(
|
||||||
|
keyId: keyID,
|
||||||
|
attestationObject: attestationObject,
|
||||||
|
assertion: assertion.base64EncodedString(),
|
||||||
|
clientDataHash: Self.base64URL(signedPayloadHash),
|
||||||
|
signedPayloadBase64: signedPayload.base64EncodedString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadOrCreateKeyID(using service: DCAppAttestService) async throws -> String {
|
||||||
|
if let existing = PushRelayRegistrationStore.loadAppAttestKeyID(), !existing.isEmpty {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
let keyID = try await service.generateKey()
|
||||||
|
_ = PushRelayRegistrationStore.saveAppAttestKeyID(keyID)
|
||||||
|
return keyID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attestKeyIfNeeded(
|
||||||
|
service: DCAppAttestService,
|
||||||
|
keyID: String,
|
||||||
|
challenge: String)
|
||||||
|
async throws -> String? {
|
||||||
|
if PushRelayRegistrationStore.loadAttestedKeyID() == keyID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let challengeData = Data(challenge.utf8)
|
||||||
|
let clientDataHash = Data(SHA256.hash(data: challengeData))
|
||||||
|
let attestation = try await service.attestKey(keyID, clientDataHash: clientDataHash)
|
||||||
|
// Apple treats App Attest key attestation as a one-time operation. Save the
|
||||||
|
// attested marker immediately so later receipt/network failures do not cause a
|
||||||
|
// permanently broken re-attestation loop on the same key.
|
||||||
|
_ = PushRelayRegistrationStore.saveAttestedKeyID(keyID)
|
||||||
|
return attestation.base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func generateAssertion(
|
||||||
|
service: DCAppAttestService,
|
||||||
|
keyID: String,
|
||||||
|
signedPayloadHash: Data)
|
||||||
|
async throws -> Data {
|
||||||
|
do {
|
||||||
|
return try await service.generateAssertion(keyID, clientDataHash: signedPayloadHash)
|
||||||
|
} catch {
|
||||||
|
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||||
|
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func base64URL(_ data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PushRelayReceiptProvider {
|
||||||
|
func loadReceiptBase64() async throws -> String {
|
||||||
|
if let receipt = self.readReceiptData() {
|
||||||
|
return receipt.base64EncodedString()
|
||||||
|
}
|
||||||
|
let refreshCoordinator = PushRelayReceiptRefreshCoordinator()
|
||||||
|
try await refreshCoordinator.refresh()
|
||||||
|
if let refreshed = self.readReceiptData() {
|
||||||
|
return refreshed.base64EncodedString()
|
||||||
|
}
|
||||||
|
throw PushRelayError.missingReceipt
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readReceiptData() -> Data? {
|
||||||
|
guard let url = Bundle.main.appStoreReceiptURL else { return nil }
|
||||||
|
guard let data = try? Data(contentsOf: url), !data.isEmpty else { return nil }
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The client is constructed once and used behind PushRegistrationManager actor isolation.
|
||||||
|
final class PushRelayClient: @unchecked Sendable {
|
||||||
|
private let baseURL: URL
|
||||||
|
private let session: URLSession
|
||||||
|
private let jsonDecoder = JSONDecoder()
|
||||||
|
private let jsonEncoder = JSONEncoder()
|
||||||
|
private let appAttest = PushRelayAppAttestService()
|
||||||
|
private let receiptProvider = PushRelayReceiptProvider()
|
||||||
|
|
||||||
|
init(baseURL: URL, session: URLSession = .shared) {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.session = session
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedBaseURLString: String {
|
||||||
|
Self.normalizeBaseURLString(self.baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(
|
||||||
|
installationId: String,
|
||||||
|
bundleId: String,
|
||||||
|
appVersion: String,
|
||||||
|
environment: PushAPNsEnvironment,
|
||||||
|
distribution: PushDistributionMode,
|
||||||
|
apnsTokenHex: String,
|
||||||
|
gatewayIdentity: PushRelayGatewayIdentity)
|
||||||
|
async throws -> PushRelayRegisterResponse {
|
||||||
|
let challenge = try await self.fetchChallenge()
|
||||||
|
let signedPayload = PushRelayRegisterSignedPayload(
|
||||||
|
challengeId: challenge.challengeId,
|
||||||
|
installationId: installationId,
|
||||||
|
bundleId: bundleId,
|
||||||
|
environment: environment.rawValue,
|
||||||
|
distribution: distribution.rawValue,
|
||||||
|
gateway: gatewayIdentity,
|
||||||
|
appVersion: appVersion,
|
||||||
|
apnsToken: apnsTokenHex)
|
||||||
|
let signedPayloadData = try self.jsonEncoder.encode(signedPayload)
|
||||||
|
let appAttest = try await self.appAttest.createProof(
|
||||||
|
challenge: challenge.challenge,
|
||||||
|
signedPayload: signedPayloadData)
|
||||||
|
let receiptBase64 = try await self.receiptProvider.loadReceiptBase64()
|
||||||
|
let requestBody = PushRelayRegisterRequest(
|
||||||
|
challengeId: signedPayload.challengeId,
|
||||||
|
installationId: signedPayload.installationId,
|
||||||
|
bundleId: signedPayload.bundleId,
|
||||||
|
environment: signedPayload.environment,
|
||||||
|
distribution: signedPayload.distribution,
|
||||||
|
gateway: signedPayload.gateway,
|
||||||
|
appVersion: signedPayload.appVersion,
|
||||||
|
apnsToken: signedPayload.apnsToken,
|
||||||
|
appAttest: PushRelayAppAttestPayload(
|
||||||
|
keyId: appAttest.keyId,
|
||||||
|
attestationObject: appAttest.attestationObject,
|
||||||
|
assertion: appAttest.assertion,
|
||||||
|
clientDataHash: appAttest.clientDataHash,
|
||||||
|
signedPayloadBase64: appAttest.signedPayloadBase64),
|
||||||
|
receipt: PushRelayReceiptPayload(base64: receiptBase64))
|
||||||
|
|
||||||
|
let endpoint = self.baseURL.appending(path: "v1/push/register")
|
||||||
|
var request = URLRequest(url: endpoint)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 20
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try self.jsonEncoder.encode(requestBody)
|
||||||
|
|
||||||
|
let (data, response) = try await self.session.data(for: request)
|
||||||
|
let status = Self.statusCode(from: response)
|
||||||
|
guard (200..<300).contains(status) else {
|
||||||
|
if status == 401 {
|
||||||
|
// If the relay rejects registration, drop local App Attest state so the next
|
||||||
|
// attempt re-attests instead of getting stuck without an attestation object.
|
||||||
|
_ = PushRelayRegistrationStore.clearAppAttestKeyID()
|
||||||
|
_ = PushRelayRegistrationStore.clearAttestedKeyID()
|
||||||
|
}
|
||||||
|
throw PushRelayError.requestFailed(
|
||||||
|
status: status,
|
||||||
|
message: Self.decodeErrorMessage(data: data))
|
||||||
|
}
|
||||||
|
let decoded = try self.decode(PushRelayRegisterResponse.self, from: data)
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchChallenge() async throws -> PushRelayChallengeResponse {
|
||||||
|
let endpoint = self.baseURL.appending(path: "v1/push/challenge")
|
||||||
|
var request = URLRequest(url: endpoint)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.timeoutInterval = 10
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = Data("{}".utf8)
|
||||||
|
|
||||||
|
let (data, response) = try await self.session.data(for: request)
|
||||||
|
let status = Self.statusCode(from: response)
|
||||||
|
guard (200..<300).contains(status) else {
|
||||||
|
throw PushRelayError.requestFailed(
|
||||||
|
status: status,
|
||||||
|
message: Self.decodeErrorMessage(data: data))
|
||||||
|
}
|
||||||
|
return try self.decode(PushRelayChallengeResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
|
||||||
|
do {
|
||||||
|
return try self.jsonDecoder.decode(type, from: data)
|
||||||
|
} catch {
|
||||||
|
throw PushRelayError.invalidResponse(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func statusCode(from response: URLResponse) -> Int {
|
||||||
|
(response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeBaseURLString(_ url: URL) -> String {
|
||||||
|
var absolute = url.absoluteString
|
||||||
|
while absolute.hasSuffix("/") {
|
||||||
|
absolute.removeLast()
|
||||||
|
}
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeErrorMessage(data: Data) -> String {
|
||||||
|
if let decoded = try? JSONDecoder().decode(RelayErrorResponse.self, from: data) {
|
||||||
|
let message = decoded.message ?? decoded.reason ?? decoded.error ?? ""
|
||||||
|
if !message.isEmpty {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return raw.isEmpty ? "unknown relay error" : raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
private struct StoredPushRelayRegistrationState: Codable {
|
||||||
|
var relayHandle: String
|
||||||
|
var sendGrant: String
|
||||||
|
var relayOrigin: String?
|
||||||
|
var gatewayDeviceId: String
|
||||||
|
var relayHandleExpiresAtMs: Int64?
|
||||||
|
var tokenDebugSuffix: String?
|
||||||
|
var lastAPNsTokenHashHex: String
|
||||||
|
var installationId: String
|
||||||
|
var lastTransport: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PushRelayRegistrationStore {
|
||||||
|
private static let service = "ai.openclaw.pushrelay"
|
||||||
|
private static let registrationStateAccount = "registration-state"
|
||||||
|
private static let appAttestKeyIDAccount = "app-attest-key-id"
|
||||||
|
private static let appAttestedKeyIDAccount = "app-attested-key-id"
|
||||||
|
|
||||||
|
struct RegistrationState: Codable {
|
||||||
|
var relayHandle: String
|
||||||
|
var sendGrant: String
|
||||||
|
var relayOrigin: String?
|
||||||
|
var gatewayDeviceId: String
|
||||||
|
var relayHandleExpiresAtMs: Int64?
|
||||||
|
var tokenDebugSuffix: String?
|
||||||
|
var lastAPNsTokenHashHex: String
|
||||||
|
var installationId: String
|
||||||
|
var lastTransport: String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadRegistrationState() -> RegistrationState? {
|
||||||
|
guard let raw = KeychainStore.loadString(
|
||||||
|
service: self.service,
|
||||||
|
account: self.registrationStateAccount),
|
||||||
|
let data = raw.data(using: .utf8),
|
||||||
|
let decoded = try? JSONDecoder().decode(StoredPushRelayRegistrationState.self, from: data)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return RegistrationState(
|
||||||
|
relayHandle: decoded.relayHandle,
|
||||||
|
sendGrant: decoded.sendGrant,
|
||||||
|
relayOrigin: decoded.relayOrigin,
|
||||||
|
gatewayDeviceId: decoded.gatewayDeviceId,
|
||||||
|
relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
|
||||||
|
tokenDebugSuffix: decoded.tokenDebugSuffix,
|
||||||
|
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
|
||||||
|
installationId: decoded.installationId,
|
||||||
|
lastTransport: decoded.lastTransport)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func saveRegistrationState(_ state: RegistrationState) -> Bool {
|
||||||
|
let stored = StoredPushRelayRegistrationState(
|
||||||
|
relayHandle: state.relayHandle,
|
||||||
|
sendGrant: state.sendGrant,
|
||||||
|
relayOrigin: state.relayOrigin,
|
||||||
|
gatewayDeviceId: state.gatewayDeviceId,
|
||||||
|
relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
|
||||||
|
tokenDebugSuffix: state.tokenDebugSuffix,
|
||||||
|
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,
|
||||||
|
installationId: state.installationId,
|
||||||
|
lastTransport: state.lastTransport)
|
||||||
|
guard let data = try? JSONEncoder().encode(stored),
|
||||||
|
let raw = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func clearRegistrationState() -> Bool {
|
||||||
|
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadAppAttestKeyID() -> String? {
|
||||||
|
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value?.isEmpty == false { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func saveAppAttestKeyID(_ keyID: String) -> Bool {
|
||||||
|
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestKeyIDAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func clearAppAttestKeyID() -> Bool {
|
||||||
|
KeychainStore.delete(service: self.service, account: self.appAttestKeyIDAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadAttestedKeyID() -> String? {
|
||||||
|
let value = KeychainStore.loadString(service: self.service, account: self.appAttestedKeyIDAccount)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if value?.isEmpty == false { return value }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func saveAttestedKeyID(_ keyID: String) -> Bool {
|
||||||
|
KeychainStore.saveString(keyID, service: self.service, account: self.appAttestedKeyIDAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func clearAttestedKeyID() -> Bool {
|
||||||
|
KeychainStore.delete(service: self.service, account: self.appAttestedKeyIDAccount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -767,12 +767,22 @@ struct SettingsTab: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedBootstrapToken =
|
||||||
|
payload.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayBootstrapToken(trimmedBootstrapToken, instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let token = payload.token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.gatewayToken = trimmedToken
|
self.gatewayToken = trimmedToken
|
||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||||
}
|
}
|
||||||
|
} else if !trimmedBootstrapToken.isEmpty {
|
||||||
|
self.gatewayToken = ""
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayToken("", instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let password = payload.password, !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
@ -780,6 +790,11 @@ struct SettingsTab: View {
|
||||||
if !trimmedInstanceId.isEmpty {
|
if !trimmedInstanceId.isEmpty {
|
||||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||||
}
|
}
|
||||||
|
} else if !trimmedBootstrapToken.isEmpty {
|
||||||
|
self.gatewayPassword = ""
|
||||||
|
if !trimmedInstanceId.isEmpty {
|
||||||
|
GatewaySettingsStore.saveGatewayPassword("", instanceId: trimmedInstanceId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,13 @@ private func agentAction(
|
||||||
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
||||||
#expect(
|
#expect(
|
||||||
DeepLinkParser.parse(url) == .gateway(
|
DeepLinkParser.parse(url) == .gateway(
|
||||||
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
|
.init(
|
||||||
|
host: "openclaw.local",
|
||||||
|
port: 18789,
|
||||||
|
tls: true,
|
||||||
|
bootstrapToken: nil,
|
||||||
|
token: "abc",
|
||||||
|
password: "def")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
|
@Test func parseGatewayLinkRejectsInsecureNonLoopbackWs() {
|
||||||
|
|
@ -102,14 +108,15 @@ private func agentAction(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
let payload = #"{"url":"wss://gateway.example.com:443","bootstrapToken":"tok","password":"pw"}"#
|
||||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "gateway.example.com",
|
host: "gateway.example.com",
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: true,
|
tls: true,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: "pw"))
|
password: "pw"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,38 +125,40 @@ private func agentAction(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||||
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
|
let payload = #"{"url":"wss://gateway.example.com","bootstrapToken":"tok"}"#
|
||||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "gateway.example.com",
|
host: "gateway.example.com",
|
||||||
port: 443,
|
port: 443,
|
||||||
tls: true,
|
tls: true,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: nil))
|
password: nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
|
@Test func parseGatewaySetupCodeRejectsInsecureNonLoopbackWs() {
|
||||||
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
|
||||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
#expect(link == nil)
|
#expect(link == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
|
@Test func parseGatewaySetupCodeRejectsInsecurePrefixBypassHost() {
|
||||||
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
|
||||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
#expect(link == nil)
|
#expect(link == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
|
@Test func parseGatewaySetupCodeAllowsLoopbackWs() {
|
||||||
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
|
||||||
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
let link = GatewayConnectDeepLink.fromSetupCode(setupCode(from: payload))
|
||||||
|
|
||||||
#expect(link == .init(
|
#expect(link == .init(
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 18789,
|
port: 18789,
|
||||||
tls: false,
|
tls: false,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: nil))
|
password: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,10 @@ import Testing
|
||||||
_ = try await transport.requestHealth(timeoutMs: 250)
|
_ = try await transport.requestHealth(timeoutMs: 250)
|
||||||
Issue.record("Expected requestHealth to throw when gateway not connected")
|
Issue.record("Expected requestHealth to throw when gateway not connected")
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await transport.resetSession(sessionKey: "node-test")
|
||||||
|
Issue.record("Expected resetSession to throw when gateway not connected")
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
<key>WKCompanionAppBundleIdentifier</key>
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
|
||||||
<key>WKWatchKitApp</key>
|
<key>WKWatchKitApp</key>
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>$(OPENCLAW_MARKETING_VERSION)</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>20260308</string>
|
<string>$(OPENCLAW_BUILD_VERSION)</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionAttributes</key>
|
<key>NSExtensionAttributes</key>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
require "shellwords"
|
require "shellwords"
|
||||||
require "open3"
|
require "open3"
|
||||||
|
require "json"
|
||||||
|
|
||||||
default_platform(:ios)
|
default_platform(:ios)
|
||||||
|
|
||||||
|
BETA_APP_IDENTIFIER = "ai.openclaw.client"
|
||||||
|
|
||||||
def load_env_file(path)
|
def load_env_file(path)
|
||||||
return unless File.exist?(path)
|
return unless File.exist?(path)
|
||||||
|
|
||||||
|
|
@ -84,6 +87,111 @@ def read_asc_key_content_from_keychain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def repo_root
|
||||||
|
File.expand_path("../../..", __dir__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ios_root
|
||||||
|
File.expand_path("..", __dir__)
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_release_version(raw_value)
|
||||||
|
version = raw_value.to_s.strip.sub(/\Av/, "")
|
||||||
|
UI.user_error!("Missing root package.json version.") unless env_present?(version)
|
||||||
|
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
|
||||||
|
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.13 or 2026.3.13-beta.1.")
|
||||||
|
end
|
||||||
|
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
|
def read_root_package_version
|
||||||
|
package_json_path = File.join(repo_root, "package.json")
|
||||||
|
UI.user_error!("Missing package.json at #{package_json_path}.") unless File.exist?(package_json_path)
|
||||||
|
|
||||||
|
parsed = JSON.parse(File.read(package_json_path))
|
||||||
|
normalize_release_version(parsed["version"])
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
UI.user_error!("Invalid package.json at #{package_json_path}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def short_release_version(version)
|
||||||
|
normalize_release_version(version).sub(/([.-]?beta[.-]\d+)\z/i, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def shell_join(parts)
|
||||||
|
Shellwords.join(parts.compact)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_beta_build_number(api_key:, version:)
|
||||||
|
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||||
|
if env_present?(explicit)
|
||||||
|
UI.user_error!("Invalid IOS_BETA_BUILD_NUMBER '#{explicit}'. Expected digits only.") unless explicit.match?(/\A\d+\z/)
|
||||||
|
UI.message("Using explicit iOS beta build number #{explicit}.")
|
||||||
|
return explicit
|
||||||
|
end
|
||||||
|
|
||||||
|
short_version = short_release_version(version)
|
||||||
|
latest_build = latest_testflight_build_number(
|
||||||
|
api_key: api_key,
|
||||||
|
app_identifier: BETA_APP_IDENTIFIER,
|
||||||
|
version: short_version,
|
||||||
|
initial_build_number: 0
|
||||||
|
)
|
||||||
|
next_build = latest_build.to_i + 1
|
||||||
|
UI.message("Resolved iOS beta build number #{next_build} for #{short_version} (latest TestFlight build: #{latest_build}).")
|
||||||
|
next_build.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def beta_build_number_needs_asc_auth?
|
||||||
|
explicit = ENV["IOS_BETA_BUILD_NUMBER"]
|
||||||
|
!env_present?(explicit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_beta_release!(version:, build_number:)
|
||||||
|
script_path = File.join(repo_root, "scripts", "ios-beta-prepare.sh")
|
||||||
|
UI.message("Preparing iOS beta release #{version} (build #{build_number}).")
|
||||||
|
sh(shell_join(["bash", script_path, "--build-number", build_number]))
|
||||||
|
|
||||||
|
beta_xcconfig = File.join(ios_root, "build", "BetaRelease.xcconfig")
|
||||||
|
UI.user_error!("Missing beta xcconfig at #{beta_xcconfig}.") unless File.exist?(beta_xcconfig)
|
||||||
|
|
||||||
|
ENV["XCODE_XCCONFIG_FILE"] = beta_xcconfig
|
||||||
|
beta_xcconfig
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_beta_release(context)
|
||||||
|
version = context[:version]
|
||||||
|
output_directory = File.join("build", "beta")
|
||||||
|
archive_path = File.join(output_directory, "OpenClaw-#{version}.xcarchive")
|
||||||
|
|
||||||
|
build_app(
|
||||||
|
project: "OpenClaw.xcodeproj",
|
||||||
|
scheme: "OpenClaw",
|
||||||
|
configuration: "Release",
|
||||||
|
export_method: "app-store",
|
||||||
|
clean: true,
|
||||||
|
skip_profile_detection: true,
|
||||||
|
build_path: "build",
|
||||||
|
archive_path: archive_path,
|
||||||
|
output_directory: output_directory,
|
||||||
|
output_name: "OpenClaw-#{version}.ipa",
|
||||||
|
xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_xcargs: "-allowProvisioningUpdates",
|
||||||
|
export_options: {
|
||||||
|
signingStyle: "automatic"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
archive_path: archive_path,
|
||||||
|
build_number: context[:build_number],
|
||||||
|
ipa_path: lane_context[SharedValues::IPA_OUTPUT_PATH],
|
||||||
|
short_version: context[:short_version],
|
||||||
|
version: version
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
platform :ios do
|
platform :ios do
|
||||||
private_lane :asc_api_key do
|
private_lane :asc_api_key do
|
||||||
load_env_file(File.join(__dir__, ".env"))
|
load_env_file(File.join(__dir__, ".env"))
|
||||||
|
|
@ -132,38 +240,48 @@ platform :ios do
|
||||||
api_key
|
api_key
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Build + upload to TestFlight"
|
private_lane :prepare_beta_context do |options|
|
||||||
|
require_api_key = options[:require_api_key] == true
|
||||||
|
needs_api_key = require_api_key || beta_build_number_needs_asc_auth?
|
||||||
|
api_key = needs_api_key ? asc_api_key : nil
|
||||||
|
version = read_root_package_version
|
||||||
|
build_number = resolve_beta_build_number(api_key: api_key, version: version)
|
||||||
|
beta_xcconfig = prepare_beta_release!(version: version, build_number: build_number)
|
||||||
|
|
||||||
|
{
|
||||||
|
api_key: api_key,
|
||||||
|
beta_xcconfig: beta_xcconfig,
|
||||||
|
build_number: build_number,
|
||||||
|
short_version: short_release_version(version),
|
||||||
|
version: version
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build a beta archive locally without uploading"
|
||||||
|
lane :beta_archive do
|
||||||
|
context = prepare_beta_context(require_api_key: false)
|
||||||
|
build = build_beta_release(context)
|
||||||
|
UI.success("Built iOS beta archive: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||||
|
build
|
||||||
|
ensure
|
||||||
|
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Build + upload a beta to TestFlight"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
api_key = asc_api_key
|
context = prepare_beta_context(require_api_key: true)
|
||||||
|
build = build_beta_release(context)
|
||||||
team_id = ENV["IOS_DEVELOPMENT_TEAM"]
|
|
||||||
if team_id.nil? || team_id.strip.empty?
|
|
||||||
helper_path = File.expand_path("../../../scripts/ios-team-id.sh", __dir__)
|
|
||||||
if File.exist?(helper_path)
|
|
||||||
# Keep CI/local compatibility where teams are present in keychain but not Xcode account metadata.
|
|
||||||
team_id = sh("IOS_ALLOW_KEYCHAIN_TEAM_FALLBACK=1 bash #{helper_path.shellescape}").strip
|
|
||||||
end
|
|
||||||
end
|
|
||||||
UI.user_error!("Missing IOS_DEVELOPMENT_TEAM (Apple Team ID). Add it to fastlane/.env or export it in your shell.") if team_id.nil? || team_id.strip.empty?
|
|
||||||
|
|
||||||
build_app(
|
|
||||||
project: "OpenClaw.xcodeproj",
|
|
||||||
scheme: "OpenClaw",
|
|
||||||
export_method: "app-store",
|
|
||||||
clean: true,
|
|
||||||
skip_profile_detection: true,
|
|
||||||
xcargs: "DEVELOPMENT_TEAM=#{team_id} -allowProvisioningUpdates",
|
|
||||||
export_xcargs: "-allowProvisioningUpdates",
|
|
||||||
export_options: {
|
|
||||||
signingStyle: "automatic"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
upload_to_testflight(
|
upload_to_testflight(
|
||||||
api_key: api_key,
|
api_key: context[:api_key],
|
||||||
|
ipa: build[:ipa_path],
|
||||||
skip_waiting_for_build_processing: true,
|
skip_waiting_for_build_processing: true,
|
||||||
uses_non_exempt_encryption: false
|
uses_non_exempt_encryption: false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
UI.success("Uploaded iOS beta: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||||
|
ensure
|
||||||
|
ENV.delete("XCODE_XCCONFIG_FILE")
|
||||||
end
|
end
|
||||||
|
|
||||||
desc "Upload App Store metadata (and optionally screenshots)"
|
desc "Upload App Store metadata (and optionally screenshots)"
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,9 @@ ASC_KEYCHAIN_ACCOUNT=YOUR_MAC_USERNAME
|
||||||
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
Optional app targeting variables (helpful if Fastlane cannot auto-resolve app by bundle):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ASC_APP_IDENTIFIER=ai.openclaw.ios
|
ASC_APP_IDENTIFIER=ai.openclaw.client
|
||||||
# or
|
# or
|
||||||
ASC_APP_ID=6760218713
|
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
File-based fallback (CI/non-macOS):
|
File-based fallback (CI/non-macOS):
|
||||||
|
|
@ -60,9 +60,37 @@ cd apps/ios
|
||||||
fastlane ios auth_check
|
fastlane ios auth_check
|
||||||
```
|
```
|
||||||
|
|
||||||
Run:
|
ASC auth is only required when:
|
||||||
|
|
||||||
|
- uploading to TestFlight
|
||||||
|
- auto-resolving the next build number from App Store Connect
|
||||||
|
|
||||||
|
If you pass `--build-number` to `pnpm ios:beta:archive`, the local archive path does not need ASC auth.
|
||||||
|
|
||||||
|
Archive locally without upload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:beta:archive
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload to TestFlight:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm ios:beta
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct Fastlane entry point:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
fastlane beta
|
fastlane ios beta
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Versioning rules:
|
||||||
|
|
||||||
|
- Root `package.json.version` is the single source of truth for iOS
|
||||||
|
- Use `YYYY.M.D` for stable versions and `YYYY.M.D-beta.N` for beta versions
|
||||||
|
- Fastlane stamps `CFBundleShortVersionString` to `YYYY.M.D`
|
||||||
|
- Fastlane resolves `CFBundleVersion` as the next integer TestFlight build number for that short version
|
||||||
|
- The beta flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
|
||||||
|
- Local beta signing uses a temporary generated xcconfig and leaves local development signing overrides untouched
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ This directory is used by `fastlane deliver` for App Store Connect text metadata
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd apps/ios
|
cd apps/ios
|
||||||
ASC_APP_ID=6760218713 \
|
ASC_APP_ID=YOUR_APP_STORE_CONNECT_APP_ID \
|
||||||
DELIVER_METADATA=1 fastlane ios metadata
|
DELIVER_METADATA=1 fastlane ios metadata
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,17 @@ targets:
|
||||||
SUPPORTS_LIVE_ACTIVITIES: YES
|
SUPPORTS_LIVE_ACTIVITIES: YES
|
||||||
ENABLE_APPINTENTS_METADATA: NO
|
ENABLE_APPINTENTS_METADATA: NO
|
||||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||||
|
configs:
|
||||||
|
Debug:
|
||||||
|
OPENCLAW_PUSH_TRANSPORT: direct
|
||||||
|
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||||
|
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||||
|
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
|
||||||
|
Release:
|
||||||
|
OPENCLAW_PUSH_TRANSPORT: direct
|
||||||
|
OPENCLAW_PUSH_DISTRIBUTION: local
|
||||||
|
OPENCLAW_PUSH_RELAY_BASE_URL: ""
|
||||||
|
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
|
||||||
info:
|
info:
|
||||||
path: Sources/Info.plist
|
path: Sources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -107,8 +118,8 @@ targets:
|
||||||
- CFBundleURLName: ai.openclaw.ios
|
- CFBundleURLName: ai.openclaw.ios
|
||||||
CFBundleURLSchemes:
|
CFBundleURLSchemes:
|
||||||
- openclaw
|
- openclaw
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
|
|
@ -131,6 +142,10 @@ targets:
|
||||||
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
|
||||||
NSSupportsLiveActivities: true
|
NSSupportsLiveActivities: true
|
||||||
ITSAppUsesNonExemptEncryption: false
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
|
||||||
|
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
|
||||||
|
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
|
||||||
|
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
|
||||||
UISupportedInterfaceOrientations:
|
UISupportedInterfaceOrientations:
|
||||||
- UIInterfaceOrientationPortrait
|
- UIInterfaceOrientationPortrait
|
||||||
- UIInterfaceOrientationPortraitUpsideDown
|
- UIInterfaceOrientationPortraitUpsideDown
|
||||||
|
|
@ -168,8 +183,8 @@ targets:
|
||||||
path: ShareExtension/Info.plist
|
path: ShareExtension/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClaw Share
|
CFBundleDisplayName: OpenClaw Share
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.share-services
|
NSExtensionPointIdentifier: com.apple.share-services
|
||||||
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
NSExtensionPrincipalClass: "$(PRODUCT_MODULE_NAME).ShareViewController"
|
||||||
|
|
@ -205,8 +220,8 @@ targets:
|
||||||
path: ActivityWidget/Info.plist
|
path: ActivityWidget/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClaw Activity
|
CFBundleDisplayName: OpenClaw Activity
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
NSSupportsLiveActivities: true
|
NSSupportsLiveActivities: true
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||||
|
|
@ -224,6 +239,7 @@ targets:
|
||||||
Release: Config/Signing.xcconfig
|
Release: Config/Signing.xcconfig
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
ENABLE_APPINTENTS_METADATA: NO
|
ENABLE_APPINTENTS_METADATA: NO
|
||||||
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||||
|
|
@ -231,8 +247,8 @@ targets:
|
||||||
path: WatchApp/Info.plist
|
path: WatchApp/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClaw
|
CFBundleDisplayName: OpenClaw
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
|
||||||
WKWatchKitApp: true
|
WKWatchKitApp: true
|
||||||
|
|
||||||
|
|
@ -256,8 +272,8 @@ targets:
|
||||||
path: WatchExtension/Info.plist
|
path: WatchExtension/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClaw
|
CFBundleDisplayName: OpenClaw
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
NSExtension:
|
NSExtension:
|
||||||
NSExtensionAttributes:
|
NSExtensionAttributes:
|
||||||
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
|
||||||
|
|
@ -293,8 +309,8 @@ targets:
|
||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClawTests
|
CFBundleDisplayName: OpenClawTests
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
|
|
||||||
OpenClawLogicTests:
|
OpenClawLogicTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
|
|
@ -319,5 +335,5 @@ targets:
|
||||||
path: Tests/Info.plist
|
path: Tests/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: OpenClawLogicTests
|
CFBundleDisplayName: OpenClawLogicTests
|
||||||
CFBundleShortVersionString: "2026.3.9"
|
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
|
||||||
CFBundleVersion: "20260308"
|
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
|
||||||
|
|
|
||||||
|
|
@ -600,30 +600,29 @@ final class AppState {
|
||||||
private func syncGatewayConfigIfNeeded() {
|
private func syncGatewayConfigIfNeeded() {
|
||||||
guard !self.isPreview, !self.isInitializing else { return }
|
guard !self.isPreview, !self.isInitializing else { return }
|
||||||
|
|
||||||
let connectionMode = self.connectionMode
|
|
||||||
let remoteTarget = self.remoteTarget
|
|
||||||
let remoteIdentity = self.remoteIdentity
|
|
||||||
let remoteTransport = self.remoteTransport
|
|
||||||
let remoteUrl = self.remoteUrl
|
|
||||||
let remoteToken = self.remoteToken
|
|
||||||
let remoteTokenDirty = self.remoteTokenDirty
|
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
self.syncGatewayConfigNow()
|
||||||
let synced = Self.syncedGatewayRoot(
|
|
||||||
currentRoot: OpenClawConfigFile.loadDict(),
|
|
||||||
connectionMode: connectionMode,
|
|
||||||
remoteTransport: remoteTransport,
|
|
||||||
remoteTarget: remoteTarget,
|
|
||||||
remoteIdentity: remoteIdentity,
|
|
||||||
remoteUrl: remoteUrl,
|
|
||||||
remoteToken: remoteToken,
|
|
||||||
remoteTokenDirty: remoteTokenDirty)
|
|
||||||
guard synced.changed else { return }
|
|
||||||
OpenClawConfigFile.saveDict(synced.root)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func syncGatewayConfigNow() {
|
||||||
|
guard !self.isPreview, !self.isInitializing else { return }
|
||||||
|
|
||||||
|
// Keep app-only connection settings local to avoid overwriting remote gateway config.
|
||||||
|
let synced = Self.syncedGatewayRoot(
|
||||||
|
currentRoot: OpenClawConfigFile.loadDict(),
|
||||||
|
connectionMode: self.connectionMode,
|
||||||
|
remoteTransport: self.remoteTransport,
|
||||||
|
remoteTarget: self.remoteTarget,
|
||||||
|
remoteIdentity: self.remoteIdentity,
|
||||||
|
remoteUrl: self.remoteUrl,
|
||||||
|
remoteToken: self.remoteToken,
|
||||||
|
remoteTokenDirty: self.remoteTokenDirty)
|
||||||
|
guard synced.changed else { return }
|
||||||
|
OpenClawConfigFile.saveDict(synced.root)
|
||||||
|
}
|
||||||
|
|
||||||
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
func triggerVoiceEars(ttl: TimeInterval? = 5) {
|
||||||
self.earBoostTask?.cancel()
|
self.earBoostTask?.cancel()
|
||||||
self.earBoostActive = true
|
self.earBoostActive = true
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,10 @@ final class ControlChannel {
|
||||||
return desc
|
return desc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let authIssue = RemoteGatewayAuthIssue(error: error) {
|
||||||
|
return authIssue.statusMessage
|
||||||
|
}
|
||||||
|
|
||||||
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
// If the gateway explicitly rejects the hello (e.g., auth/token mismatch), surface it.
|
||||||
if let urlErr = error as? URLError,
|
if let urlErr = error as? URLError,
|
||||||
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
urlErr.code == .dataNotAllowed // used for WS close 1008 auth failures
|
||||||
|
|
@ -320,6 +324,8 @@ final class ControlChannel {
|
||||||
switch source {
|
switch source {
|
||||||
case .deviceToken:
|
case .deviceToken:
|
||||||
return "Auth: device token (paired device)"
|
return "Auth: device token (paired device)"
|
||||||
|
case .bootstrapToken:
|
||||||
|
return "Auth: bootstrap token (setup code)"
|
||||||
case .sharedToken:
|
case .sharedToken:
|
||||||
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
return "Auth: shared token (\(isRemote ? "gateway.remote.token" : "gateway.auth.token"))"
|
||||||
case .password:
|
case .password:
|
||||||
|
|
|
||||||
|
|
@ -348,10 +348,18 @@ struct GeneralSettings: View {
|
||||||
Text("Testing…")
|
Text("Testing…")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
case .ok:
|
case let .ok(success):
|
||||||
Label("Ready", systemImage: "checkmark.circle.fill")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.caption)
|
Label(success.title, systemImage: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
if let detail = success.detail {
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
case let .failed(message):
|
case let .failed(message):
|
||||||
Text(message)
|
Text(message)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -518,7 +526,7 @@ struct GeneralSettings: View {
|
||||||
private enum RemoteStatus: Equatable {
|
private enum RemoteStatus: Equatable {
|
||||||
case idle
|
case idle
|
||||||
case checking
|
case checking
|
||||||
case ok
|
case ok(RemoteGatewayProbeSuccess)
|
||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -558,114 +566,14 @@ extension GeneralSettings {
|
||||||
@MainActor
|
@MainActor
|
||||||
func testRemote() async {
|
func testRemote() async {
|
||||||
self.remoteStatus = .checking
|
self.remoteStatus = .checking
|
||||||
let settings = CommandResolver.connectionSettings()
|
switch await RemoteGatewayProbe.run() {
|
||||||
if self.state.remoteTransport == .direct {
|
case let .ready(success):
|
||||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
self.remoteStatus = .ok(success)
|
||||||
guard !trimmedUrl.isEmpty else {
|
case let .authIssue(issue):
|
||||||
self.remoteStatus = .failed("Set a gateway URL first")
|
self.remoteStatus = .failed(issue.statusMessage)
|
||||||
return
|
case let .failed(message):
|
||||||
}
|
self.remoteStatus = .failed(message)
|
||||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
|
||||||
self.remoteStatus = .failed(
|
|
||||||
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
guard !settings.target.isEmpty else {
|
|
||||||
self.remoteStatus = .failed("Set an SSH target first")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: basic SSH reachability check
|
|
||||||
guard let sshCommand = Self.sshCheckCommand(
|
|
||||||
target: settings.target,
|
|
||||||
identity: settings.identity)
|
|
||||||
else {
|
|
||||||
self.remoteStatus = .failed("SSH target is invalid")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let sshResult = await ShellExecutor.run(
|
|
||||||
command: sshCommand,
|
|
||||||
cwd: nil,
|
|
||||||
env: nil,
|
|
||||||
timeout: 8)
|
|
||||||
|
|
||||||
guard sshResult.ok else {
|
|
||||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: control channel health check
|
|
||||||
let originalMode = AppStateStore.shared.connectionMode
|
|
||||||
do {
|
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
|
||||||
target: settings.target,
|
|
||||||
identity: settings.identity))
|
|
||||||
let data = try await ControlChannel.shared.health(timeout: 10)
|
|
||||||
if decodeHealthSnapshot(from: data) != nil {
|
|
||||||
self.remoteStatus = .ok
|
|
||||||
} else {
|
|
||||||
self.remoteStatus = .failed("Control channel returned invalid health JSON")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
self.remoteStatus = .failed(error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore original mode if we temporarily switched
|
|
||||||
switch originalMode {
|
|
||||||
case .remote:
|
|
||||||
break
|
|
||||||
case .local:
|
|
||||||
try? await ControlChannel.shared.configure(mode: .local)
|
|
||||||
case .unconfigured:
|
|
||||||
await ControlChannel.shared.disconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
|
||||||
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
|
||||||
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
|
||||||
let options = [
|
|
||||||
"-o", "BatchMode=yes",
|
|
||||||
"-o", "ConnectTimeout=5",
|
|
||||||
"-o", "StrictHostKeyChecking=accept-new",
|
|
||||||
"-o", "UpdateHostKeys=yes",
|
|
||||||
]
|
|
||||||
let args = CommandResolver.sshArguments(
|
|
||||||
target: parsed,
|
|
||||||
identity: identity,
|
|
||||||
options: options,
|
|
||||||
remoteCommand: ["echo", "ok"])
|
|
||||||
return ["/usr/bin/ssh"] + args
|
|
||||||
}
|
|
||||||
|
|
||||||
private func formatSSHFailure(_ response: Response, target: String) -> String {
|
|
||||||
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
|
|
||||||
let trimmed = payload?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
.split(whereSeparator: \.isNewline)
|
|
||||||
.joined(separator: " ")
|
|
||||||
if let trimmed,
|
|
||||||
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
|
||||||
{
|
|
||||||
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
|
||||||
return "SSH check failed: Host key verification failed. Remove the old key with " +
|
|
||||||
"`ssh-keygen -R \(host)` and try again."
|
|
||||||
}
|
|
||||||
if let trimmed, !trimmed.isEmpty {
|
|
||||||
if let message = response.message, message.hasPrefix("exit ") {
|
|
||||||
return "SSH check failed: \(trimmed) (\(message))"
|
|
||||||
}
|
|
||||||
return "SSH check failed: \(trimmed)"
|
|
||||||
}
|
|
||||||
if let message = response.message {
|
|
||||||
return "SSH check failed (\(message))"
|
|
||||||
}
|
|
||||||
return "SSH check failed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func revealLogs() {
|
private func revealLogs() {
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ enum HostEnvSecurityPolicy {
|
||||||
"BASH_ENV",
|
"BASH_ENV",
|
||||||
"ENV",
|
"ENV",
|
||||||
"GIT_EXTERNAL_DIFF",
|
"GIT_EXTERNAL_DIFF",
|
||||||
|
"GIT_EXEC_PATH",
|
||||||
"SHELL",
|
"SHELL",
|
||||||
"SHELLOPTS",
|
"SHELLOPTS",
|
||||||
"PS4",
|
"PS4",
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,8 @@ actor MacNodeBrowserProxy {
|
||||||
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
|
request.setValue(password, forHTTPHeaderField: "x-openclaw-password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if method != "GET", let body = params.body?.value {
|
if method != "GET", let body = params.body {
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.fragmentsAllowed])
|
request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ final class MacNodeModeCoordinator {
|
||||||
try await self.session.connect(
|
try await self.session.connect(
|
||||||
url: config.url,
|
url: config.url,
|
||||||
token: config.token,
|
token: config.token,
|
||||||
|
bootstrapToken: nil,
|
||||||
password: config.password,
|
password: config.password,
|
||||||
connectOptions: connectOptions,
|
connectOptions: connectOptions,
|
||||||
sessionBox: sessionBox,
|
sessionBox: sessionBox,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,13 @@ enum UIStrings {
|
||||||
static let welcomeTitle = "Welcome to OpenClaw"
|
static let welcomeTitle = "Welcome to OpenClaw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum RemoteOnboardingProbeState: Equatable {
|
||||||
|
case idle
|
||||||
|
case checking
|
||||||
|
case ok(RemoteGatewayProbeSuccess)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class OnboardingController {
|
final class OnboardingController {
|
||||||
static let shared = OnboardingController()
|
static let shared = OnboardingController()
|
||||||
|
|
@ -72,6 +79,9 @@ struct OnboardingView: View {
|
||||||
@State var didAutoKickoff = false
|
@State var didAutoKickoff = false
|
||||||
@State var showAdvancedConnection = false
|
@State var showAdvancedConnection = false
|
||||||
@State var preferredGatewayID: String?
|
@State var preferredGatewayID: String?
|
||||||
|
@State var remoteProbeState: RemoteOnboardingProbeState = .idle
|
||||||
|
@State var remoteAuthIssue: RemoteGatewayAuthIssue?
|
||||||
|
@State var suppressRemoteProbeReset = false
|
||||||
@State var gatewayDiscovery: GatewayDiscoveryModel
|
@State var gatewayDiscovery: GatewayDiscoveryModel
|
||||||
@State var onboardingChatModel: OpenClawChatViewModel
|
@State var onboardingChatModel: OpenClawChatViewModel
|
||||||
@State var onboardingSkillsModel = SkillsSettingsModel()
|
@State var onboardingSkillsModel = SkillsSettingsModel()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import AppKit
|
||||||
import OpenClawChatUI
|
import OpenClawChatUI
|
||||||
import OpenClawDiscovery
|
import OpenClawDiscovery
|
||||||
import OpenClawIPC
|
import OpenClawIPC
|
||||||
|
import OpenClawKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension OnboardingView {
|
extension OnboardingView {
|
||||||
|
|
@ -97,6 +98,11 @@ extension OnboardingView {
|
||||||
|
|
||||||
self.gatewayDiscoverySection()
|
self.gatewayDiscoverySection()
|
||||||
|
|
||||||
|
if self.shouldShowRemoteConnectionSection {
|
||||||
|
Divider().padding(.vertical, 4)
|
||||||
|
self.remoteConnectionSection()
|
||||||
|
}
|
||||||
|
|
||||||
self.connectionChoiceButton(
|
self.connectionChoiceButton(
|
||||||
title: "Configure later",
|
title: "Configure later",
|
||||||
subtitle: "Don’t start the Gateway yet.",
|
subtitle: "Don’t start the Gateway yet.",
|
||||||
|
|
@ -109,6 +115,22 @@ extension OnboardingView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.state.connectionMode) { _, newValue in
|
||||||
|
guard Self.shouldResetRemoteProbeFeedback(
|
||||||
|
for: newValue,
|
||||||
|
suppressReset: self.suppressRemoteProbeReset)
|
||||||
|
else { return }
|
||||||
|
self.resetRemoteProbeFeedback()
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.remoteTransport) { _, _ in
|
||||||
|
self.resetRemoteProbeFeedback()
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.remoteTarget) { _, _ in
|
||||||
|
self.resetRemoteProbeFeedback()
|
||||||
|
}
|
||||||
|
.onChange(of: self.state.remoteUrl) { _, _ in
|
||||||
|
self.resetRemoteProbeFeedback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var localGatewaySubtitle: String {
|
private var localGatewaySubtitle: String {
|
||||||
|
|
@ -199,25 +221,6 @@ extension OnboardingView {
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: fieldWidth)
|
.frame(width: fieldWidth)
|
||||||
}
|
}
|
||||||
GridRow {
|
|
||||||
Text("Gateway token")
|
|
||||||
.font(.callout.weight(.semibold))
|
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
|
||||||
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.frame(width: fieldWidth)
|
|
||||||
}
|
|
||||||
if self.state.remoteTokenUnsupported {
|
|
||||||
GridRow {
|
|
||||||
Text("")
|
|
||||||
.frame(width: labelWidth, alignment: .leading)
|
|
||||||
Text(
|
|
||||||
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.orange)
|
|
||||||
.frame(width: fieldWidth, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if self.state.remoteTransport == .direct {
|
if self.state.remoteTransport == .direct {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("Gateway URL")
|
Text("Gateway URL")
|
||||||
|
|
@ -289,6 +292,250 @@ extension OnboardingView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var shouldShowRemoteConnectionSection: Bool {
|
||||||
|
self.state.connectionMode == .remote ||
|
||||||
|
self.showAdvancedConnection ||
|
||||||
|
self.remoteProbeState != .idle ||
|
||||||
|
self.remoteAuthIssue != nil ||
|
||||||
|
Self.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: self.showAdvancedConnection,
|
||||||
|
remoteToken: self.state.remoteToken,
|
||||||
|
remoteTokenUnsupported: self.state.remoteTokenUnsupported,
|
||||||
|
authIssue: self.remoteAuthIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowRemoteTokenField: Bool {
|
||||||
|
guard self.shouldShowRemoteConnectionSection else { return false }
|
||||||
|
return Self.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: self.showAdvancedConnection,
|
||||||
|
remoteToken: self.state.remoteToken,
|
||||||
|
remoteTokenUnsupported: self.state.remoteTokenUnsupported,
|
||||||
|
authIssue: self.remoteAuthIssue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remoteProbePreflightMessage: String? {
|
||||||
|
switch self.state.remoteTransport {
|
||||||
|
case .direct:
|
||||||
|
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedUrl.isEmpty {
|
||||||
|
return "Select a nearby gateway or open Advanced to enter a gateway URL."
|
||||||
|
}
|
||||||
|
if GatewayRemoteConfig.normalizeGatewayUrl(trimmedUrl) == nil {
|
||||||
|
return "Gateway URL must use wss:// for remote hosts (ws:// only for localhost)."
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case .ssh:
|
||||||
|
let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmedTarget.isEmpty {
|
||||||
|
return "Select a nearby gateway or open Advanced to enter an SSH target."
|
||||||
|
}
|
||||||
|
return CommandResolver.sshTargetValidationMessage(trimmedTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canProbeRemoteConnection: Bool {
|
||||||
|
self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func remoteConnectionSection() -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Remote connection")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
Text("Checks the real remote websocket and auth handshake.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Button {
|
||||||
|
Task { await self.probeRemoteConnection() }
|
||||||
|
} label: {
|
||||||
|
if self.remoteProbeState == .checking {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.frame(minWidth: 120)
|
||||||
|
} else {
|
||||||
|
Text("Check connection")
|
||||||
|
.frame(minWidth: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!self.canProbeRemoteConnection)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.shouldShowRemoteTokenField {
|
||||||
|
self.remoteTokenField()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let message = self.remoteProbePreflightMessage, self.remoteProbeState != .checking {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.remoteProbeStatusView()
|
||||||
|
|
||||||
|
if let issue = self.remoteAuthIssue {
|
||||||
|
self.remoteAuthPromptView(issue: issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remoteTokenField() -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text("Gateway token")
|
||||||
|
.font(.callout.weight(.semibold))
|
||||||
|
.frame(width: 110, alignment: .leading)
|
||||||
|
SecureField("remote gateway auth token (gateway.remote.token)", text: self.$state.remoteToken)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(maxWidth: 320)
|
||||||
|
}
|
||||||
|
Text("Used when the remote gateway requires token auth.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if self.state.remoteTokenUnsupported {
|
||||||
|
Text(
|
||||||
|
"The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func remoteProbeStatusView() -> some View {
|
||||||
|
switch self.remoteProbeState {
|
||||||
|
case .idle:
|
||||||
|
EmptyView()
|
||||||
|
case .checking:
|
||||||
|
Text("Checking remote gateway…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
case let .ok(success):
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Label(success.title, systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
if let detail = success.detail {
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .failed(message):
|
||||||
|
if self.remoteAuthIssue == nil {
|
||||||
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View {
|
||||||
|
let promptStyle = Self.remoteAuthPromptStyle(for: issue)
|
||||||
|
return HStack(alignment: .top, spacing: 10) {
|
||||||
|
Image(systemName: promptStyle.systemImage)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(promptStyle.tint)
|
||||||
|
.frame(width: 16, alignment: .center)
|
||||||
|
.padding(.top, 1)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(issue.title)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
Text(.init(issue.body))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let footnote = issue.footnote {
|
||||||
|
Text(.init(footnote))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func probeRemoteConnection() async {
|
||||||
|
let originalMode = self.state.connectionMode
|
||||||
|
let shouldRestoreMode = originalMode != .remote
|
||||||
|
if shouldRestoreMode {
|
||||||
|
// Reuse the shared remote endpoint stack for probing without committing the user's mode choice.
|
||||||
|
self.state.connectionMode = .remote
|
||||||
|
}
|
||||||
|
self.remoteProbeState = .checking
|
||||||
|
self.remoteAuthIssue = nil
|
||||||
|
defer {
|
||||||
|
if shouldRestoreMode {
|
||||||
|
self.suppressRemoteProbeReset = true
|
||||||
|
self.state.connectionMode = originalMode
|
||||||
|
self.suppressRemoteProbeReset = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch await RemoteGatewayProbe.run() {
|
||||||
|
case let .ready(success):
|
||||||
|
self.remoteProbeState = .ok(success)
|
||||||
|
case let .authIssue(issue):
|
||||||
|
self.remoteAuthIssue = issue
|
||||||
|
self.remoteProbeState = .failed(issue.statusMessage)
|
||||||
|
case let .failed(message):
|
||||||
|
self.remoteProbeState = .failed(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetRemoteProbeFeedback() {
|
||||||
|
self.remoteProbeState = .idle
|
||||||
|
self.remoteAuthIssue = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func remoteAuthPromptStyle(
|
||||||
|
for issue: RemoteGatewayAuthIssue)
|
||||||
|
-> (systemImage: String, tint: Color)
|
||||||
|
{
|
||||||
|
switch issue {
|
||||||
|
case .tokenRequired:
|
||||||
|
return ("key.fill", .orange)
|
||||||
|
case .tokenMismatch:
|
||||||
|
return ("exclamationmark.triangle.fill", .orange)
|
||||||
|
case .gatewayTokenNotConfigured:
|
||||||
|
return ("wrench.and.screwdriver.fill", .orange)
|
||||||
|
case .setupCodeExpired:
|
||||||
|
return ("qrcode.viewfinder", .orange)
|
||||||
|
case .passwordRequired:
|
||||||
|
return ("lock.slash.fill", .orange)
|
||||||
|
case .pairingRequired:
|
||||||
|
return ("link.badge.plus", .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: Bool,
|
||||||
|
remoteToken: String,
|
||||||
|
remoteTokenUnsupported: Bool,
|
||||||
|
authIssue: RemoteGatewayAuthIssue?) -> Bool
|
||||||
|
{
|
||||||
|
showAdvancedConnection ||
|
||||||
|
remoteTokenUnsupported ||
|
||||||
|
!remoteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
|
||||||
|
authIssue?.showsTokenField == true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldResetRemoteProbeFeedback(
|
||||||
|
for connectionMode: AppState.ConnectionMode,
|
||||||
|
suppressReset: Bool) -> Bool
|
||||||
|
{
|
||||||
|
!suppressReset && connectionMode != .remote
|
||||||
|
}
|
||||||
|
|
||||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
if self.state.remoteTransport == .direct {
|
if self.state.remoteTransport == .direct {
|
||||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import Foundation
|
||||||
|
import OpenClawIPC
|
||||||
|
import OpenClawKit
|
||||||
|
|
||||||
|
enum RemoteGatewayAuthIssue: Equatable {
|
||||||
|
case tokenRequired
|
||||||
|
case tokenMismatch
|
||||||
|
case gatewayTokenNotConfigured
|
||||||
|
case setupCodeExpired
|
||||||
|
case passwordRequired
|
||||||
|
case pairingRequired
|
||||||
|
|
||||||
|
init?(error: Error) {
|
||||||
|
guard let authError = error as? GatewayConnectAuthError else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch authError.detail {
|
||||||
|
case .authTokenMissing:
|
||||||
|
self = .tokenRequired
|
||||||
|
case .authTokenMismatch:
|
||||||
|
self = .tokenMismatch
|
||||||
|
case .authTokenNotConfigured:
|
||||||
|
self = .gatewayTokenNotConfigured
|
||||||
|
case .authBootstrapTokenInvalid:
|
||||||
|
self = .setupCodeExpired
|
||||||
|
case .authPasswordMissing, .authPasswordMismatch, .authPasswordNotConfigured:
|
||||||
|
self = .passwordRequired
|
||||||
|
case .pairingRequired:
|
||||||
|
self = .pairingRequired
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var showsTokenField: Bool {
|
||||||
|
switch self {
|
||||||
|
case .tokenRequired, .tokenMismatch:
|
||||||
|
true
|
||||||
|
case .gatewayTokenNotConfigured, .setupCodeExpired, .passwordRequired, .pairingRequired:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .tokenRequired:
|
||||||
|
"This gateway requires an auth token"
|
||||||
|
case .tokenMismatch:
|
||||||
|
"That token did not match the gateway"
|
||||||
|
case .gatewayTokenNotConfigured:
|
||||||
|
"This gateway host needs token setup"
|
||||||
|
case .setupCodeExpired:
|
||||||
|
"This setup code is no longer valid"
|
||||||
|
case .passwordRequired:
|
||||||
|
"This gateway is using unsupported auth"
|
||||||
|
case .pairingRequired:
|
||||||
|
"This device needs pairing approval"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: String {
|
||||||
|
switch self {
|
||||||
|
case .tokenRequired:
|
||||||
|
"Paste the token configured on the gateway host. On the gateway host, run `openclaw config get gateway.auth.token`. If the gateway uses an environment variable instead, use `OPENCLAW_GATEWAY_TOKEN`."
|
||||||
|
case .tokenMismatch:
|
||||||
|
"Check `gateway.auth.token` or `OPENCLAW_GATEWAY_TOKEN` on the gateway host and try again."
|
||||||
|
case .gatewayTokenNotConfigured:
|
||||||
|
"This gateway is set to token auth, but no `gateway.auth.token` is configured on the gateway host. If the gateway uses an environment variable instead, set `OPENCLAW_GATEWAY_TOKEN` before starting the gateway."
|
||||||
|
case .setupCodeExpired:
|
||||||
|
"Scan or paste a fresh setup code from an already-paired OpenClaw client, then try again."
|
||||||
|
case .passwordRequired:
|
||||||
|
"This onboarding flow does not support password auth yet. Reconfigure the gateway to use token auth, then retry."
|
||||||
|
case .pairingRequired:
|
||||||
|
"Approve this device from an already-paired OpenClaw client. In your OpenClaw chat, run `/pair approve`, then click **Check connection** again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var footnote: String? {
|
||||||
|
switch self {
|
||||||
|
case .tokenRequired, .gatewayTokenNotConfigured:
|
||||||
|
"No token yet? Generate one on the gateway host with `openclaw doctor --generate-gateway-token`, then set it as `gateway.auth.token`."
|
||||||
|
case .setupCodeExpired:
|
||||||
|
nil
|
||||||
|
case .pairingRequired:
|
||||||
|
"If you do not have another paired OpenClaw client yet, approve the pending request on the gateway host with `openclaw devices approve`."
|
||||||
|
case .tokenMismatch, .passwordRequired:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusMessage: String {
|
||||||
|
switch self {
|
||||||
|
case .tokenRequired:
|
||||||
|
"This gateway requires an auth token from the gateway host."
|
||||||
|
case .tokenMismatch:
|
||||||
|
"Gateway token mismatch. Check gateway.auth.token or OPENCLAW_GATEWAY_TOKEN on the gateway host."
|
||||||
|
case .gatewayTokenNotConfigured:
|
||||||
|
"This gateway has token auth enabled, but no gateway.auth.token is configured on the host."
|
||||||
|
case .setupCodeExpired:
|
||||||
|
"Setup code expired or already used. Scan a fresh setup code, then try again."
|
||||||
|
case .passwordRequired:
|
||||||
|
"This gateway uses password auth. Remote onboarding on macOS cannot collect gateway passwords yet."
|
||||||
|
case .pairingRequired:
|
||||||
|
"Pairing required. In an already-paired OpenClaw client, run /pair approve, then check the connection again."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RemoteGatewayProbeResult: Equatable {
|
||||||
|
case ready(RemoteGatewayProbeSuccess)
|
||||||
|
case authIssue(RemoteGatewayAuthIssue)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RemoteGatewayProbeSuccess: Equatable {
|
||||||
|
let authSource: GatewayAuthSource?
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self.authSource {
|
||||||
|
case .some(.deviceToken):
|
||||||
|
"Connected via paired device"
|
||||||
|
case .some(.bootstrapToken):
|
||||||
|
"Connected with setup code"
|
||||||
|
case .some(.sharedToken):
|
||||||
|
"Connected with gateway token"
|
||||||
|
case .some(.password):
|
||||||
|
"Connected with password"
|
||||||
|
case .some(GatewayAuthSource.none), nil:
|
||||||
|
"Remote gateway ready"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var detail: String? {
|
||||||
|
switch self.authSource {
|
||||||
|
case .some(.deviceToken):
|
||||||
|
"This Mac used a stored device token. New or unpaired devices may still need the gateway token."
|
||||||
|
case .some(.bootstrapToken):
|
||||||
|
"This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth."
|
||||||
|
case .some(.sharedToken), .some(.password), .some(GatewayAuthSource.none), nil:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RemoteGatewayProbe {
|
||||||
|
@MainActor
|
||||||
|
static func run() async -> RemoteGatewayProbeResult {
|
||||||
|
AppStateStore.shared.syncGatewayConfigNow()
|
||||||
|
let settings = CommandResolver.connectionSettings()
|
||||||
|
let transport = AppStateStore.shared.remoteTransport
|
||||||
|
|
||||||
|
if transport == .direct {
|
||||||
|
let trimmedUrl = AppStateStore.shared.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedUrl.isEmpty else {
|
||||||
|
return .failed("Set a gateway URL first")
|
||||||
|
}
|
||||||
|
guard self.isValidWsUrl(trimmedUrl) else {
|
||||||
|
return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedTarget.isEmpty else {
|
||||||
|
return .failed("Set an SSH target first")
|
||||||
|
}
|
||||||
|
if let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget) {
|
||||||
|
return .failed(validationMessage)
|
||||||
|
}
|
||||||
|
guard let sshCommand = self.sshCheckCommand(target: settings.target, identity: settings.identity) else {
|
||||||
|
return .failed("SSH target is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
let sshResult = await ShellExecutor.run(
|
||||||
|
command: sshCommand,
|
||||||
|
cwd: nil,
|
||||||
|
env: nil,
|
||||||
|
timeout: 8)
|
||||||
|
guard sshResult.ok else {
|
||||||
|
return .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await GatewayConnection.shared.healthSnapshot(timeoutMs: 10_000)
|
||||||
|
let authSource = await GatewayConnection.shared.authSource()
|
||||||
|
return .ready(RemoteGatewayProbeSuccess(authSource: authSource))
|
||||||
|
} catch {
|
||||||
|
if let authIssue = RemoteGatewayAuthIssue(error: error) {
|
||||||
|
return .authIssue(authIssue)
|
||||||
|
}
|
||||||
|
return .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||||
|
GatewayRemoteConfig.normalizeGatewayUrl(raw) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sshCheckCommand(target: String, identity: String) -> [String]? {
|
||||||
|
guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
|
||||||
|
let options = [
|
||||||
|
"-o", "BatchMode=yes",
|
||||||
|
"-o", "ConnectTimeout=5",
|
||||||
|
"-o", "StrictHostKeyChecking=accept-new",
|
||||||
|
"-o", "UpdateHostKeys=yes",
|
||||||
|
]
|
||||||
|
let args = CommandResolver.sshArguments(
|
||||||
|
target: parsed,
|
||||||
|
identity: identity,
|
||||||
|
options: options,
|
||||||
|
remoteCommand: ["echo", "ok"])
|
||||||
|
return ["/usr/bin/ssh"] + args
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formatSSHFailure(_ response: Response, target: String) -> String {
|
||||||
|
let payload = response.payload.flatMap { String(data: $0, encoding: .utf8) }
|
||||||
|
let trimmed = payload?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.joined(separator: " ")
|
||||||
|
if let trimmed,
|
||||||
|
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
||||||
|
{
|
||||||
|
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
||||||
|
return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
|
||||||
|
}
|
||||||
|
if let trimmed, !trimmed.isEmpty {
|
||||||
|
if let message = response.message, message.hasPrefix("exit ") {
|
||||||
|
return "SSH check failed: \(trimmed) (\(message))"
|
||||||
|
}
|
||||||
|
return "SSH check failed: \(trimmed)"
|
||||||
|
}
|
||||||
|
if let message = response.message {
|
||||||
|
return "SSH check failed (\(message))"
|
||||||
|
}
|
||||||
|
return "SSH check failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,9 +15,9 @@
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2026.3.9</string>
|
<string>2026.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>202603080</string>
|
<string>202603130</string>
|
||||||
<key>CFBundleIconFile</key>
|
<key>CFBundleIconFile</key>
|
||||||
<string>OpenClaw</string>
|
<string>OpenClaw</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
|
|
@ -59,6 +59,8 @@
|
||||||
<string>OpenClaw uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
<string>OpenClaw uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||||
<key>NSAppleEventsUsageDescription</key>
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
<string>OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
<string>OpenClaw needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
||||||
|
<key>NSRemindersUsageDescription</key>
|
||||||
|
<string>OpenClaw can access Reminders when requested by the agent for the apple-reminders skill.</string>
|
||||||
|
|
||||||
<key>NSAppTransportSecurity</key>
|
<key>NSAppTransportSecurity</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,23 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||||
method: "sessions.list",
|
method: "sessions.list",
|
||||||
params: params,
|
params: params,
|
||||||
timeoutMs: 15000)
|
timeoutMs: 15000)
|
||||||
return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data)
|
||||||
|
let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey()
|
||||||
|
let defaults = decoded.defaults.map {
|
||||||
|
OpenClawChatSessionsDefaults(
|
||||||
|
model: $0.model,
|
||||||
|
contextTokens: $0.contextTokens,
|
||||||
|
mainSessionKey: mainSessionKey)
|
||||||
|
} ?? OpenClawChatSessionsDefaults(
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil,
|
||||||
|
mainSessionKey: mainSessionKey)
|
||||||
|
return OpenClawChatSessionsListResponse(
|
||||||
|
ts: decoded.ts,
|
||||||
|
path: decoded.path,
|
||||||
|
count: decoded.count,
|
||||||
|
defaults: defaults,
|
||||||
|
sessions: decoded.sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionModel(sessionKey: String, model: String?) async throws {
|
func setSessionModel(sessionKey: String, model: String?) async throws {
|
||||||
|
|
@ -103,6 +119,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
|
||||||
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetSession(sessionKey: String) async throws {
|
||||||
|
_ = try await GatewayConnection.shared.request(
|
||||||
|
method: "sessions.reset",
|
||||||
|
params: ["key": AnyCodable(sessionKey)],
|
||||||
|
timeoutMs: 10000)
|
||||||
|
}
|
||||||
|
|
||||||
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
func events() -> AsyncStream<OpenClawChatTransportEvent> {
|
||||||
AsyncStream { continuation in
|
AsyncStream { continuation in
|
||||||
let task = Task {
|
let task = Task {
|
||||||
|
|
|
||||||
|
|
@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
public let inputprovenance: [String: AnyCodable]?
|
public let inputprovenance: [String: AnyCodable]?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
|
||||||
public let workspacedir: String?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
message: String,
|
message: String,
|
||||||
|
|
@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
|
||||||
internalevents: [[String: AnyCodable]]?,
|
internalevents: [[String: AnyCodable]]?,
|
||||||
inputprovenance: [String: AnyCodable]?,
|
inputprovenance: [String: AnyCodable]?,
|
||||||
idempotencykey: String,
|
idempotencykey: String,
|
||||||
label: String?,
|
label: String?)
|
||||||
spawnedby: String?,
|
|
||||||
workspacedir: String?)
|
|
||||||
{
|
{
|
||||||
self.message = message
|
self.message = message
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
|
@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
self.inputprovenance = inputprovenance
|
self.inputprovenance = inputprovenance
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
|
||||||
self.workspacedir = workspacedir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
case inputprovenance = "inputProvenance"
|
case inputprovenance = "inputProvenance"
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
|
||||||
case workspacedir = "workspaceDir"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
public let tokensuffix: String
|
public let tokensuffix: String
|
||||||
public let topic: String
|
public let topic: String
|
||||||
public let environment: String
|
public let environment: String
|
||||||
|
public let transport: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ok: Bool,
|
ok: Bool,
|
||||||
|
|
@ -1122,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
reason: String?,
|
reason: String?,
|
||||||
tokensuffix: String,
|
tokensuffix: String,
|
||||||
topic: String,
|
topic: String,
|
||||||
environment: String)
|
environment: String,
|
||||||
|
transport: String)
|
||||||
{
|
{
|
||||||
self.ok = ok
|
self.ok = ok
|
||||||
self.status = status
|
self.status = status
|
||||||
|
|
@ -1131,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
self.tokensuffix = tokensuffix
|
self.tokensuffix = tokensuffix
|
||||||
self.topic = topic
|
self.topic = topic
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
self.transport = transport
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
@ -1141,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
case tokensuffix = "tokenSuffix"
|
case tokensuffix = "tokenSuffix"
|
||||||
case topic
|
case topic
|
||||||
case environment
|
case environment
|
||||||
|
case transport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1326,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let label: AnyCodable?
|
public let label: AnyCodable?
|
||||||
public let thinkinglevel: AnyCodable?
|
public let thinkinglevel: AnyCodable?
|
||||||
|
public let fastmode: AnyCodable?
|
||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
public let reasoninglevel: AnyCodable?
|
public let reasoninglevel: AnyCodable?
|
||||||
public let responseusage: AnyCodable?
|
public let responseusage: AnyCodable?
|
||||||
|
|
@ -1336,6 +1333,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let execnode: AnyCodable?
|
public let execnode: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let spawnedby: AnyCodable?
|
public let spawnedby: AnyCodable?
|
||||||
|
public let spawnedworkspacedir: AnyCodable?
|
||||||
public let spawndepth: AnyCodable?
|
public let spawndepth: AnyCodable?
|
||||||
public let subagentrole: AnyCodable?
|
public let subagentrole: AnyCodable?
|
||||||
public let subagentcontrolscope: AnyCodable?
|
public let subagentcontrolscope: AnyCodable?
|
||||||
|
|
@ -1346,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
key: String,
|
key: String,
|
||||||
label: AnyCodable?,
|
label: AnyCodable?,
|
||||||
thinkinglevel: AnyCodable?,
|
thinkinglevel: AnyCodable?,
|
||||||
|
fastmode: AnyCodable?,
|
||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
reasoninglevel: AnyCodable?,
|
reasoninglevel: AnyCodable?,
|
||||||
responseusage: AnyCodable?,
|
responseusage: AnyCodable?,
|
||||||
|
|
@ -1356,6 +1355,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
execnode: AnyCodable?,
|
execnode: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
spawnedby: AnyCodable?,
|
spawnedby: AnyCodable?,
|
||||||
|
spawnedworkspacedir: AnyCodable?,
|
||||||
spawndepth: AnyCodable?,
|
spawndepth: AnyCodable?,
|
||||||
subagentrole: AnyCodable?,
|
subagentrole: AnyCodable?,
|
||||||
subagentcontrolscope: AnyCodable?,
|
subagentcontrolscope: AnyCodable?,
|
||||||
|
|
@ -1365,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.label = label
|
self.label = label
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
|
self.fastmode = fastmode
|
||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
self.reasoninglevel = reasoninglevel
|
self.reasoninglevel = reasoninglevel
|
||||||
self.responseusage = responseusage
|
self.responseusage = responseusage
|
||||||
|
|
@ -1375,6 +1376,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
self.execnode = execnode
|
self.execnode = execnode
|
||||||
self.model = model
|
self.model = model
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
|
self.spawnedworkspacedir = spawnedworkspacedir
|
||||||
self.spawndepth = spawndepth
|
self.spawndepth = spawndepth
|
||||||
self.subagentrole = subagentrole
|
self.subagentrole = subagentrole
|
||||||
self.subagentcontrolscope = subagentcontrolscope
|
self.subagentcontrolscope = subagentcontrolscope
|
||||||
|
|
@ -1386,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
case key
|
case key
|
||||||
case label
|
case label
|
||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
|
case fastmode = "fastMode"
|
||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
case reasoninglevel = "reasoningLevel"
|
case reasoninglevel = "reasoningLevel"
|
||||||
case responseusage = "responseUsage"
|
case responseusage = "responseUsage"
|
||||||
|
|
@ -1396,6 +1399,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
case execnode = "execNode"
|
case execnode = "execNode"
|
||||||
case model
|
case model
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
|
case spawnedworkspacedir = "spawnedWorkspaceDir"
|
||||||
case spawndepth = "spawnDepth"
|
case spawndepth = "spawnDepth"
|
||||||
case subagentrole = "subagentRole"
|
case subagentrole = "subagentRole"
|
||||||
case subagentcontrolscope = "subagentControlScope"
|
case subagentcontrolscope = "subagentControlScope"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ struct GatewayChannelConnectTests {
|
||||||
private enum FakeResponse {
|
private enum FakeResponse {
|
||||||
case helloOk(delayMs: Int)
|
case helloOk(delayMs: Int)
|
||||||
case invalid(delayMs: Int)
|
case invalid(delayMs: Int)
|
||||||
|
case authFailed(
|
||||||
|
delayMs: Int,
|
||||||
|
detailCode: String,
|
||||||
|
canRetryWithDeviceToken: Bool,
|
||||||
|
recommendedNextStep: String?)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
|
private func makeSession(response: FakeResponse) -> GatewayTestWebSocketSession {
|
||||||
|
|
@ -27,6 +32,14 @@ struct GatewayChannelConnectTests {
|
||||||
case let .invalid(ms):
|
case let .invalid(ms):
|
||||||
delayMs = ms
|
delayMs = ms
|
||||||
message = .string("not json")
|
message = .string("not json")
|
||||||
|
case let .authFailed(ms, detailCode, canRetryWithDeviceToken, recommendedNextStep):
|
||||||
|
delayMs = ms
|
||||||
|
let id = task.snapshotConnectRequestID() ?? "connect"
|
||||||
|
message = .data(GatewayWebSocketTestSupport.connectAuthFailureData(
|
||||||
|
id: id,
|
||||||
|
detailCode: detailCode,
|
||||||
|
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||||
|
recommendedNextStep: recommendedNextStep))
|
||||||
}
|
}
|
||||||
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
try await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
|
||||||
return message
|
return message
|
||||||
|
|
@ -71,4 +84,29 @@ struct GatewayChannelConnectTests {
|
||||||
}())
|
}())
|
||||||
#expect(session.snapshotMakeCount() == 1)
|
#expect(session.snapshotMakeCount() == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func `connect surfaces structured auth failure`() async throws {
|
||||||
|
let session = self.makeSession(response: .authFailed(
|
||||||
|
delayMs: 0,
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||||
|
canRetryWithDeviceToken: true,
|
||||||
|
recommendedNextStep: GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue))
|
||||||
|
let channel = try GatewayChannelActor(
|
||||||
|
url: #require(URL(string: "ws://example.invalid")),
|
||||||
|
token: nil,
|
||||||
|
session: WebSocketSessionBox(session: session))
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await channel.connect()
|
||||||
|
Issue.record("expected GatewayConnectAuthError")
|
||||||
|
} catch let error as GatewayConnectAuthError {
|
||||||
|
#expect(error.detail == .authTokenMissing)
|
||||||
|
#expect(error.detailCode == GatewayConnectAuthDetailCode.authTokenMissing.rawValue)
|
||||||
|
#expect(error.canRetryWithDeviceToken)
|
||||||
|
#expect(error.recommendedNextStep == .updateAuthConfiguration)
|
||||||
|
#expect(error.recommendedNextStepCode == GatewayConnectRecoveryNextStep.updateAuthConfiguration.rawValue)
|
||||||
|
} catch {
|
||||||
|
Issue.record("unexpected error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,40 @@ enum GatewayWebSocketTestSupport {
|
||||||
return Data(json.utf8)
|
return Data(json.utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func connectAuthFailureData(
|
||||||
|
id: String,
|
||||||
|
detailCode: String,
|
||||||
|
message: String = "gateway auth rejected",
|
||||||
|
canRetryWithDeviceToken: Bool = false,
|
||||||
|
recommendedNextStep: String? = nil) -> Data
|
||||||
|
{
|
||||||
|
let recommendedNextStepJson: String
|
||||||
|
if let recommendedNextStep {
|
||||||
|
recommendedNextStepJson = """
|
||||||
|
,
|
||||||
|
"recommendedNextStep": "\(recommendedNextStep)"
|
||||||
|
"""
|
||||||
|
} else {
|
||||||
|
recommendedNextStepJson = ""
|
||||||
|
}
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"type": "res",
|
||||||
|
"id": "\(id)",
|
||||||
|
"ok": false,
|
||||||
|
"error": {
|
||||||
|
"message": "\(message)",
|
||||||
|
"details": {
|
||||||
|
"code": "\(detailCode)",
|
||||||
|
"canRetryWithDeviceToken": \(canRetryWithDeviceToken ? "true" : "false")
|
||||||
|
\(recommendedNextStepJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
return Data(json.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
static func requestID(from message: URLSessionWebSocketTask.Message) -> String? {
|
||||||
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
guard let obj = self.requestFrameObject(from: message) else { return nil }
|
||||||
guard (obj["type"] as? String) == "req" else {
|
guard (obj["type"] as? String) == "req" else {
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,49 @@ struct MacNodeBrowserProxyTests {
|
||||||
#expect(tabs.count == 1)
|
#expect(tabs.count == 1)
|
||||||
#expect(tabs[0]["id"] as? String == "tab-1")
|
#expect(tabs[0]["id"] as? String == "tab-1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression test: nested POST bodies must serialize without __SwiftValue crashes.
|
||||||
|
@Test func postRequestSerializesNestedBodyWithoutCrash() async throws {
|
||||||
|
actor BodyCapture {
|
||||||
|
private var body: Data?
|
||||||
|
|
||||||
|
func set(_ body: Data?) {
|
||||||
|
self.body = body
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> Data? {
|
||||||
|
self.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let capturedBody = BodyCapture()
|
||||||
|
let proxy = MacNodeBrowserProxy(
|
||||||
|
endpointProvider: {
|
||||||
|
MacNodeBrowserProxy.Endpoint(
|
||||||
|
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||||
|
token: nil,
|
||||||
|
password: nil)
|
||||||
|
},
|
||||||
|
performRequest: { request in
|
||||||
|
await capturedBody.set(request.httpBody)
|
||||||
|
let url = try #require(request.url)
|
||||||
|
let response = try #require(
|
||||||
|
HTTPURLResponse(
|
||||||
|
url: url,
|
||||||
|
statusCode: 200,
|
||||||
|
httpVersion: nil,
|
||||||
|
headerFields: nil))
|
||||||
|
return (Data(#"{"ok":true}"#.utf8), response)
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = try await proxy.request(
|
||||||
|
paramsJSON: #"{"method":"POST","path":"/action","body":{"nested":{"key":"val"},"arr":[1,2]}}"#)
|
||||||
|
|
||||||
|
let bodyData = try #require(await capturedBody.get())
|
||||||
|
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
||||||
|
let nested = try #require(parsed["nested"] as? [String: Any])
|
||||||
|
#expect(nested["key"] as? String == "val")
|
||||||
|
let arr = try #require(parsed["arr"] as? [Any])
|
||||||
|
#expect(arr.count == 2)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
import OpenClawKit
|
||||||
|
import Testing
|
||||||
|
@testable import OpenClaw
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct OnboardingRemoteAuthPromptTests {
|
||||||
|
@Test func `auth detail codes map to remote auth issues`() {
|
||||||
|
let tokenMissing = GatewayConnectAuthError(
|
||||||
|
message: "token missing",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authTokenMissing.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let tokenMismatch = GatewayConnectAuthError(
|
||||||
|
message: "token mismatch",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authTokenMismatch.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let tokenNotConfigured = GatewayConnectAuthError(
|
||||||
|
message: "token not configured",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let bootstrapInvalid = GatewayConnectAuthError(
|
||||||
|
message: "setup code expired",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let passwordMissing = GatewayConnectAuthError(
|
||||||
|
message: "password missing",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authPasswordMissing.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let pairingRequired = GatewayConnectAuthError(
|
||||||
|
message: "pairing required",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.pairingRequired.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let unknown = GatewayConnectAuthError(
|
||||||
|
message: "other",
|
||||||
|
detailCode: "SOMETHING_ELSE",
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: tokenMissing) == .tokenRequired)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: tokenMismatch) == .tokenMismatch)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: tokenNotConfigured) == .gatewayTokenNotConfigured)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: bootstrapInvalid) == .setupCodeExpired)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: passwordMissing) == .passwordRequired)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: pairingRequired) == .pairingRequired)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: unknown) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `password detail family maps to password required issue`() {
|
||||||
|
let mismatch = GatewayConnectAuthError(
|
||||||
|
message: "password mismatch",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
let notConfigured = GatewayConnectAuthError(
|
||||||
|
message: "password not configured",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: mismatch) == .passwordRequired)
|
||||||
|
#expect(RemoteGatewayAuthIssue(error: notConfigured) == .passwordRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `token field visibility follows onboarding rules`() {
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: nil) == false)
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: true,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: nil))
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "secret",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: nil))
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: true,
|
||||||
|
authIssue: nil))
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .tokenRequired))
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .tokenMismatch))
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .gatewayTokenNotConfigured) == false)
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .setupCodeExpired) == false)
|
||||||
|
#expect(OnboardingView.shouldShowRemoteTokenField(
|
||||||
|
showAdvancedConnection: false,
|
||||||
|
remoteToken: "",
|
||||||
|
remoteTokenUnsupported: false,
|
||||||
|
authIssue: .pairingRequired) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `pairing required copy points users to pair approve`() {
|
||||||
|
let issue = RemoteGatewayAuthIssue.pairingRequired
|
||||||
|
|
||||||
|
#expect(issue.title == "This device needs pairing approval")
|
||||||
|
#expect(issue.body.contains("`/pair approve`"))
|
||||||
|
#expect(issue.statusMessage.contains("/pair approve"))
|
||||||
|
#expect(issue.footnote?.contains("`openclaw devices approve`") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `paired device success copy explains auth source`() {
|
||||||
|
let pairedDevice = RemoteGatewayProbeSuccess(authSource: .deviceToken)
|
||||||
|
let bootstrap = RemoteGatewayProbeSuccess(authSource: .bootstrapToken)
|
||||||
|
let sharedToken = RemoteGatewayProbeSuccess(authSource: .sharedToken)
|
||||||
|
let noAuth = RemoteGatewayProbeSuccess(authSource: GatewayAuthSource.none)
|
||||||
|
|
||||||
|
#expect(pairedDevice.title == "Connected via paired device")
|
||||||
|
#expect(pairedDevice.detail == "This Mac used a stored device token. New or unpaired devices may still need the gateway token.")
|
||||||
|
#expect(bootstrap.title == "Connected with setup code")
|
||||||
|
#expect(bootstrap.detail == "This Mac is still using the temporary setup code. Approve pairing to finish provisioning device-scoped auth.")
|
||||||
|
#expect(sharedToken.title == "Connected with gateway token")
|
||||||
|
#expect(sharedToken.detail == nil)
|
||||||
|
#expect(noAuth.title == "Remote gateway ready")
|
||||||
|
#expect(noAuth.detail == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func `transient probe mode restore does not clear probe feedback`() {
|
||||||
|
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: false))
|
||||||
|
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .unconfigured, suppressReset: false))
|
||||||
|
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .remote, suppressReset: false) == false)
|
||||||
|
#expect(OnboardingView.shouldResetRemoteProbeFeedback(for: .local, suppressReset: true) == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,13 @@ public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable
|
||||||
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
|
public struct OpenClawChatSessionsDefaults: Codable, Sendable {
|
||||||
public let model: String?
|
public let model: String?
|
||||||
public let contextTokens: Int?
|
public let contextTokens: Int?
|
||||||
|
public let mainSessionKey: String?
|
||||||
|
|
||||||
|
public init(model: String?, contextTokens: Int?, mainSessionKey: String? = nil) {
|
||||||
|
self.model = model
|
||||||
|
self.contextTokens = contextTokens
|
||||||
|
self.mainSessionKey = mainSessionKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
|
||||||
|
|
@ -69,4 +76,18 @@ public struct OpenClawChatSessionsListResponse: Codable, Sendable {
|
||||||
public let count: Int?
|
public let count: Int?
|
||||||
public let defaults: OpenClawChatSessionsDefaults?
|
public let defaults: OpenClawChatSessionsDefaults?
|
||||||
public let sessions: [OpenClawChatSessionEntry]
|
public let sessions: [OpenClawChatSessionEntry]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
ts: Double?,
|
||||||
|
path: String?,
|
||||||
|
count: Int?,
|
||||||
|
defaults: OpenClawChatSessionsDefaults?,
|
||||||
|
sessions: [OpenClawChatSessionEntry])
|
||||||
|
{
|
||||||
|
self.ts = ts
|
||||||
|
self.path = path
|
||||||
|
self.count = count
|
||||||
|
self.defaults = defaults
|
||||||
|
self.sessions = sessions
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable {
|
||||||
func events() -> AsyncStream<OpenClawChatTransportEvent>
|
func events() -> AsyncStream<OpenClawChatTransportEvent>
|
||||||
|
|
||||||
func setActiveSessionKey(_ sessionKey: String) async throws
|
func setActiveSessionKey(_ sessionKey: String) async throws
|
||||||
|
func resetSession(sessionKey: String) async throws
|
||||||
}
|
}
|
||||||
|
|
||||||
extension OpenClawChatTransport {
|
extension OpenClawChatTransport {
|
||||||
public func setActiveSessionKey(_: String) async throws {}
|
public func setActiveSessionKey(_: String) async throws {}
|
||||||
|
|
||||||
|
public func resetSession(sessionKey _: String) async throws {
|
||||||
|
throw NSError(
|
||||||
|
domain: "OpenClawChatTransport",
|
||||||
|
code: 0,
|
||||||
|
userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"])
|
||||||
|
}
|
||||||
|
|
||||||
public func abortRun(sessionKey _: String, runId _: String) async throws {
|
public func abortRun(sessionKey _: String, runId _: String) async throws {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "OpenClawChatTransport",
|
domain: "OpenClawChatTransport",
|
||||||
|
|
|
||||||
|
|
@ -138,21 +138,23 @@ public final class OpenClawChatViewModel {
|
||||||
let now = Date().timeIntervalSince1970 * 1000
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
let cutoff = now - (24 * 60 * 60 * 1000)
|
let cutoff = now - (24 * 60 * 60 * 1000)
|
||||||
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
|
||||||
|
let mainSessionKey = self.resolvedMainSessionKey
|
||||||
|
|
||||||
var result: [OpenClawChatSessionEntry] = []
|
var result: [OpenClawChatSessionEntry] = []
|
||||||
var included = Set<String>()
|
var included = Set<String>()
|
||||||
|
|
||||||
// Always show the main session first, even if it hasn't been updated recently.
|
// Always show the resolved main session first, even if it hasn't been updated recently.
|
||||||
if let main = sorted.first(where: { $0.key == "main" }) {
|
if let main = sorted.first(where: { $0.key == mainSessionKey }) {
|
||||||
result.append(main)
|
result.append(main)
|
||||||
included.insert(main.key)
|
included.insert(main.key)
|
||||||
} else {
|
} else {
|
||||||
result.append(self.placeholderSession(key: "main"))
|
result.append(self.placeholderSession(key: mainSessionKey))
|
||||||
included.insert("main")
|
included.insert(mainSessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in sorted {
|
for entry in sorted {
|
||||||
guard !included.contains(entry.key) else { continue }
|
guard !included.contains(entry.key) else { continue }
|
||||||
|
guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue }
|
||||||
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
included.insert(entry.key)
|
included.insert(entry.key)
|
||||||
|
|
@ -169,6 +171,18 @@ public final class OpenClawChatViewModel {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var resolvedMainSessionKey: String {
|
||||||
|
let trimmed = self.sessionDefaults?.mainSessionKey?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isHiddenInternalSession(_ key: String) -> Bool {
|
||||||
|
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding")
|
||||||
|
}
|
||||||
|
|
||||||
public var showsModelPicker: Bool {
|
public var showsModelPicker: Bool {
|
||||||
!self.modelChoices.isEmpty
|
!self.modelChoices.isEmpty
|
||||||
}
|
}
|
||||||
|
|
@ -365,10 +379,19 @@ public final class OpenClawChatViewModel {
|
||||||
return "\(message.role)|\(timestamp)|\(text)"
|
return "\(message.role)|\(timestamp)|\(text)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let resetTriggers: Set<String> = ["/new", "/reset", "/clear"]
|
||||||
|
|
||||||
private func performSend() async {
|
private func performSend() async {
|
||||||
guard !self.isSending else { return }
|
guard !self.isSending else { return }
|
||||||
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
|
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
|
||||||
|
|
||||||
|
if Self.resetTriggers.contains(trimmed.lowercased()) {
|
||||||
|
self.input = ""
|
||||||
|
await self.performReset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let sessionKey = self.sessionKey
|
let sessionKey = self.sessionKey
|
||||||
|
|
||||||
guard self.healthOK else {
|
guard self.healthOK else {
|
||||||
|
|
@ -499,6 +522,22 @@ public final class OpenClawChatViewModel {
|
||||||
await self.bootstrap()
|
await self.bootstrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func performReset() async {
|
||||||
|
self.isLoading = true
|
||||||
|
self.errorText = nil
|
||||||
|
defer { self.isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await self.transport.resetSession(sessionKey: self.sessionKey)
|
||||||
|
} catch {
|
||||||
|
self.errorText = error.localizedDescription
|
||||||
|
chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.bootstrap()
|
||||||
|
}
|
||||||
|
|
||||||
private func performSelectThinkingLevel(_ level: String) async {
|
private func performSelectThinkingLevel(_ level: String) async {
|
||||||
let next = Self.normalizedThinkingLevel(level) ?? "off"
|
let next = Self.normalizedThinkingLevel(level) ?? "off"
|
||||||
guard next != self.thinkingLevel else { return }
|
guard next != self.thinkingLevel else { return }
|
||||||
|
|
@ -549,7 +588,9 @@ public final class OpenClawChatViewModel {
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
model: nextModelRef)
|
model: nextModelRef)
|
||||||
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
|
guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else {
|
||||||
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false)
|
// Keep older successful patches as rollback state, but do not replay
|
||||||
|
// stale UI/session state over a newer in-flight or completed selection.
|
||||||
|
self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
|
self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true)
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,15 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||||
public let host: String
|
public let host: String
|
||||||
public let port: Int
|
public let port: Int
|
||||||
public let tls: Bool
|
public let tls: Bool
|
||||||
|
public let bootstrapToken: String?
|
||||||
public let token: String?
|
public let token: String?
|
||||||
public let password: String?
|
public let password: String?
|
||||||
|
|
||||||
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
|
public init(host: String, port: Int, tls: Bool, bootstrapToken: String?, token: String?, password: String?) {
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
self.tls = tls
|
self.tls = tls
|
||||||
|
self.bootstrapToken = bootstrapToken
|
||||||
self.token = token
|
self.token = token
|
||||||
self.password = password
|
self.password = password
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +27,7 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
|
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, bootstrapToken?, token?, password?}`).
|
||||||
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
||||||
guard let data = Self.decodeBase64Url(code) else { return nil }
|
guard let data = Self.decodeBase64Url(code) else { return nil }
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||||
|
|
@ -41,9 +43,16 @@ public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let port = parsed.port ?? (tls ? 443 : 18789)
|
let port = parsed.port ?? (tls ? 443 : 18789)
|
||||||
|
let bootstrapToken = json["bootstrapToken"] as? String
|
||||||
let token = json["token"] as? String
|
let token = json["token"] as? String
|
||||||
let password = json["password"] as? String
|
let password = json["password"] as? String
|
||||||
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
|
return GatewayConnectDeepLink(
|
||||||
|
host: hostname,
|
||||||
|
port: port,
|
||||||
|
tls: tls,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
|
token: token,
|
||||||
|
password: password)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decodeBase64Url(_ input: String) -> Data? {
|
private static func decodeBase64Url(_ input: String) -> Data? {
|
||||||
|
|
@ -140,6 +149,7 @@ public enum DeepLinkParser {
|
||||||
host: hostParam,
|
host: hostParam,
|
||||||
port: port,
|
port: port,
|
||||||
tls: tls,
|
tls: tls,
|
||||||
|
bootstrapToken: nil,
|
||||||
token: query["token"],
|
token: query["token"],
|
||||||
password: query["password"]))
|
password: query["password"]))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ public struct GatewayConnectOptions: Sendable {
|
||||||
public enum GatewayAuthSource: String, Sendable {
|
public enum GatewayAuthSource: String, Sendable {
|
||||||
case deviceToken = "device-token"
|
case deviceToken = "device-token"
|
||||||
case sharedToken = "shared-token"
|
case sharedToken = "shared-token"
|
||||||
|
case bootstrapToken = "bootstrap-token"
|
||||||
case password = "password"
|
case password = "password"
|
||||||
case none = "none"
|
case none = "none"
|
||||||
}
|
}
|
||||||
|
|
@ -131,39 +132,34 @@ private let defaultOperatorConnectScopes: [String] = [
|
||||||
"operator.pairing",
|
"operator.pairing",
|
||||||
]
|
]
|
||||||
|
|
||||||
private enum GatewayConnectErrorCodes {
|
private extension String {
|
||||||
static let authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
var nilIfEmpty: String? {
|
||||||
static let authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
self.isEmpty ? nil : self
|
||||||
static let authTokenMissing = "AUTH_TOKEN_MISSING"
|
}
|
||||||
static let authPasswordMissing = "AUTH_PASSWORD_MISSING"
|
|
||||||
static let authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
|
|
||||||
static let authRateLimited = "AUTH_RATE_LIMITED"
|
|
||||||
static let pairingRequired = "PAIRING_REQUIRED"
|
|
||||||
static let controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
|
|
||||||
static let deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GatewayConnectAuthError: LocalizedError {
|
private struct SelectedConnectAuth: Sendable {
|
||||||
let message: String
|
let authToken: String?
|
||||||
let detailCode: String?
|
let authBootstrapToken: String?
|
||||||
let canRetryWithDeviceToken: Bool
|
let authDeviceToken: String?
|
||||||
|
let authPassword: String?
|
||||||
|
let signatureToken: String?
|
||||||
|
let storedToken: String?
|
||||||
|
let authSource: GatewayAuthSource
|
||||||
|
}
|
||||||
|
|
||||||
var errorDescription: String? { self.message }
|
private enum GatewayConnectErrorCodes {
|
||||||
|
static let authTokenMismatch = GatewayConnectAuthDetailCode.authTokenMismatch.rawValue
|
||||||
var isNonRecoverable: Bool {
|
static let authDeviceTokenMismatch = GatewayConnectAuthDetailCode.authDeviceTokenMismatch.rawValue
|
||||||
switch self.detailCode {
|
static let authTokenMissing = GatewayConnectAuthDetailCode.authTokenMissing.rawValue
|
||||||
case GatewayConnectErrorCodes.authTokenMissing,
|
static let authTokenNotConfigured = GatewayConnectAuthDetailCode.authTokenNotConfigured.rawValue
|
||||||
GatewayConnectErrorCodes.authPasswordMissing,
|
static let authPasswordMissing = GatewayConnectAuthDetailCode.authPasswordMissing.rawValue
|
||||||
GatewayConnectErrorCodes.authPasswordMismatch,
|
static let authPasswordMismatch = GatewayConnectAuthDetailCode.authPasswordMismatch.rawValue
|
||||||
GatewayConnectErrorCodes.authRateLimited,
|
static let authPasswordNotConfigured = GatewayConnectAuthDetailCode.authPasswordNotConfigured.rawValue
|
||||||
GatewayConnectErrorCodes.pairingRequired,
|
static let authRateLimited = GatewayConnectAuthDetailCode.authRateLimited.rawValue
|
||||||
GatewayConnectErrorCodes.controlUiDeviceIdentityRequired,
|
static let pairingRequired = GatewayConnectAuthDetailCode.pairingRequired.rawValue
|
||||||
GatewayConnectErrorCodes.deviceIdentityRequired:
|
static let controlUiDeviceIdentityRequired = GatewayConnectAuthDetailCode.controlUiDeviceIdentityRequired.rawValue
|
||||||
return true
|
static let deviceIdentityRequired = GatewayConnectAuthDetailCode.deviceIdentityRequired.rawValue
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public actor GatewayChannelActor {
|
public actor GatewayChannelActor {
|
||||||
|
|
@ -175,6 +171,7 @@ public actor GatewayChannelActor {
|
||||||
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
private var connectWaiters: [CheckedContinuation<Void, Error>] = []
|
||||||
private var url: URL
|
private var url: URL
|
||||||
private var token: String?
|
private var token: String?
|
||||||
|
private var bootstrapToken: String?
|
||||||
private var password: String?
|
private var password: String?
|
||||||
private let session: WebSocketSessioning
|
private let session: WebSocketSessioning
|
||||||
private var backoffMs: Double = 500
|
private var backoffMs: Double = 500
|
||||||
|
|
@ -206,6 +203,7 @@ public actor GatewayChannelActor {
|
||||||
public init(
|
public init(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String? = nil,
|
||||||
password: String? = nil,
|
password: String? = nil,
|
||||||
session: WebSocketSessionBox? = nil,
|
session: WebSocketSessionBox? = nil,
|
||||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
||||||
|
|
@ -214,6 +212,7 @@ public actor GatewayChannelActor {
|
||||||
{
|
{
|
||||||
self.url = url
|
self.url = url
|
||||||
self.token = token
|
self.token = token
|
||||||
|
self.bootstrapToken = bootstrapToken
|
||||||
self.password = password
|
self.password = password
|
||||||
self.session = session?.session ?? URLSession(configuration: .default)
|
self.session = session?.session ?? URLSession(configuration: .default)
|
||||||
self.pushHandler = pushHandler
|
self.pushHandler = pushHandler
|
||||||
|
|
@ -278,8 +277,7 @@ public actor GatewayChannelActor {
|
||||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||||
self.reconnectPausedForAuthFailure = true
|
self.reconnectPausedForAuthFailure = true
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway watchdog reconnect paused for non-recoverable auth failure " +
|
"gateway watchdog reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
||||||
"\(error.localizedDescription, privacy: .public)"
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -420,39 +418,24 @@ public actor GatewayChannelActor {
|
||||||
}
|
}
|
||||||
let includeDeviceIdentity = options.includeDeviceIdentity
|
let includeDeviceIdentity = options.includeDeviceIdentity
|
||||||
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
|
||||||
let storedToken =
|
let selectedAuth = self.selectConnectAuth(
|
||||||
(includeDeviceIdentity && identity != nil)
|
role: role,
|
||||||
? DeviceAuthStore.loadToken(deviceId: identity!.deviceId, role: role)?.token
|
includeDeviceIdentity: includeDeviceIdentity,
|
||||||
: nil
|
deviceId: identity?.deviceId)
|
||||||
let shouldUseDeviceRetryToken =
|
if selectedAuth.authDeviceToken != nil && self.pendingDeviceTokenRetry {
|
||||||
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
|
||||||
storedToken != nil && self.token != nil && self.isTrustedDeviceRetryEndpoint()
|
|
||||||
if shouldUseDeviceRetryToken {
|
|
||||||
self.pendingDeviceTokenRetry = false
|
self.pendingDeviceTokenRetry = false
|
||||||
}
|
}
|
||||||
// Keep shared credentials explicit when provided. Device token retry is attached
|
self.lastAuthSource = selectedAuth.authSource
|
||||||
// only on a bounded second attempt after token mismatch.
|
self.logger.info("gateway connect auth=\(selectedAuth.authSource.rawValue, privacy: .public)")
|
||||||
let authToken = self.token ?? (includeDeviceIdentity ? storedToken : nil)
|
if let authToken = selectedAuth.authToken {
|
||||||
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
|
||||||
let authSource: GatewayAuthSource
|
|
||||||
if authDeviceToken != nil || (self.token == nil && storedToken != nil) {
|
|
||||||
authSource = .deviceToken
|
|
||||||
} else if authToken != nil {
|
|
||||||
authSource = .sharedToken
|
|
||||||
} else if self.password != nil {
|
|
||||||
authSource = .password
|
|
||||||
} else {
|
|
||||||
authSource = .none
|
|
||||||
}
|
|
||||||
self.lastAuthSource = authSource
|
|
||||||
self.logger.info("gateway connect auth=\(authSource.rawValue, privacy: .public)")
|
|
||||||
if let authToken {
|
|
||||||
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
|
var auth: [String: ProtoAnyCodable] = ["token": ProtoAnyCodable(authToken)]
|
||||||
if let authDeviceToken {
|
if let authDeviceToken = selectedAuth.authDeviceToken {
|
||||||
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
|
auth["deviceToken"] = ProtoAnyCodable(authDeviceToken)
|
||||||
}
|
}
|
||||||
params["auth"] = ProtoAnyCodable(auth)
|
params["auth"] = ProtoAnyCodable(auth)
|
||||||
} else if let password = self.password {
|
} else if let authBootstrapToken = selectedAuth.authBootstrapToken {
|
||||||
|
params["auth"] = ProtoAnyCodable(["bootstrapToken": ProtoAnyCodable(authBootstrapToken)])
|
||||||
|
} else if let password = selectedAuth.authPassword {
|
||||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
|
@ -465,7 +448,7 @@ public actor GatewayChannelActor {
|
||||||
role: role,
|
role: role,
|
||||||
scopes: scopes,
|
scopes: scopes,
|
||||||
signedAtMs: signedAtMs,
|
signedAtMs: signedAtMs,
|
||||||
token: authToken,
|
token: selectedAuth.signatureToken,
|
||||||
nonce: connectNonce,
|
nonce: connectNonce,
|
||||||
platform: platform,
|
platform: platform,
|
||||||
deviceFamily: InstanceIdentity.deviceFamily)
|
deviceFamily: InstanceIdentity.deviceFamily)
|
||||||
|
|
@ -494,14 +477,14 @@ public actor GatewayChannelActor {
|
||||||
} catch {
|
} catch {
|
||||||
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
|
let shouldRetryWithDeviceToken = self.shouldRetryWithStoredDeviceToken(
|
||||||
error: error,
|
error: error,
|
||||||
explicitGatewayToken: self.token,
|
explicitGatewayToken: self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty,
|
||||||
storedToken: storedToken,
|
storedToken: selectedAuth.storedToken,
|
||||||
attemptedDeviceTokenRetry: authDeviceToken != nil)
|
attemptedDeviceTokenRetry: selectedAuth.authDeviceToken != nil)
|
||||||
if shouldRetryWithDeviceToken {
|
if shouldRetryWithDeviceToken {
|
||||||
self.pendingDeviceTokenRetry = true
|
self.pendingDeviceTokenRetry = true
|
||||||
self.deviceTokenRetryBudgetUsed = true
|
self.deviceTokenRetryBudgetUsed = true
|
||||||
self.backoffMs = min(self.backoffMs, 250)
|
self.backoffMs = min(self.backoffMs, 250)
|
||||||
} else if authDeviceToken != nil,
|
} else if selectedAuth.authDeviceToken != nil,
|
||||||
let identity,
|
let identity,
|
||||||
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
self.shouldClearStoredDeviceTokenAfterRetry(error)
|
||||||
{
|
{
|
||||||
|
|
@ -512,6 +495,50 @@ public actor GatewayChannelActor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func selectConnectAuth(
|
||||||
|
role: String,
|
||||||
|
includeDeviceIdentity: Bool,
|
||||||
|
deviceId: String?
|
||||||
|
) -> SelectedConnectAuth {
|
||||||
|
let explicitToken = self.token?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
let explicitBootstrapToken =
|
||||||
|
self.bootstrapToken?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
|
||||||
|
let storedToken =
|
||||||
|
(includeDeviceIdentity && deviceId != nil)
|
||||||
|
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)?.token
|
||||||
|
: nil
|
||||||
|
let shouldUseDeviceRetryToken =
|
||||||
|
includeDeviceIdentity && self.pendingDeviceTokenRetry &&
|
||||||
|
storedToken != nil && explicitToken != nil && self.isTrustedDeviceRetryEndpoint()
|
||||||
|
let authToken =
|
||||||
|
explicitToken ??
|
||||||
|
(includeDeviceIdentity && explicitPassword == nil &&
|
||||||
|
(explicitBootstrapToken == nil || storedToken != nil) ? storedToken : nil)
|
||||||
|
let authBootstrapToken = authToken == nil ? explicitBootstrapToken : nil
|
||||||
|
let authDeviceToken = shouldUseDeviceRetryToken ? storedToken : nil
|
||||||
|
let authSource: GatewayAuthSource
|
||||||
|
if authDeviceToken != nil || (explicitToken == nil && authToken != nil) {
|
||||||
|
authSource = .deviceToken
|
||||||
|
} else if authToken != nil {
|
||||||
|
authSource = .sharedToken
|
||||||
|
} else if authBootstrapToken != nil {
|
||||||
|
authSource = .bootstrapToken
|
||||||
|
} else if explicitPassword != nil {
|
||||||
|
authSource = .password
|
||||||
|
} else {
|
||||||
|
authSource = .none
|
||||||
|
}
|
||||||
|
return SelectedConnectAuth(
|
||||||
|
authToken: authToken,
|
||||||
|
authBootstrapToken: authBootstrapToken,
|
||||||
|
authDeviceToken: authDeviceToken,
|
||||||
|
authPassword: explicitPassword,
|
||||||
|
signatureToken: authToken ?? authBootstrapToken,
|
||||||
|
storedToken: storedToken,
|
||||||
|
authSource: authSource)
|
||||||
|
}
|
||||||
|
|
||||||
private func handleConnectResponse(
|
private func handleConnectResponse(
|
||||||
_ res: ResponseFrame,
|
_ res: ResponseFrame,
|
||||||
identity: DeviceIdentity?,
|
identity: DeviceIdentity?,
|
||||||
|
|
@ -522,10 +549,12 @@ public actor GatewayChannelActor {
|
||||||
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
let details = res.error?["details"]?.value as? [String: ProtoAnyCodable]
|
||||||
let detailCode = details?["code"]?.value as? String
|
let detailCode = details?["code"]?.value as? String
|
||||||
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
let canRetryWithDeviceToken = details?["canRetryWithDeviceToken"]?.value as? Bool ?? false
|
||||||
|
let recommendedNextStep = details?["recommendedNextStep"]?.value as? String
|
||||||
throw GatewayConnectAuthError(
|
throw GatewayConnectAuthError(
|
||||||
message: msg,
|
message: msg,
|
||||||
detailCode: detailCode,
|
detailCodeRaw: detailCode,
|
||||||
canRetryWithDeviceToken: canRetryWithDeviceToken)
|
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||||
|
recommendedNextStepRaw: recommendedNextStep)
|
||||||
}
|
}
|
||||||
guard let payload = res.payload else {
|
guard let payload = res.payload else {
|
||||||
throw NSError(
|
throw NSError(
|
||||||
|
|
@ -710,8 +739,7 @@ public actor GatewayChannelActor {
|
||||||
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
if self.shouldPauseReconnectAfterAuthFailure(error) {
|
||||||
self.reconnectPausedForAuthFailure = true
|
self.reconnectPausedForAuthFailure = true
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway reconnect paused for non-recoverable auth failure " +
|
"gateway reconnect paused for non-recoverable auth failure \(error.localizedDescription, privacy: .public)"
|
||||||
"\(error.localizedDescription, privacy: .public)"
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -743,7 +771,7 @@ public actor GatewayChannelActor {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return authError.canRetryWithDeviceToken ||
|
return authError.canRetryWithDeviceToken ||
|
||||||
authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch
|
authError.detail == .authTokenMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
|
private func shouldPauseReconnectAfterAuthFailure(_ error: Error) -> Bool {
|
||||||
|
|
@ -753,7 +781,7 @@ public actor GatewayChannelActor {
|
||||||
if authError.isNonRecoverable {
|
if authError.isNonRecoverable {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if authError.detailCode == GatewayConnectErrorCodes.authTokenMismatch &&
|
if authError.detail == .authTokenMismatch &&
|
||||||
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
self.deviceTokenRetryBudgetUsed && !self.pendingDeviceTokenRetry
|
||||||
{
|
{
|
||||||
return true
|
return true
|
||||||
|
|
@ -765,7 +793,7 @@ public actor GatewayChannelActor {
|
||||||
guard let authError = error as? GatewayConnectAuthError else {
|
guard let authError = error as? GatewayConnectAuthError else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return authError.detailCode == GatewayConnectErrorCodes.authDeviceTokenMismatch
|
return authError.detail == .authDeviceTokenMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isTrustedDeviceRetryEndpoint() -> Bool {
|
private func isTrustedDeviceRetryEndpoint() -> Bool {
|
||||||
|
|
@ -867,6 +895,9 @@ public actor GatewayChannelActor {
|
||||||
|
|
||||||
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
// Wrap low-level URLSession/WebSocket errors with context so UI can surface them.
|
||||||
private func wrap(_ error: Error, context: String) -> Error {
|
private func wrap(_ error: Error, context: String) -> Error {
|
||||||
|
if error is GatewayConnectAuthError || error is GatewayResponseError || error is GatewayDecodingError {
|
||||||
|
return error
|
||||||
|
}
|
||||||
if let urlError = error as? URLError {
|
if let urlError = error as? URLError {
|
||||||
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
let desc = urlError.localizedDescription.isEmpty ? "cancelled" : urlError.localizedDescription
|
||||||
return NSError(
|
return NSError(
|
||||||
|
|
@ -910,8 +941,8 @@ public actor GatewayChannelActor {
|
||||||
return (id: id, data: data)
|
return (id: id, data: data)
|
||||||
} catch {
|
} catch {
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
"gateway \(kind) encode failed \(method, privacy: .public) " +
|
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
|
||||||
"error=\(error.localizedDescription, privacy: .public)")
|
)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,114 @@
|
||||||
import OpenClawProtocol
|
import OpenClawProtocol
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
public enum GatewayConnectAuthDetailCode: String, Sendable {
|
||||||
|
case authRequired = "AUTH_REQUIRED"
|
||||||
|
case authUnauthorized = "AUTH_UNAUTHORIZED"
|
||||||
|
case authTokenMismatch = "AUTH_TOKEN_MISMATCH"
|
||||||
|
case authBootstrapTokenInvalid = "AUTH_BOOTSTRAP_TOKEN_INVALID"
|
||||||
|
case authDeviceTokenMismatch = "AUTH_DEVICE_TOKEN_MISMATCH"
|
||||||
|
case authTokenMissing = "AUTH_TOKEN_MISSING"
|
||||||
|
case authTokenNotConfigured = "AUTH_TOKEN_NOT_CONFIGURED"
|
||||||
|
case authPasswordMissing = "AUTH_PASSWORD_MISSING"
|
||||||
|
case authPasswordMismatch = "AUTH_PASSWORD_MISMATCH"
|
||||||
|
case authPasswordNotConfigured = "AUTH_PASSWORD_NOT_CONFIGURED"
|
||||||
|
case authRateLimited = "AUTH_RATE_LIMITED"
|
||||||
|
case authTailscaleIdentityMissing = "AUTH_TAILSCALE_IDENTITY_MISSING"
|
||||||
|
case authTailscaleProxyMissing = "AUTH_TAILSCALE_PROXY_MISSING"
|
||||||
|
case authTailscaleWhoisFailed = "AUTH_TAILSCALE_WHOIS_FAILED"
|
||||||
|
case authTailscaleIdentityMismatch = "AUTH_TAILSCALE_IDENTITY_MISMATCH"
|
||||||
|
case pairingRequired = "PAIRING_REQUIRED"
|
||||||
|
case controlUiDeviceIdentityRequired = "CONTROL_UI_DEVICE_IDENTITY_REQUIRED"
|
||||||
|
case deviceIdentityRequired = "DEVICE_IDENTITY_REQUIRED"
|
||||||
|
case deviceAuthInvalid = "DEVICE_AUTH_INVALID"
|
||||||
|
case deviceAuthDeviceIdMismatch = "DEVICE_AUTH_DEVICE_ID_MISMATCH"
|
||||||
|
case deviceAuthSignatureExpired = "DEVICE_AUTH_SIGNATURE_EXPIRED"
|
||||||
|
case deviceAuthNonceRequired = "DEVICE_AUTH_NONCE_REQUIRED"
|
||||||
|
case deviceAuthNonceMismatch = "DEVICE_AUTH_NONCE_MISMATCH"
|
||||||
|
case deviceAuthSignatureInvalid = "DEVICE_AUTH_SIGNATURE_INVALID"
|
||||||
|
case deviceAuthPublicKeyInvalid = "DEVICE_AUTH_PUBLIC_KEY_INVALID"
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum GatewayConnectRecoveryNextStep: String, Sendable {
|
||||||
|
case retryWithDeviceToken = "retry_with_device_token"
|
||||||
|
case updateAuthConfiguration = "update_auth_configuration"
|
||||||
|
case updateAuthCredentials = "update_auth_credentials"
|
||||||
|
case waitThenRetry = "wait_then_retry"
|
||||||
|
case reviewAuthConfiguration = "review_auth_configuration"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured websocket connect-auth rejection surfaced before the channel is usable.
|
||||||
|
public struct GatewayConnectAuthError: LocalizedError, Sendable {
|
||||||
|
public let message: String
|
||||||
|
public let detailCodeRaw: String?
|
||||||
|
public let recommendedNextStepRaw: String?
|
||||||
|
public let canRetryWithDeviceToken: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
message: String,
|
||||||
|
detailCodeRaw: String?,
|
||||||
|
canRetryWithDeviceToken: Bool,
|
||||||
|
recommendedNextStepRaw: String? = nil)
|
||||||
|
{
|
||||||
|
let trimmedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedDetailCode = detailCodeRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedRecommendedNextStep =
|
||||||
|
recommendedNextStepRaw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self.message = trimmedMessage.isEmpty ? "gateway connect failed" : trimmedMessage
|
||||||
|
self.detailCodeRaw = trimmedDetailCode?.isEmpty == false ? trimmedDetailCode : nil
|
||||||
|
self.canRetryWithDeviceToken = canRetryWithDeviceToken
|
||||||
|
self.recommendedNextStepRaw =
|
||||||
|
trimmedRecommendedNextStep?.isEmpty == false ? trimmedRecommendedNextStep : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
message: String,
|
||||||
|
detailCode: String?,
|
||||||
|
canRetryWithDeviceToken: Bool,
|
||||||
|
recommendedNextStep: String? = nil)
|
||||||
|
{
|
||||||
|
self.init(
|
||||||
|
message: message,
|
||||||
|
detailCodeRaw: detailCode,
|
||||||
|
canRetryWithDeviceToken: canRetryWithDeviceToken,
|
||||||
|
recommendedNextStepRaw: recommendedNextStep)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var detailCode: String? { self.detailCodeRaw }
|
||||||
|
|
||||||
|
public var recommendedNextStepCode: String? { self.recommendedNextStepRaw }
|
||||||
|
|
||||||
|
public var detail: GatewayConnectAuthDetailCode? {
|
||||||
|
guard let detailCodeRaw else { return nil }
|
||||||
|
return GatewayConnectAuthDetailCode(rawValue: detailCodeRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var recommendedNextStep: GatewayConnectRecoveryNextStep? {
|
||||||
|
guard let recommendedNextStepRaw else { return nil }
|
||||||
|
return GatewayConnectRecoveryNextStep(rawValue: recommendedNextStepRaw)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var errorDescription: String? { self.message }
|
||||||
|
|
||||||
|
public var isNonRecoverable: Bool {
|
||||||
|
switch self.detail {
|
||||||
|
case .authTokenMissing,
|
||||||
|
.authBootstrapTokenInvalid,
|
||||||
|
.authTokenNotConfigured,
|
||||||
|
.authPasswordMissing,
|
||||||
|
.authPasswordMismatch,
|
||||||
|
.authPasswordNotConfigured,
|
||||||
|
.authRateLimited,
|
||||||
|
.pairingRequired,
|
||||||
|
.controlUiDeviceIdentityRequired,
|
||||||
|
.deviceIdentityRequired:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
/// Structured error surfaced when the gateway responds with `{ ok: false }`.
|
||||||
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
public struct GatewayResponseError: LocalizedError, @unchecked Sendable {
|
||||||
public let method: String
|
public let method: String
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ public actor GatewayNodeSession {
|
||||||
private var channel: GatewayChannelActor?
|
private var channel: GatewayChannelActor?
|
||||||
private var activeURL: URL?
|
private var activeURL: URL?
|
||||||
private var activeToken: String?
|
private var activeToken: String?
|
||||||
|
private var activeBootstrapToken: String?
|
||||||
private var activePassword: String?
|
private var activePassword: String?
|
||||||
private var activeConnectOptionsKey: String?
|
private var activeConnectOptionsKey: String?
|
||||||
private var connectOptions: GatewayConnectOptions?
|
private var connectOptions: GatewayConnectOptions?
|
||||||
|
|
@ -194,6 +195,7 @@ public actor GatewayNodeSession {
|
||||||
public func connect(
|
public func connect(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
|
bootstrapToken: String?,
|
||||||
password: String?,
|
password: String?,
|
||||||
connectOptions: GatewayConnectOptions,
|
connectOptions: GatewayConnectOptions,
|
||||||
sessionBox: WebSocketSessionBox?,
|
sessionBox: WebSocketSessionBox?,
|
||||||
|
|
@ -204,6 +206,7 @@ public actor GatewayNodeSession {
|
||||||
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
let nextOptionsKey = self.connectOptionsKey(connectOptions)
|
||||||
let shouldReconnect = self.activeURL != url ||
|
let shouldReconnect = self.activeURL != url ||
|
||||||
self.activeToken != token ||
|
self.activeToken != token ||
|
||||||
|
self.activeBootstrapToken != bootstrapToken ||
|
||||||
self.activePassword != password ||
|
self.activePassword != password ||
|
||||||
self.activeConnectOptionsKey != nextOptionsKey ||
|
self.activeConnectOptionsKey != nextOptionsKey ||
|
||||||
self.channel == nil
|
self.channel == nil
|
||||||
|
|
@ -221,6 +224,7 @@ public actor GatewayNodeSession {
|
||||||
let channel = GatewayChannelActor(
|
let channel = GatewayChannelActor(
|
||||||
url: url,
|
url: url,
|
||||||
token: token,
|
token: token,
|
||||||
|
bootstrapToken: bootstrapToken,
|
||||||
password: password,
|
password: password,
|
||||||
session: sessionBox,
|
session: sessionBox,
|
||||||
pushHandler: { [weak self] push in
|
pushHandler: { [weak self] push in
|
||||||
|
|
@ -233,6 +237,7 @@ public actor GatewayNodeSession {
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.activeURL = url
|
self.activeURL = url
|
||||||
self.activeToken = token
|
self.activeToken = token
|
||||||
|
self.activeBootstrapToken = bootstrapToken
|
||||||
self.activePassword = password
|
self.activePassword = password
|
||||||
self.activeConnectOptionsKey = nextOptionsKey
|
self.activeConnectOptionsKey = nextOptionsKey
|
||||||
}
|
}
|
||||||
|
|
@ -257,6 +262,7 @@ public actor GatewayNodeSession {
|
||||||
self.channel = nil
|
self.channel = nil
|
||||||
self.activeURL = nil
|
self.activeURL = nil
|
||||||
self.activeToken = nil
|
self.activeToken = nil
|
||||||
|
self.activeBootstrapToken = nil
|
||||||
self.activePassword = nil
|
self.activePassword = nil
|
||||||
self.activeConnectOptionsKey = nil
|
self.activeConnectOptionsKey = nil
|
||||||
self.hasEverConnected = false
|
self.hasEverConnected = false
|
||||||
|
|
|
||||||
|
|
@ -538,8 +538,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
public let inputprovenance: [String: AnyCodable]?
|
public let inputprovenance: [String: AnyCodable]?
|
||||||
public let idempotencykey: String
|
public let idempotencykey: String
|
||||||
public let label: String?
|
public let label: String?
|
||||||
public let spawnedby: String?
|
|
||||||
public let workspacedir: String?
|
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
message: String,
|
message: String,
|
||||||
|
|
@ -566,9 +564,7 @@ public struct AgentParams: Codable, Sendable {
|
||||||
internalevents: [[String: AnyCodable]]?,
|
internalevents: [[String: AnyCodable]]?,
|
||||||
inputprovenance: [String: AnyCodable]?,
|
inputprovenance: [String: AnyCodable]?,
|
||||||
idempotencykey: String,
|
idempotencykey: String,
|
||||||
label: String?,
|
label: String?)
|
||||||
spawnedby: String?,
|
|
||||||
workspacedir: String?)
|
|
||||||
{
|
{
|
||||||
self.message = message
|
self.message = message
|
||||||
self.agentid = agentid
|
self.agentid = agentid
|
||||||
|
|
@ -595,8 +591,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
self.inputprovenance = inputprovenance
|
self.inputprovenance = inputprovenance
|
||||||
self.idempotencykey = idempotencykey
|
self.idempotencykey = idempotencykey
|
||||||
self.label = label
|
self.label = label
|
||||||
self.spawnedby = spawnedby
|
|
||||||
self.workspacedir = workspacedir
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
@ -625,8 +619,6 @@ public struct AgentParams: Codable, Sendable {
|
||||||
case inputprovenance = "inputProvenance"
|
case inputprovenance = "inputProvenance"
|
||||||
case idempotencykey = "idempotencyKey"
|
case idempotencykey = "idempotencyKey"
|
||||||
case label
|
case label
|
||||||
case spawnedby = "spawnedBy"
|
|
||||||
case workspacedir = "workspaceDir"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1114,6 +1106,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
public let tokensuffix: String
|
public let tokensuffix: String
|
||||||
public let topic: String
|
public let topic: String
|
||||||
public let environment: String
|
public let environment: String
|
||||||
|
public let transport: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
ok: Bool,
|
ok: Bool,
|
||||||
|
|
@ -1122,7 +1115,8 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
reason: String?,
|
reason: String?,
|
||||||
tokensuffix: String,
|
tokensuffix: String,
|
||||||
topic: String,
|
topic: String,
|
||||||
environment: String)
|
environment: String,
|
||||||
|
transport: String)
|
||||||
{
|
{
|
||||||
self.ok = ok
|
self.ok = ok
|
||||||
self.status = status
|
self.status = status
|
||||||
|
|
@ -1131,6 +1125,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
self.tokensuffix = tokensuffix
|
self.tokensuffix = tokensuffix
|
||||||
self.topic = topic
|
self.topic = topic
|
||||||
self.environment = environment
|
self.environment = environment
|
||||||
|
self.transport = transport
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
|
@ -1141,6 +1136,7 @@ public struct PushTestResult: Codable, Sendable {
|
||||||
case tokensuffix = "tokenSuffix"
|
case tokensuffix = "tokenSuffix"
|
||||||
case topic
|
case topic
|
||||||
case environment
|
case environment
|
||||||
|
case transport
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1326,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let key: String
|
public let key: String
|
||||||
public let label: AnyCodable?
|
public let label: AnyCodable?
|
||||||
public let thinkinglevel: AnyCodable?
|
public let thinkinglevel: AnyCodable?
|
||||||
|
public let fastmode: AnyCodable?
|
||||||
public let verboselevel: AnyCodable?
|
public let verboselevel: AnyCodable?
|
||||||
public let reasoninglevel: AnyCodable?
|
public let reasoninglevel: AnyCodable?
|
||||||
public let responseusage: AnyCodable?
|
public let responseusage: AnyCodable?
|
||||||
|
|
@ -1336,6 +1333,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
public let execnode: AnyCodable?
|
public let execnode: AnyCodable?
|
||||||
public let model: AnyCodable?
|
public let model: AnyCodable?
|
||||||
public let spawnedby: AnyCodable?
|
public let spawnedby: AnyCodable?
|
||||||
|
public let spawnedworkspacedir: AnyCodable?
|
||||||
public let spawndepth: AnyCodable?
|
public let spawndepth: AnyCodable?
|
||||||
public let subagentrole: AnyCodable?
|
public let subagentrole: AnyCodable?
|
||||||
public let subagentcontrolscope: AnyCodable?
|
public let subagentcontrolscope: AnyCodable?
|
||||||
|
|
@ -1346,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
key: String,
|
key: String,
|
||||||
label: AnyCodable?,
|
label: AnyCodable?,
|
||||||
thinkinglevel: AnyCodable?,
|
thinkinglevel: AnyCodable?,
|
||||||
|
fastmode: AnyCodable?,
|
||||||
verboselevel: AnyCodable?,
|
verboselevel: AnyCodable?,
|
||||||
reasoninglevel: AnyCodable?,
|
reasoninglevel: AnyCodable?,
|
||||||
responseusage: AnyCodable?,
|
responseusage: AnyCodable?,
|
||||||
|
|
@ -1356,6 +1355,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
execnode: AnyCodable?,
|
execnode: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
spawnedby: AnyCodable?,
|
spawnedby: AnyCodable?,
|
||||||
|
spawnedworkspacedir: AnyCodable?,
|
||||||
spawndepth: AnyCodable?,
|
spawndepth: AnyCodable?,
|
||||||
subagentrole: AnyCodable?,
|
subagentrole: AnyCodable?,
|
||||||
subagentcontrolscope: AnyCodable?,
|
subagentcontrolscope: AnyCodable?,
|
||||||
|
|
@ -1365,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
self.key = key
|
self.key = key
|
||||||
self.label = label
|
self.label = label
|
||||||
self.thinkinglevel = thinkinglevel
|
self.thinkinglevel = thinkinglevel
|
||||||
|
self.fastmode = fastmode
|
||||||
self.verboselevel = verboselevel
|
self.verboselevel = verboselevel
|
||||||
self.reasoninglevel = reasoninglevel
|
self.reasoninglevel = reasoninglevel
|
||||||
self.responseusage = responseusage
|
self.responseusage = responseusage
|
||||||
|
|
@ -1375,6 +1376,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
self.execnode = execnode
|
self.execnode = execnode
|
||||||
self.model = model
|
self.model = model
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
|
self.spawnedworkspacedir = spawnedworkspacedir
|
||||||
self.spawndepth = spawndepth
|
self.spawndepth = spawndepth
|
||||||
self.subagentrole = subagentrole
|
self.subagentrole = subagentrole
|
||||||
self.subagentcontrolscope = subagentcontrolscope
|
self.subagentcontrolscope = subagentcontrolscope
|
||||||
|
|
@ -1386,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
case key
|
case key
|
||||||
case label
|
case label
|
||||||
case thinkinglevel = "thinkingLevel"
|
case thinkinglevel = "thinkingLevel"
|
||||||
|
case fastmode = "fastMode"
|
||||||
case verboselevel = "verboseLevel"
|
case verboselevel = "verboseLevel"
|
||||||
case reasoninglevel = "reasoningLevel"
|
case reasoninglevel = "reasoningLevel"
|
||||||
case responseusage = "responseUsage"
|
case responseusage = "responseUsage"
|
||||||
|
|
@ -1396,6 +1399,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||||
case execnode = "execNode"
|
case execnode = "execNode"
|
||||||
case model
|
case model
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
|
case spawnedworkspacedir = "spawnedWorkspaceDir"
|
||||||
case spawndepth = "spawnDepth"
|
case spawndepth = "spawnDepth"
|
||||||
case subagentrole = "subagentRole"
|
case subagentrole = "subagentRole"
|
||||||
case subagentcontrolscope = "subagentControlScope"
|
case subagentcontrolscope = "subagentControlScope"
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ private func makeViewModel(
|
||||||
historyResponses: [OpenClawChatHistoryPayload],
|
historyResponses: [OpenClawChatHistoryPayload],
|
||||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
||||||
modelResponses: [[OpenClawChatModelChoice]] = [],
|
modelResponses: [[OpenClawChatModelChoice]] = [],
|
||||||
|
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
|
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||||
initialThinkingLevel: String? = nil,
|
initialThinkingLevel: String? = nil,
|
||||||
|
|
@ -93,6 +94,7 @@ private func makeViewModel(
|
||||||
historyResponses: historyResponses,
|
historyResponses: historyResponses,
|
||||||
sessionsResponses: sessionsResponses,
|
sessionsResponses: sessionsResponses,
|
||||||
modelResponses: modelResponses,
|
modelResponses: modelResponses,
|
||||||
|
resetSessionHook: resetSessionHook,
|
||||||
setSessionModelHook: setSessionModelHook,
|
setSessionModelHook: setSessionModelHook,
|
||||||
setSessionThinkingHook: setSessionThinkingHook)
|
setSessionThinkingHook: setSessionThinkingHook)
|
||||||
let vm = await MainActor.run {
|
let vm = await MainActor.run {
|
||||||
|
|
@ -199,6 +201,7 @@ private actor TestChatTransportState {
|
||||||
var historyCallCount: Int = 0
|
var historyCallCount: Int = 0
|
||||||
var sessionsCallCount: Int = 0
|
var sessionsCallCount: Int = 0
|
||||||
var modelsCallCount: Int = 0
|
var modelsCallCount: Int = 0
|
||||||
|
var resetSessionKeys: [String] = []
|
||||||
var sentRunIds: [String] = []
|
var sentRunIds: [String] = []
|
||||||
var sentThinkingLevels: [String] = []
|
var sentThinkingLevels: [String] = []
|
||||||
var abortedRunIds: [String] = []
|
var abortedRunIds: [String] = []
|
||||||
|
|
@ -211,6 +214,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||||
private let historyResponses: [OpenClawChatHistoryPayload]
|
private let historyResponses: [OpenClawChatHistoryPayload]
|
||||||
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
private let sessionsResponses: [OpenClawChatSessionsListResponse]
|
||||||
private let modelResponses: [[OpenClawChatModelChoice]]
|
private let modelResponses: [[OpenClawChatModelChoice]]
|
||||||
|
private let resetSessionHook: (@Sendable (String) async throws -> Void)?
|
||||||
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
|
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
|
||||||
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
|
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
|
||||||
|
|
||||||
|
|
@ -221,12 +225,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||||
historyResponses: [OpenClawChatHistoryPayload],
|
historyResponses: [OpenClawChatHistoryPayload],
|
||||||
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
sessionsResponses: [OpenClawChatSessionsListResponse] = [],
|
||||||
modelResponses: [[OpenClawChatModelChoice]] = [],
|
modelResponses: [[OpenClawChatModelChoice]] = [],
|
||||||
|
resetSessionHook: (@Sendable (String) async throws -> Void)? = nil,
|
||||||
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
|
||||||
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
|
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil)
|
||||||
{
|
{
|
||||||
self.historyResponses = historyResponses
|
self.historyResponses = historyResponses
|
||||||
self.sessionsResponses = sessionsResponses
|
self.sessionsResponses = sessionsResponses
|
||||||
self.modelResponses = modelResponses
|
self.modelResponses = modelResponses
|
||||||
|
self.resetSessionHook = resetSessionHook
|
||||||
self.setSessionModelHook = setSessionModelHook
|
self.setSessionModelHook = setSessionModelHook
|
||||||
self.setSessionThinkingHook = setSessionThinkingHook
|
self.setSessionThinkingHook = setSessionThinkingHook
|
||||||
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
|
||||||
|
|
@ -301,6 +307,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetSession(sessionKey: String) async throws {
|
||||||
|
await self.state.resetSessionKeysAppend(sessionKey)
|
||||||
|
if let resetSessionHook = self.resetSessionHook {
|
||||||
|
try await resetSessionHook(sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
|
func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws {
|
||||||
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
|
await self.state.patchedThinkingLevelsAppend(thinkingLevel)
|
||||||
if let setSessionThinkingHook = self.setSessionThinkingHook {
|
if let setSessionThinkingHook = self.setSessionThinkingHook {
|
||||||
|
|
@ -336,6 +349,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
|
||||||
func patchedThinkingLevels() async -> [String] {
|
func patchedThinkingLevels() async -> [String] {
|
||||||
await self.state.patchedThinkingLevels
|
await self.state.patchedThinkingLevels
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resetSessionKeys() async -> [String] {
|
||||||
|
await self.state.resetSessionKeys
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TestChatTransportState {
|
extension TestChatTransportState {
|
||||||
|
|
@ -370,6 +387,10 @@ extension TestChatTransportState {
|
||||||
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
|
fileprivate func patchedThinkingLevelsAppend(_ v: String) {
|
||||||
self.patchedThinkingLevels.append(v)
|
self.patchedThinkingLevels.append(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate func resetSessionKeysAppend(_ v: String) {
|
||||||
|
self.resetSessionKeys.append(v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suite struct ChatViewModelTests {
|
@Suite struct ChatViewModelTests {
|
||||||
|
|
@ -592,6 +613,151 @@ extension TestChatTransportState {
|
||||||
#expect(keys == ["main", "custom"])
|
#expect(keys == ["main", "custom"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws {
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
let recent = now - (30 * 60 * 1000)
|
||||||
|
let recentOlder = now - (90 * 60 * 1000)
|
||||||
|
let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main")
|
||||||
|
let sessions = OpenClawChatSessionsListResponse(
|
||||||
|
ts: now,
|
||||||
|
path: nil,
|
||||||
|
count: 2,
|
||||||
|
defaults: OpenClawChatSessionsDefaults(
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil,
|
||||||
|
mainSessionKey: "Luke’s MacBook Pro"),
|
||||||
|
sessions: [
|
||||||
|
OpenClawChatSessionEntry(
|
||||||
|
key: "Luke’s MacBook Pro",
|
||||||
|
kind: nil,
|
||||||
|
displayName: "Luke’s MacBook Pro",
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
|
updatedAt: recent,
|
||||||
|
sessionId: nil,
|
||||||
|
systemSent: nil,
|
||||||
|
abortedLastRun: nil,
|
||||||
|
thinkingLevel: nil,
|
||||||
|
verboseLevel: nil,
|
||||||
|
inputTokens: nil,
|
||||||
|
outputTokens: nil,
|
||||||
|
totalTokens: nil,
|
||||||
|
modelProvider: nil,
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil),
|
||||||
|
sessionEntry(key: "recent-1", updatedAt: recentOlder),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (_, vm) = await makeViewModel(
|
||||||
|
sessionKey: "Luke’s MacBook Pro",
|
||||||
|
historyResponses: [history],
|
||||||
|
sessionsResponses: [sessions])
|
||||||
|
await MainActor.run { vm.load() }
|
||||||
|
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||||
|
|
||||||
|
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||||
|
#expect(keys == ["Luke’s MacBook Pro", "recent-1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sessionChoicesHideInternalOnboardingSession() async throws {
|
||||||
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
|
let recent = now - (2 * 60 * 1000)
|
||||||
|
let recentOlder = now - (5 * 60 * 1000)
|
||||||
|
let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main")
|
||||||
|
let sessions = OpenClawChatSessionsListResponse(
|
||||||
|
ts: now,
|
||||||
|
path: nil,
|
||||||
|
count: 2,
|
||||||
|
defaults: OpenClawChatSessionsDefaults(
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil,
|
||||||
|
mainSessionKey: "agent:main:main"),
|
||||||
|
sessions: [
|
||||||
|
OpenClawChatSessionEntry(
|
||||||
|
key: "agent:main:onboarding",
|
||||||
|
kind: nil,
|
||||||
|
displayName: "Luke’s MacBook Pro",
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
|
updatedAt: recent,
|
||||||
|
sessionId: nil,
|
||||||
|
systemSent: nil,
|
||||||
|
abortedLastRun: nil,
|
||||||
|
thinkingLevel: nil,
|
||||||
|
verboseLevel: nil,
|
||||||
|
inputTokens: nil,
|
||||||
|
outputTokens: nil,
|
||||||
|
totalTokens: nil,
|
||||||
|
modelProvider: nil,
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil),
|
||||||
|
OpenClawChatSessionEntry(
|
||||||
|
key: "agent:main:main",
|
||||||
|
kind: nil,
|
||||||
|
displayName: "Luke’s MacBook Pro",
|
||||||
|
surface: nil,
|
||||||
|
subject: nil,
|
||||||
|
room: nil,
|
||||||
|
space: nil,
|
||||||
|
updatedAt: recentOlder,
|
||||||
|
sessionId: nil,
|
||||||
|
systemSent: nil,
|
||||||
|
abortedLastRun: nil,
|
||||||
|
thinkingLevel: nil,
|
||||||
|
verboseLevel: nil,
|
||||||
|
inputTokens: nil,
|
||||||
|
outputTokens: nil,
|
||||||
|
totalTokens: nil,
|
||||||
|
modelProvider: nil,
|
||||||
|
model: nil,
|
||||||
|
contextTokens: nil),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (_, vm) = await makeViewModel(
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
historyResponses: [history],
|
||||||
|
sessionsResponses: [sessions])
|
||||||
|
await MainActor.run { vm.load() }
|
||||||
|
try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } }
|
||||||
|
|
||||||
|
let keys = await MainActor.run { vm.sessionChoices.map(\.key) }
|
||||||
|
#expect(keys == ["agent:main:main"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resetTriggerResetsSessionAndReloadsHistory() async throws {
|
||||||
|
let before = historyPayload(
|
||||||
|
messages: [
|
||||||
|
chatTextMessage(role: "assistant", text: "before reset", timestamp: 1),
|
||||||
|
])
|
||||||
|
let after = historyPayload(
|
||||||
|
messages: [
|
||||||
|
chatTextMessage(role: "assistant", text: "after reset", timestamp: 2),
|
||||||
|
])
|
||||||
|
|
||||||
|
let (transport, vm) = await makeViewModel(historyResponses: [before, after])
|
||||||
|
try await loadAndWaitBootstrap(vm: vm)
|
||||||
|
try await waitUntil("initial history loaded") {
|
||||||
|
await MainActor.run { vm.messages.first?.content.first?.text == "before reset" }
|
||||||
|
}
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
vm.input = "/new"
|
||||||
|
vm.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
try await waitUntil("reset called") {
|
||||||
|
await transport.resetSessionKeys() == ["main"]
|
||||||
|
}
|
||||||
|
try await waitUntil("history reloaded") {
|
||||||
|
await MainActor.run { vm.messages.first?.content.first?.text == "after reset" }
|
||||||
|
}
|
||||||
|
#expect(await transport.lastSentRunId() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
|
@Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws {
|
||||||
let now = Date().timeIntervalSince1970 * 1000
|
let now = Date().timeIntervalSince1970 * 1000
|
||||||
let history = historyPayload()
|
let history = historyPayload()
|
||||||
|
|
@ -758,7 +924,8 @@ extension TestChatTransportState {
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
|
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro")
|
||||||
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro")
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro")
|
||||||
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
|
@Test func sendWaitsForInFlightModelPatchToFinish() async throws {
|
||||||
|
|
@ -852,11 +1019,15 @@ extension TestChatTransportState {
|
||||||
}
|
}
|
||||||
|
|
||||||
try await waitUntil("older model completion wins after latest failure") {
|
try await waitUntil("older model completion wins after latest failure") {
|
||||||
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
|
await MainActor.run {
|
||||||
|
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
|
||||||
|
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
|
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
|
||||||
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
|
||||||
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
|
||||||
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
|
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1012,12 +1183,17 @@ extension TestChatTransportState {
|
||||||
}
|
}
|
||||||
|
|
||||||
try await waitUntil("late model completion updates only the original session") {
|
try await waitUntil("late model completion updates only the original session") {
|
||||||
await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" }
|
await MainActor.run {
|
||||||
|
vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" &&
|
||||||
|
vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
|
#expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4")
|
||||||
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4")
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4")
|
||||||
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai")
|
||||||
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro")
|
||||||
|
#expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil)
|
||||||
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
|
#expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,17 @@ import Testing
|
||||||
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
string: "openclaw://gateway?host=127.0.0.1&port=18789&tls=0&token=abc")!
|
||||||
#expect(
|
#expect(
|
||||||
DeepLinkParser.parse(url) == .gateway(
|
DeepLinkParser.parse(url) == .gateway(
|
||||||
.init(host: "127.0.0.1", port: 18789, tls: false, token: "abc", password: nil)))
|
.init(
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 18789,
|
||||||
|
tls: false,
|
||||||
|
bootstrapToken: nil,
|
||||||
|
token: "abc",
|
||||||
|
password: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
|
@Test func setupCodeRejectsInsecureNonLoopbackWs() {
|
||||||
let payload = #"{"url":"ws://attacker.example:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://attacker.example:18789","bootstrapToken":"tok"}"#
|
||||||
let encoded = Data(payload.utf8)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
|
@ -34,7 +40,7 @@ import Testing
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
|
@Test func setupCodeRejectsInsecurePrefixBypassHost() {
|
||||||
let payload = #"{"url":"ws://127.attacker.example:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://127.attacker.example:18789","bootstrapToken":"tok"}"#
|
||||||
let encoded = Data(payload.utf8)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
|
@ -44,7 +50,7 @@ import Testing
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func setupCodeAllowsLoopbackWs() {
|
@Test func setupCodeAllowsLoopbackWs() {
|
||||||
let payload = #"{"url":"ws://127.0.0.1:18789","token":"tok"}"#
|
let payload = #"{"url":"ws://127.0.0.1:18789","bootstrapToken":"tok"}"#
|
||||||
let encoded = Data(payload.utf8)
|
let encoded = Data(payload.utf8)
|
||||||
.base64EncodedString()
|
.base64EncodedString()
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
|
@ -55,7 +61,8 @@ import Testing
|
||||||
host: "127.0.0.1",
|
host: "127.0.0.1",
|
||||||
port: 18789,
|
port: 18789,
|
||||||
tls: false,
|
tls: false,
|
||||||
token: "tok",
|
bootstrapToken: "tok",
|
||||||
|
token: nil,
|
||||||
password: nil))
|
password: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import OpenClawKit
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@Suite struct GatewayErrorsTests {
|
||||||
|
@Test func bootstrapTokenInvalidIsNonRecoverable() {
|
||||||
|
let error = GatewayConnectAuthError(
|
||||||
|
message: "setup code expired",
|
||||||
|
detailCode: GatewayConnectAuthDetailCode.authBootstrapTokenInvalid.rawValue,
|
||||||
|
canRetryWithDeviceToken: false)
|
||||||
|
|
||||||
|
#expect(error.isNonRecoverable)
|
||||||
|
#expect(error.detail == .authBootstrapTokenInvalid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -266,6 +266,7 @@ struct GatewayNodeSessionTests {
|
||||||
try await gateway.connect(
|
try await gateway.connect(
|
||||||
url: URL(string: "ws://example.invalid")!,
|
url: URL(string: "ws://example.invalid")!,
|
||||||
token: nil,
|
token: nil,
|
||||||
|
bootstrapToken: nil,
|
||||||
password: nil,
|
password: nil,
|
||||||
connectOptions: options,
|
connectOptions: options,
|
||||||
sessionBox: WebSocketSessionBox(session: session),
|
sessionBox: WebSocketSessionBox(session: session),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
- runner: infer canonical tool names from malformed `toolCallId` variants (e.g. `functionsread3`, `functionswrite4`) when allowlist is present, preventing `Tool not found` regressions in strict routers.
|
||||||
|
|
@ -118,6 +118,11 @@ Session stores live under the state directory (default `~/.openclaw`):
|
||||||
|
|
||||||
You can override the store path via `session.store` and `{agentId}` templating.
|
You can override the store path via `session.store` and `{agentId}` templating.
|
||||||
|
|
||||||
|
Gateway and ACP session discovery also scans disk-backed agent stores under the
|
||||||
|
default `agents/` root and under templated `session.store` roots. Discovered
|
||||||
|
stores must stay inside that resolved agent root and use a regular
|
||||||
|
`sessions.json` file. Symlinks and out-of-root paths are ignored.
|
||||||
|
|
||||||
## WebChat behavior
|
## WebChat behavior
|
||||||
|
|
||||||
WebChat attaches to the **selected agent** and defaults to the agent’s main
|
WebChat attaches to the **selected agent** and defaults to the agent’s main
|
||||||
|
|
|
||||||
|
|
@ -193,16 +193,18 @@ Edit `~/.openclaw/openclaw.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use `connectionMode: "webhook"`, set `verificationToken`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
|
If you use `connectionMode: "webhook"`, set both `verificationToken` and `encryptKey`. The Feishu webhook server binds to `127.0.0.1` by default; set `webhookHost` only if you intentionally need a different bind address.
|
||||||
|
|
||||||
#### Verification Token (webhook mode)
|
#### Verification Token and Encrypt Key (webhook mode)
|
||||||
|
|
||||||
When using webhook mode, set `channels.feishu.verificationToken` in your config. To get the value:
|
When using webhook mode, set both `channels.feishu.verificationToken` and `channels.feishu.encryptKey` in your config. To get the values:
|
||||||
|
|
||||||
1. In Feishu Open Platform, open your app
|
1. In Feishu Open Platform, open your app
|
||||||
2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调)
|
2. Go to **Development** → **Events & Callbacks** (开发配置 → 事件与回调)
|
||||||
3. Open the **Encryption** tab (加密策略)
|
3. Open the **Encryption** tab (加密策略)
|
||||||
4. Copy **Verification Token**
|
4. Copy **Verification Token** and **Encrypt Key**
|
||||||
|
|
||||||
|
The screenshot below shows where to find the **Verification Token**. The **Encrypt Key** is listed in the same **Encryption** section.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
@ -600,6 +602,7 @@ Key options:
|
||||||
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
|
| `channels.feishu.connectionMode` | Event transport mode | `websocket` |
|
||||||
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
|
| `channels.feishu.defaultAccount` | Default account ID for outbound routing | `default` |
|
||||||
| `channels.feishu.verificationToken` | Required for webhook mode | - |
|
| `channels.feishu.verificationToken` | Required for webhook mode | - |
|
||||||
|
| `channels.feishu.encryptKey` | Required for webhook mode | - |
|
||||||
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
|
| `channels.feishu.webhookPath` | Webhook route path | `/feishu/events` |
|
||||||
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
|
| `channels.feishu.webhookHost` | Webhook bind host | `127.0.0.1` |
|
||||||
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
|
| `channels.feishu.webhookPort` | Webhook bind port | `3000` |
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,35 @@ Notes:
|
||||||
- `onchar` still responds to explicit @mentions.
|
- `onchar` still responds to explicit @mentions.
|
||||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||||
|
|
||||||
|
## Threading and sessions
|
||||||
|
|
||||||
|
Use `channels.mattermost.replyToMode` to control whether channel and group replies stay in the
|
||||||
|
main channel or start a thread under the triggering post.
|
||||||
|
|
||||||
|
- `off` (default): only reply in a thread when the inbound post is already in one.
|
||||||
|
- `first`: for top-level channel/group posts, start a thread under that post and route the
|
||||||
|
conversation to a thread-scoped session.
|
||||||
|
- `all`: same behavior as `first` for Mattermost today.
|
||||||
|
- Direct messages ignore this setting and stay non-threaded.
|
||||||
|
|
||||||
|
Config example:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
mattermost: {
|
||||||
|
replyToMode: "all",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Thread-scoped sessions use the triggering post id as the thread root.
|
||||||
|
- `first` and `all` are currently equivalent because once Mattermost has a thread root,
|
||||||
|
follow-up chunks and media continue in that same thread.
|
||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
|
|
||||||
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code).
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,11 @@ Example:
|
||||||
**Teams + channel allowlist**
|
**Teams + channel allowlist**
|
||||||
|
|
||||||
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
||||||
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
|
- Keys should use stable team IDs and channel conversation IDs.
|
||||||
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
||||||
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
||||||
- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
- On startup, OpenClaw resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
||||||
and logs the mapping; unresolved entries are kept as typed.
|
and logs the mapping; unresolved team/channel names are kept as typed but ignored for routing by default unless `channels.msteams.dangerouslyAllowNameMatching: true` is enabled.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
|
@ -457,7 +457,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: DM allowlist (AAD object IDs recommended). The wizard resolves names to IDs during setup when Graph access is available.
|
||||||
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching.
|
- `channels.msteams.dangerouslyAllowNameMatching`: break-glass toggle to re-enable mutable UPN/display-name matching and direct team/channel name routing.
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
- `channels.msteams.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ If you use the `device-pair` plugin, you can do first-time device pairing entire
|
||||||
The setup code is a base64-encoded JSON payload that contains:
|
The setup code is a base64-encoded JSON payload that contains:
|
||||||
|
|
||||||
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
|
- `url`: the Gateway WebSocket URL (`ws://...` or `wss://...`)
|
||||||
- `token`: a short-lived pairing token
|
- `bootstrapToken`: a short-lived single-device bootstrap token used for the initial pairing handshake
|
||||||
|
|
||||||
Treat the setup code like a password while it is valid.
|
Treat the setup code like a password while it is valid.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,15 +169,15 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||||
- `allowlist`
|
- `allowlist`
|
||||||
- `disabled`
|
- `disabled`
|
||||||
|
|
||||||
Channel allowlist lives under `channels.slack.channels`.
|
Channel allowlist lives under `channels.slack.channels` and should use stable channel IDs.
|
||||||
|
|
||||||
Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
|
Runtime note: if `channels.slack` is completely missing (env-only setup), runtime falls back to `groupPolicy="allowlist"` and logs a warning (even if `channels.defaults.groupPolicy` is set).
|
||||||
|
|
||||||
Name/ID resolution:
|
Name/ID resolution:
|
||||||
|
|
||||||
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
|
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
|
||||||
- unresolved entries are kept as configured
|
- unresolved channel-name entries are kept as configured but ignored for routing by default
|
||||||
- inbound authorization matching is ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
|
- inbound authorization and channel routing are ID-first by default; direct username/slug matching requires `channels.slack.dangerouslyAllowNameMatching: true`
|
||||||
|
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||||
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
|
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
|
||||||
- implicit reply-to-bot thread behavior
|
- implicit reply-to-bot thread behavior
|
||||||
|
|
||||||
Per-channel controls (`channels.slack.channels.<id|name>`):
|
Per-channel controls (`channels.slack.channels.<id>`; names only via startup resolution or `dangerouslyAllowNameMatching`):
|
||||||
|
|
||||||
- `requireMention`
|
- `requireMention`
|
||||||
- `users` (allowlist)
|
- `users` (allowlist)
|
||||||
|
|
|
||||||
|
|
@ -335,9 +335,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||||
|
|
||||||
If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured.
|
If native commands are disabled, built-ins are removed. Custom/plugin commands may still register if configured.
|
||||||
|
|
||||||
Common setup failure:
|
Common setup failures:
|
||||||
|
|
||||||
- `setMyCommands failed` usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
|
- `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the Telegram menu still overflowed after trimming; reduce plugin/skill/custom commands or disable `channels.telegram.commands.native`.
|
||||||
|
- `setMyCommands failed` with network/fetch errors usually means outbound DNS/HTTPS to `api.telegram.org` is blocked.
|
||||||
|
|
||||||
### Device pairing commands (`device-pair` plugin)
|
### Device pairing commands (`device-pair` plugin)
|
||||||
|
|
||||||
|
|
@ -843,7 +844,8 @@ openclaw message poll --channel telegram --target -1001234567890:topic:42 \
|
||||||
|
|
||||||
- authorize your sender identity (pairing and/or numeric `allowFrom`)
|
- authorize your sender identity (pairing and/or numeric `allowFrom`)
|
||||||
- command authorization still applies even when group policy is `open`
|
- command authorization still applies even when group policy is `open`
|
||||||
- `setMyCommands failed` usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
|
- `setMyCommands failed` with `BOT_COMMANDS_TOO_MUCH` means the native menu has too many entries; reduce plugin/skill/custom commands or disable native menus
|
||||||
|
- `setMyCommands failed` with network/fetch errors usually indicates DNS/HTTPS reachability issues to `api.telegram.org`
|
||||||
|
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,13 @@ Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whats
|
||||||
|
|
||||||
### Telegram failure signatures
|
### Telegram failure signatures
|
||||||
|
|
||||||
| Symptom | Fastest check | Fix |
|
| Symptom | Fastest check | Fix |
|
||||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
|
| ----------------------------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
|
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
|
||||||
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
|
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
|
||||||
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
|
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
|
||||||
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
|
| `setMyCommands` rejected at startup | Inspect logs for `BOT_COMMANDS_TOO_MUCH` | Reduce plugin/skill/custom Telegram commands or disable native menus. |
|
||||||
|
| Upgraded and allowlist blocks you | `openclaw security audit` and config allowlists | Run `openclaw doctor --fix` or replace `@username` with numeric sender IDs. |
|
||||||
|
|
||||||
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
|
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,11 +86,13 @@ Approve via:
|
||||||
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
- Restrict to an allowlist with:
|
- Restrict to an allowlist with:
|
||||||
- `channels.zalouser.groupPolicy = "allowlist"`
|
- `channels.zalouser.groupPolicy = "allowlist"`
|
||||||
- `channels.zalouser.groups` (keys are group IDs or names; controls which groups are allowed)
|
- `channels.zalouser.groups` (keys should be stable group IDs; names are resolved to IDs on startup when possible)
|
||||||
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
|
- `channels.zalouser.groupAllowFrom` (controls which senders in allowed groups can trigger the bot)
|
||||||
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
||||||
- The configure wizard can prompt for group allowlists.
|
- The configure wizard can prompt for group allowlists.
|
||||||
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
- On startup, OpenClaw resolves group/user names in allowlists to IDs and logs the mapping.
|
||||||
|
- Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless `channels.zalouser.dangerouslyAllowNameMatching: true` is enabled.
|
||||||
|
- `channels.zalouser.dangerouslyAllowNameMatching: true` is a break-glass compatibility mode that re-enables mutable group-name matching.
|
||||||
- If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks.
|
- If `groupAllowFrom` is unset, runtime falls back to `allowFrom` for group sender checks.
|
||||||
- Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`).
|
- Sender checks apply to both normal group messages and control commands (for example `/new`, `/reset`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,5 @@ openclaw agent --agent ops --message "Generate report" --deliver --reply-channel
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names or `secretref-managed`), not resolved secret plaintext.
|
- When this command triggers `models.json` regeneration, SecretRef-managed provider credentials are persisted as non-secret markers (for example env var names, `secretref-env:ENV_VAR_NAME`, or `secretref-managed`), not resolved secret plaintext.
|
||||||
|
- Marker writes are source-authoritative: OpenClaw persists markers from the active source config snapshot, not from resolved runtime secret values.
|
||||||
|
|
|
||||||
|
|
@ -337,7 +337,7 @@ Options:
|
||||||
- `--non-interactive`
|
- `--non-interactive`
|
||||||
- `--mode <local|remote>`
|
- `--mode <local|remote>`
|
||||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
|
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ollama|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|mistral-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|opencode-go|custom-api-key|skip>`
|
||||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||||
|
|
@ -355,8 +355,8 @@ Options:
|
||||||
- `--minimax-api-key <key>`
|
- `--minimax-api-key <key>`
|
||||||
- `--opencode-zen-api-key <key>`
|
- `--opencode-zen-api-key <key>`
|
||||||
- `--opencode-go-api-key <key>`
|
- `--opencode-go-api-key <key>`
|
||||||
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
|
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`)
|
||||||
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
|
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key` or `--auth-choice ollama`)
|
||||||
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
|
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
|
||||||
- `--custom-provider-id <id>` (non-interactive; optional custom provider id)
|
- `--custom-provider-id <id>` (non-interactive; optional custom provider id)
|
||||||
- `--custom-compatibility <openai|anthropic>` (non-interactive; optional; default `openai`)
|
- `--custom-compatibility <openai|anthropic>` (non-interactive; optional; default `openai`)
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,18 @@ openclaw onboard --non-interactive \
|
||||||
|
|
||||||
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||||
|
|
||||||
|
Non-interactive Ollama:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw onboard --non-interactive \
|
||||||
|
--auth-choice ollama \
|
||||||
|
--custom-base-url "http://ollama-host:11434" \
|
||||||
|
--custom-model-id "qwen3.5:27b" \
|
||||||
|
--accept-risk
|
||||||
|
```
|
||||||
|
|
||||||
|
`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here.
|
||||||
|
|
||||||
Store provider keys as refs instead of plaintext:
|
Store provider keys as refs instead of plaintext:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -83,6 +95,13 @@ openclaw onboard --non-interactive \
|
||||||
--accept-risk
|
--accept-risk
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Non-interactive local gateway health:
|
||||||
|
|
||||||
|
- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully.
|
||||||
|
- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`.
|
||||||
|
- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`.
|
||||||
|
- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied.
|
||||||
|
|
||||||
Interactive onboarding behavior with reference mode:
|
Interactive onboarding behavior with reference mode:
|
||||||
|
|
||||||
- Choose **Use secret reference** when prompted.
|
- Choose **Use secret reference** when prompted.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue