chore: merge main into realtime audio ownership fix

* origin/main: (491 commits)
  feat(gateway): advertise chat attachment limits on hello-ok (#116188)
  fix(ui): preserve full graphemes in session owner initials (#117350)
  refactor(tui): consolidate runtime ownership (#117368)
  fix(update): return failure when dirty checkout blocks update (#117452)
  fix(auto-reply): apply mixed chat directives in one session transaction (#117542)
  refactor(ui): unify channel status and setup controls (#117499)
  fix(doctor): preserve surviving lint selections (#117543)
  fix(media): keep an unquoted MEDIA path with spaces as one media item (#112464)
  refactor(channels): consolidate lightweight plugin discovery (#117541)
  fix(imessage): prevent duplicate messages after delayed sends (#110853)
  refactor(memory): unify authoritative dreaming state and presentation (#117538)
  refactor(agents): consolidate main-session recovery ownership (#117383)
  fix(proxy-capture): make path-based session cleanup atomic (#98852)
  refactor(agents): consolidate compaction and context-engine ownership (#117482)
  fix(gateway): preserve Responses usage details (#117533)
  perf(gateway): skip empty session recovery stores (#117498)
  fix(ci): update canonical Kova performance pin (#117508)
  fix(system-agent): emit the wizard cancel hint once per message (#113731)
  refactor: dedupe secrets runtime snapshot fixtures (#117502)
  test(google): cover ready callback close precedence
  ...
This commit is contained in:
Vincent Koc
2026-08-02 02:41:51 +08:00
2785 changed files with 179738 additions and 264730 deletions
File diff suppressed because it is too large Load Diff
-2
View File
@@ -2,5 +2,3 @@
CLAUDE.md -text
src/gateway/server-methods/CLAUDE.md -text
ui/src/i18n/.i18n/* linguist-generated
ui/src/i18n/locales/*.ts linguist-generated
ui/src/i18n/locales/en.ts -linguist-generated
-172
View File
@@ -1,172 +0,0 @@
name: Docker E2E plan and hydrate
description: >
Create a Docker E2E lane plan, expose GitHub outputs, and optionally hydrate
the prebuilt package artifact plus shared Docker images needed by the plan.
inputs:
mode:
description: prepare, chunk, or targeted.
required: true
chunk:
description: Release-path chunk for mode=chunk.
required: false
default: ""
lanes:
description: Comma/space separated lane names for targeted or prepare mode.
required: false
default: ""
include-openwebui:
description: Whether Open WebUI is included when planning release/prepare coverage.
required: false
default: "true"
include-release-path-suites:
description: Whether prepare mode should plan all release-path suites.
required: false
default: "false"
hydrate-artifacts:
description: Whether to download/pull artifacts required by the plan.
required: false
default: "true"
package-artifact-name:
description: Workflow artifact name containing openclaw-current.tgz.
required: false
default: docker-e2e-package
outputs:
credentials:
description: Comma-separated credential groups required by selected lanes.
value: ${{ steps.plan.outputs.credentials }}
needs_bare_image:
description: "1 when selected lanes require the bare Docker E2E image."
value: ${{ steps.plan.outputs.needs_bare_image }}
needs_e2e_image:
description: "1 when selected lanes require any Docker E2E image."
value: ${{ steps.plan.outputs.needs_e2e_image }}
needs_functional_image:
description: "1 when selected lanes require the functional Docker E2E image."
value: ${{ steps.plan.outputs.needs_functional_image }}
needs_live_image:
description: "1 when selected lanes require building the live Docker image."
value: ${{ steps.plan.outputs.needs_live_image }}
needs_package:
description: "1 when selected lanes require the OpenClaw package tarball."
value: ${{ steps.plan.outputs.needs_package }}
plan_json:
description: Path to the generated plan JSON.
value: ${{ steps.plan.outputs.plan_json }}
runs:
using: composite
steps:
- name: Plan Docker E2E lanes
id: plan
shell: bash
env:
MODE: ${{ inputs.mode }}
CHUNK: ${{ inputs.chunk }}
LANES: ${{ inputs.lanes }}
INCLUDE_OPENWEBUI: ${{ inputs.include-openwebui }}
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include-release-path-suites }}
run: |
set -euo pipefail
mkdir -p .artifacts/docker-tests
case "$MODE" in
prepare)
plan_path=".artifacts/docker-tests/plan.json"
if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL=1
elif [[ -n "$LANES" ]]; then
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
elif [[ "$INCLUDE_OPENWEBUI" == "true" ]]; then
export OPENCLAW_DOCKER_ALL_LANES=openwebui
fi
;;
chunk)
if [[ -z "$CHUNK" ]]; then
echo "chunk input is required for Docker E2E chunk planning." >&2
exit 1
fi
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
export OPENCLAW_DOCKER_ALL_CHUNK="$CHUNK"
plan_path=".artifacts/docker-tests/release-${CHUNK}-plan.json"
;;
targeted)
if [[ -z "$LANES" ]]; then
echo "lanes input is required for Docker E2E targeted planning." >&2
exit 1
fi
if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
fi
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
plan_path=".artifacts/docker-tests/targeted-plan.json"
;;
*)
echo "mode must be prepare, chunk, or targeted. Got: $MODE" >&2
exit 1
;;
esac
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
- name: Download OpenClaw Docker E2E package
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_package == '1'
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: ${{ inputs.package-artifact-name }}
path: .artifacts/docker-e2e-package
- name: Pull shared bare Docker E2E image
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_bare_image == '1'
shell: bash
run: |
set -euo pipefail
bash scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
- name: Pull shared functional Docker E2E image
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_functional_image == '1'
shell: bash
run: |
set -euo pipefail
bash scripts/ci-docker-pull-retry.sh "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
- name: Validate Docker E2E credentials
if: inputs.hydrate-artifacts == 'true'
shell: bash
env:
CREDENTIALS: ${{ steps.plan.outputs.credentials }}
run: |
set -euo pipefail
credentials=",$CREDENTIALS,"
require_any() {
local label="$1"
shift
local key
for key in "$@"; do
if [[ -n "${!key:-}" ]]; then
return 0
fi
done
echo "Missing credential for ${label}: expected one of $*" >&2
exit 1
}
if [[ "$credentials" == *",openai,"* ]]; then
require_any OpenAI OPENAI_API_KEY
fi
if [[ "$credentials" == *",codex,"* ]]; then
require_any Codex OPENCLAW_CODEX_AUTH_JSON
fi
if [[ "$credentials" == *",anthropic,"* ]]; then
require_any Anthropic ANTHROPIC_API_TOKEN ANTHROPIC_API_KEY OPENCLAW_CLAUDE_CREDENTIALS_JSON OPENCLAW_CLAUDE_JSON
fi
if [[ "$credentials" == *",factory,"* ]]; then
require_any Factory FACTORY_API_KEY
fi
if [[ "$credentials" == *",gemini,"* ]]; then
require_any Gemini GEMINI_API_KEY GOOGLE_API_KEY OPENCLAW_GEMINI_SETTINGS_JSON
fi
if [[ "$credentials" == *",opencode,"* ]]; then
require_any OpenCode OPENCODE_API_KEY OPENCODE_ZEN_API_KEY
fi
+29 -4
View File
@@ -1063,6 +1063,7 @@ jobs:
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
core-support-boundary-result: ${{ steps.built_artifact_checks.outputs['core-support-boundary-result'] }}
gateway-watch-result: ${{ steps.built_artifact_checks.outputs['gateway-watch-result'] }}
tui-pty-result: ${{ steps.built_artifact_checks.outputs['tui-pty-result'] }}
steps:
- *linux_node_checkout_step
- name: Ensure secrets base commit (PR fast path)
@@ -1193,6 +1194,7 @@ jobs:
RUN_CHANNELS: ${{ needs.preflight.outputs.run_checks }}
RUN_CORE_SUPPORT_BOUNDARY: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
RUN_GATEWAY_WATCH: ${{ needs.preflight.outputs.run_check_additional }}
RUN_TUI_PTY: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
shell: bash
run: |
set -uo pipefail
@@ -1204,6 +1206,7 @@ jobs:
["channels"]="skipped"
["core-support-boundary"]="skipped"
["gateway-watch"]="skipped"
["tui-pty"]="skipped"
)
start_check() {
@@ -1254,6 +1257,15 @@ jobs:
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
fi
if [ "$RUN_TUI_PTY" = "true" ]; then
start_check "tui-pty" env \
NODE_OPTIONS=--max-old-space-size=8192 \
OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1 \
OPENCLAW_TUI_PTY_USE_BUILT_CLI=1 \
OPENCLAW_VITEST_MAX_WORKERS=2 \
node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
fi
if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then
start_check "gateway-watch" \
node scripts/check-gateway-watch-regression.mjs --skip-build
@@ -1269,12 +1281,12 @@ jobs:
wait_checks
fi
for name in channels core-support-boundary gateway-watch; do
for name in channels core-support-boundary gateway-watch tui-pty; do
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
done
failures=0
for name in channels core-support-boundary gateway-watch; do
for name in channels core-support-boundary gateway-watch tui-pty; do
if [ "${results[$name]}" = "failure" ]; then
echo "::error title=${name} failed::${name} failed"
failures=1
@@ -2266,11 +2278,18 @@ jobs:
# The i18n verify covers keys extracted from every ui/ source,
# not just ui/src/i18n; any ui-touching diff (run_ui_tests) or
# i18n-tooling diff must run it. oxlint always runs.
lint_args=(--threads=8)
if [ "$(nproc)" -lt 8 ]; then
# Fork PRs run on 4-core hosted runners with 16GB. Bound both
# the core process size and its threads so type-aware oxlint
# does not intermittently lose the runner under memory pressure.
lint_args=(--split-core --threads=1)
fi
if [ "$RUN_CONTROL_UI_I18N" = "true" ] || [ "$RUN_UI_TESTS" = "true" ]; then
pnpm lint --threads=8
pnpm lint "${lint_args[@]}"
else
echo "[skip] changed scope cannot affect control-UI i18n catalogs"
node scripts/run-oxlint-shards.mjs --threads=8
node scripts/run-oxlint-shards.mjs "${lint_args[@]}"
fi
if [ "$FORMAT_CHECK" = "true" ]; then
pnpm format:check
@@ -3435,6 +3454,12 @@ jobs:
# workflow-owned as before. Load them from the workflow revision so old targets work.
uses: ./.ci-workflow/.github/actions/setup-android-toolchain
- name: Setup Node environment for native resources
if: needs.preflight.outputs.compatibility_target != 'true'
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
# Same-repo runs carry the Gradle user home (dependency, wrapper, and
# build caches) on a Blacksmith sticky disk: the setup-java gradle cache
# above is evicted so quickly under the repo quota that it rarely
@@ -34,6 +34,11 @@ jobs:
distribution: temurin
java-version: "21"
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
@@ -10,10 +10,13 @@ on:
- ui/src/i18n/.i18n/*
- ui/src/i18n/lib/types.ts
- ui/src/i18n/lib/registry.ts
- ui/config/control-ui-locales.ts
- scripts/control-ui-i18n.ts
- scripts/control-ui-i18n-verify.ts
- scripts/lib/control-ui-i18n-catalog.ts
- scripts/lib/control-ui-i18n-config.ts
- scripts/lib/control-ui-i18n-raw-copy.ts
- scripts/lib/control-ui-i18n-sync-plan.ts
- .github/actions/create-generated-pr-tokens/action.yml
- .github/actions/publish-generated-pr/action.yml
- .github/workflows/control-ui-locale-refresh.yml
@@ -250,8 +253,10 @@ jobs:
set -euo pipefail
artifact_dir="${RUNNER_TEMP}/control-ui-locale-${LOCALE}"
mkdir -p "${artifact_dir}"
git add -A ui/src/i18n
git diff --cached --binary --full-index -- ui/src/i18n ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
locale_memory="ui/src/i18n/.i18n/${LOCALE}.tm.jsonl"
locale_metadata="ui/src/i18n/.i18n/${LOCALE}.meta.json"
git add -A -- "${locale_memory}" "${locale_metadata}"
git diff --cached --binary --full-index -- "${locale_memory}" "${locale_metadata}" ':(exclude)ui/src/i18n/.i18n/catalog-fallbacks.json' > "${artifact_dir}/${LOCALE}.patch"
- name: Upload locale artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
@@ -318,17 +323,25 @@ jobs:
commit-message: "chore(ui): refresh control ui locales"
pr-title: "chore(ui): refresh control ui locales"
auto-merge: "true"
generated-paths: ui/src/i18n
generated-paths: |
ui/src/i18n/.i18n/*.tm.jsonl
ui/src/i18n/.i18n/*.meta.json
ui/src/i18n/.i18n/catalog-fallbacks.json
invalidation-paths: |
ui/src/i18n/locales/*.ts
ui/src/i18n/locales/en.ts
ui/src/i18n/locales/en-agents.ts
ui/src/i18n/.i18n/glossary.*.json
ui/src/i18n/.i18n/raw-copy-baseline.json
ui/src/i18n/lib/types.ts
ui/src/i18n/lib/registry.ts
ui/config/control-ui-locales.ts
scripts/control-ui-i18n.ts
scripts/control-ui-i18n-verify.ts
scripts/lib/control-ui-i18n-catalog.ts
scripts/lib/control-ui-i18n-config.ts
scripts/lib/control-ui-i18n-raw-copy.ts
scripts/lib/control-ui-i18n-sync-plan.ts
.github/actions/create-generated-pr-tokens/action.yml
.github/actions/publish-generated-pr/action.yml
.github/workflows/control-ui-locale-refresh.yml
+66 -44
View File
@@ -1,25 +1,26 @@
name: Docker Release
on:
push:
tags:
- "v*"
- "!v*-alpha.*"
paths-ignore:
- "docs/**"
- "**/*.md"
- "**/*.mdx"
- ".agents/**"
- "skills/**"
workflow_dispatch:
workflow_call:
inputs:
tag:
description: Existing stable, extended-stable, or beta release tag
description: Immutable stable, extended-stable, or beta release tag
required: true
type: string
release_sha:
description: Full immutable commit SHA resolved from tag
required: true
type: string
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('docker-release-manual-{0}', inputs.tag) || 'docker-release-publish' }}
# Alias promotion checks are read-then-write; serialize every Docker publish
# so an older stable run cannot overwrite a newer latest/main promotion.
group: docker-release-publish
cancel-in-progress: false
queue: max
@@ -31,15 +32,15 @@ env:
DOCKERHUB_IMAGE_NAME: openclaw/openclaw
jobs:
validate_manual_backfill:
if: github.event_name == 'workflow_dispatch'
validate_release_identity:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Validate tag input format
- name: Validate immutable tag and SHA inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_SHA: ${{ inputs.release_sha }}
run: |
set -euo pipefail
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
@@ -50,16 +51,38 @@ jobs:
echo "Invalid release tag: ${RELEASE_TAG}"
exit 1
fi
if [[ ! "${RELEASE_SHA}" =~ ^[a-f0-9]{40}$ ]]; then
echo "Release SHA must be a full lowercase commit SHA."
exit 1
fi
- name: Checkout selected tag
- name: Checkout immutable release tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
persist-credentials: false
- name: Verify tag, SHA, and package identity agree
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_SHA: ${{ inputs.release_sha }}
run: |
set -euo pipefail
tag_sha="$(git rev-parse "refs/tags/${RELEASE_TAG}^{commit}")"
checkout_sha="$(git rev-parse HEAD)"
package_version="$(node -p "require('./package.json').version")"
if [[ "${tag_sha}" != "${RELEASE_SHA}" || "${checkout_sha}" != "${RELEASE_SHA}" ]]; then
echo "Release tag ${RELEASE_TAG} must resolve to supplied SHA ${RELEASE_SHA}; got ${tag_sha}." >&2
exit 1
fi
if [[ "v${package_version}" != "${RELEASE_TAG}" && ! "${RELEASE_TAG}" =~ ^v${package_version}-[1-9][0-9]*$ ]]; then
echo "Release tag ${RELEASE_TAG} does not match package.json version ${package_version} or its correction-tag form." >&2
exit 1
fi
resolve_release_policy:
needs: validate_manual_backfill
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.validate_manual_backfill.result == 'success') }}
needs: validate_release_identity
runs-on: ubuntu-24.04
permissions:
contents: read
@@ -78,7 +101,7 @@ jobs:
id: policy
shell: bash
env:
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
if [[ "${SOURCE_REF}" != refs/tags/v* ]]; then
@@ -98,20 +121,19 @@ jobs:
echo "- Channel: ${channel}"
} >> "$GITHUB_STEP_SUMMARY"
approve_manual_backfill:
name: Approve Docker backfill ${{ inputs.tag }}
if: github.event_name == 'workflow_dispatch'
needs: [validate_manual_backfill, resolve_release_policy]
# WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT.
approve_docker_publish:
name: Approve Docker publication ${{ inputs.tag }}
needs: [validate_release_identity, resolve_release_policy]
# Docker publication remains protected even though only release orchestration can call it.
runs-on: ubuntu-24.04
environment: docker-release
permissions: {}
steps:
- name: Approve Docker backfill
- name: Approve Docker publication
env:
RELEASE_TAG: ${{ inputs.tag }}
run: |
echo "Approved immutable Docker image backfill for ${RELEASE_TAG}"
echo "Approved immutable Docker image publication for ${RELEASE_TAG}"
validate_publish_config:
runs-on: ubuntu-24.04
@@ -132,8 +154,8 @@ jobs:
echo "Docker Hub publishing configured for ${DOCKERHUB_IMAGE}."
resolve_build_provenance:
needs: [approve_manual_backfill, resolve_release_policy, validate_publish_config]
if: ${{ always() && needs.resolve_release_policy.result == 'success' && needs.validate_publish_config.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
needs: [approve_docker_publish, resolve_release_policy, validate_publish_config]
if: ${{ always() && needs.approve_docker_publish.result == 'success' && needs.resolve_release_policy.result == 'success' && needs.validate_publish_config.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: read
@@ -144,7 +166,7 @@ jobs:
- name: Checkout selected source
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.sha }}
ref: ${{ inputs.release_sha }}
fetch-depth: 0
- name: Resolve shared build provenance
@@ -159,8 +181,8 @@ jobs:
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
# Build amd64 image. Default and slim tags point to the same slim runtime.
build-amd64:
needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance]
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
needs: [approve_docker_publish, validate_publish_config, resolve_build_provenance]
if: ${{ always() && needs.approve_docker_publish.result == 'success' && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' }}
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04
permissions:
@@ -219,7 +241,7 @@ jobs:
env:
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
tags=()
@@ -259,7 +281,7 @@ jobs:
env:
BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}
SOURCE_SHA: ${{ needs.resolve_build_provenance.outputs.source_sha }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
source_sha="${SOURCE_SHA}"
@@ -386,8 +408,8 @@ jobs:
# Build arm64 image. Default and slim tags point to the same slim runtime.
build-arm64:
needs: [approve_manual_backfill, validate_publish_config, resolve_build_provenance]
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
needs: [approve_docker_publish, validate_publish_config, resolve_build_provenance]
if: ${{ always() && needs.approve_docker_publish.result == 'success' && needs.validate_publish_config.result == 'success' && needs.resolve_build_provenance.result == 'success' }}
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04-arm
permissions:
@@ -427,7 +449,7 @@ jobs:
env:
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
tags=()
@@ -467,7 +489,7 @@ jobs:
env:
BUILD_TIMESTAMP: ${{ needs.resolve_build_provenance.outputs.built_at }}
SOURCE_SHA: ${{ needs.resolve_build_provenance.outputs.source_sha }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
source_sha="${SOURCE_SHA}"
@@ -596,14 +618,14 @@ jobs:
create-manifest:
needs:
[
approve_manual_backfill,
approve_docker_publish,
resolve_release_policy,
validate_publish_config,
resolve_build_provenance,
build-amd64,
build-arm64,
]
if: ${{ always() && needs.validate_publish_config.result == 'success' && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
if: ${{ always() && needs.approve_docker_publish.result == 'success' && needs.validate_publish_config.result == 'success' && needs.build-amd64.result == 'success' && needs.build-arm64.result == 'success' }}
# WARNING: DO NOT REVERT THIS TO A BLACKSMITH RUNNER WITHOUT RE-VALIDATING TAG BACKFILLS.
runs-on: ubuntu-24.04
permissions:
@@ -636,7 +658,7 @@ jobs:
env:
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
tags=()
@@ -683,7 +705,7 @@ jobs:
shell: bash
env:
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
TAGS: ${{ steps.tags.outputs.value }}
BROWSER_TAGS: ${{ steps.tags.outputs.browser }}
DOCKERHUB_TAGS: ${{ steps.tags.outputs.dockerhub }}
@@ -765,7 +787,7 @@ jobs:
env:
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
SOURCE_REF: ${{ format('refs/tags/{0}', inputs.tag) }}
run: |
set -euo pipefail
multi_refs=()
@@ -880,7 +902,7 @@ jobs:
"${dockerhub_arm64_refs[@]}"
- name: Promote and verify channel aliases
if: ${{ github.event_name != 'workflow_dispatch' && needs.resolve_release_policy.outputs.channel != 'beta' }}
if: ${{ needs.resolve_release_policy.outputs.channel != 'beta' }}
env:
VERSION: ${{ needs.resolve_release_policy.outputs.version }}
GHCR_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+1
View File
@@ -6,6 +6,7 @@ on:
- main
paths:
- docs/**
- scripts/docs-list.js
- scripts/docs-sync-publish.mjs
- .github/workflows/docs-sync-publish.yml
workflow_dispatch:
@@ -808,7 +808,9 @@ jobs:
needs: [resolve_target, evidence_reuse, prepare_release_candidate]
if: ${{ always() && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'beta' && 240 || 60 }}
# The child owns lane timeouts; this monitor must also tolerate queue delay
# so it does not cancel healthy release checks before their final verifier.
timeout-minutes: 240
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
-3
View File
@@ -110,7 +110,6 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build Linux companion bundles
working-directory: apps/linux/src-tauri
@@ -185,7 +184,6 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build macOS test bundles
working-directory: apps/linux/src-tauri
@@ -253,7 +251,6 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Build Windows test bundle
working-directory: apps/linux/src-tauri
+5 -1
View File
@@ -67,7 +67,6 @@ jobs:
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
install-deps: "false"
- name: Check Rust formatting
working-directory: apps/linux/src-tauri
@@ -122,6 +121,11 @@ jobs:
- name: Install Rust
run: rustup toolchain install stable --profile minimal
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Cache Cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
@@ -307,7 +307,6 @@ jobs:
auto-merge: "true"
generated-paths: |
apps/.i18n/native
apps/.i18n/apple-translation-contradictions.json
apps/android/app/src/main/java/ai/openclaw/app/i18n/NativeStringResources.kt
apps/android/app/src/main/res/values*/assistant.xml
apps/android/app/src/main/res/values*/strings.xml
@@ -886,6 +886,8 @@ jobs:
runs-on: ${{ inputs.use_github_hosted_runners && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_VITEST_MAX_WORKERS: "2"
steps:
- name: Checkout selected ref
+1 -1
View File
@@ -70,7 +70,7 @@ env:
OCM_VERSION: v0.2.29
OCM_LINUX_X64_SHA256: d966098d6ba2bc10891be3c76e162a37b07f28c4f51da75d2eb509886eb7e1cf
KOVA_REPOSITORY: openclaw/Kova
KOVA_CANONICAL_CONFIG_REF: 517952b835640a368c4af6dfe6dc8365ae841b57
KOVA_CANONICAL_CONFIG_REF: 283070760a16655b28835061774158b8b11b4aff
KOVA_LEGACY_LIST_CONFIG_REF: f3d037b5b8aacd6adf8ef1dd2ea4c1d778ec7c6c
PERFORMANCE_MODEL_ID: gpt-5.6-luna
# Release matrices cold-build the candidate runtime before measurement.
+107 -4
View File
@@ -44,6 +44,7 @@ on:
- alpha
- beta
- latest
- extended-stable
plugin_publish_scope:
description: Plugin publish scope to run before OpenClaw publish
required: true
@@ -61,6 +62,11 @@ on:
required: true
default: true
type: boolean
publish_docker_only:
description: Publish Docker only after independently verifying an already-published extended-stable npm package
required: true
default: false
type: boolean
release_profile:
description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest
required: false
@@ -98,6 +104,7 @@ jobs:
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
preflight_tarball_sha256: ${{ steps.manifest.outputs.tarball_sha256 }}
full_release_validation_run_attempt: ${{ steps.full_run.outputs.attempt }}
windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}
steps:
@@ -112,6 +119,7 @@ jobs:
WINDOWS_NODE_TAG: ${{ inputs.windows_node_tag }}
WINDOWS_NODE_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PUBLISH_DOCKER_ONLY: ${{ inputs.publish_docker_only && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
@@ -133,7 +141,7 @@ jobs:
exit 1
fi
release_evidence_required=false
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" || "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" ]]; then
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" || "${PUBLISH_DOCKER_ONLY}" == "true" || "${PLUGIN_PUBLISH_SCOPE}" == "all-publishable" ]]; then
release_evidence_required=true
fi
release_evidence_supplied=false
@@ -166,6 +174,20 @@ jobs:
echo "openclaw_npm_resume_run_id requires publish_openclaw_npm=true." >&2
exit 1
fi
if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "publish_docker_only requires publish_openclaw_npm=false." >&2
exit 1
fi
if [[ "${RELEASE_NPM_DIST_TAG}" != "extended-stable" ]]; then
echo "publish_docker_only is reserved for an already-published extended-stable release." >&2
exit 1
fi
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "Extended-stable core npm publication stays on the canonical extended-stable release flow; use publish_docker_only=true only after its registry readback." >&2
exit 1
fi
stable_release=true
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
stable_release=false
@@ -453,6 +475,7 @@ jobs:
exit 1
fi
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
echo "tarball_sha256=$tarball_sha256" >> "$GITHUB_OUTPUT"
- name: Validate full release validation manifest
id: full_manifest
@@ -520,7 +543,8 @@ jobs:
set -euo pipefail
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
'+refs/heads/release/*:refs/remotes/origin/release/*' \
'+refs/heads/extended-stable/*:refs/remotes/origin/extended-stable/*'
if git merge-base --is-ancestor HEAD origin/main; then
exit 0
fi
@@ -529,6 +553,11 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
while IFS= read -r release_ref; do
if git merge-base --is-ancestor HEAD "${release_ref}"; then
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/extended-stable)
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
@@ -539,7 +568,7 @@ jobs:
exit 0
fi
fi
echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
echo "Release tag must point to a commit reachable from main, release/*, extended-stable/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
exit 1
- name: Summarize release target
@@ -567,6 +596,7 @@ jobs:
publish:
name: Publish plugins, then OpenClaw
needs: [resolve_release_target]
if: ${{ !inputs.publish_docker_only }}
permissions:
actions: write
attestations: write
@@ -2331,7 +2361,7 @@ jobs:
upload_release_evidence_assets
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
echo "- GitHub release: kept draft until Docker publication succeeds" >> "$GITHUB_STEP_SUMMARY"
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
@@ -2347,3 +2377,76 @@ jobs:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: error
verify_core_npm_registry:
name: Verify already-published core npm package
needs: [resolve_release_target]
if: ${{ inputs.publish_docker_only }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Verify exact npm and selector readback matches preflight bytes
env:
RELEASE_TAG: ${{ inputs.tag }}
EXPECTED_TARBALL_SHA256: ${{ needs.resolve_release_target.outputs.preflight_tarball_sha256 }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
exact_version="$(npm view "openclaw@${version}" version)"
selector_version="$(npm view openclaw@extended-stable version)"
if [[ "${exact_version}" != "${version}" || "${selector_version}" != "${version}" ]]; then
echo "npm exact-version or extended-stable selector readback does not match ${version}." >&2
exit 1
fi
tarball_url="$(npm view "openclaw@${version}" dist.tarball)"
tarball_path="${RUNNER_TEMP}/openclaw-${version}.tgz"
curl -fsSL --connect-timeout 10 --max-time 120 --retry 3 --retry-max-time 180 \
-o "${tarball_path}" "${tarball_url}"
actual_tarball_sha256="$(sha256sum "${tarball_path}" | awk '{print $1}')"
if [[ -z "${EXPECTED_TARBALL_SHA256}" || "${actual_tarball_sha256}" != "${EXPECTED_TARBALL_SHA256}" ]]; then
echo "Published npm tarball does not match the release preflight artifact." >&2
exit 1
fi
publish_docker:
name: Publish Docker images
needs: [resolve_release_target, publish, verify_core_npm_registry]
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.verify_core_npm_registry.result == 'success')) }}
uses: ./.github/workflows/docker-release.yml
with:
tag: ${{ inputs.tag }}
release_sha: ${{ needs.resolve_release_target.outputs.sha }}
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
permissions:
contents: read
packages: write
finalize_github_release:
name: Finalize GitHub release
needs: [publish, publish_docker]
if: ${{ always() && inputs.publish_openclaw_npm && needs.publish.result == 'success' && needs.publish_docker.result == 'success' }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: write
steps:
- name: Publish the verified draft release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
expected_prerelease=false
if [[ "${RELEASE_TAG}" == *"-alpha."* || "${RELEASE_TAG}" == *"-beta."* ]]; then
expected_prerelease=true
fi
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --draft=false
release_json="$(gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease)"
if [[ "$(printf '%s' "${release_json}" | jq -r '.isDraft')" != "false" ]] || \
[[ "$(printf '%s' "${release_json}" | jq -r '.isPrerelease')" != "${expected_prerelease}" ]]; then
echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2
exit 1
fi
@@ -2551,7 +2551,7 @@ jobs:
' "$proof_path" >/dev/null
done
- name: Finalize trusted Telegram process-boundary evidence
- name: Finalize trusted Telegram execution evidence
id: finalize_boundary
if: always() && steps.terminate_sut.outputs.quiescent == 'true' && steps.run_lane.outputs.output_dir != ''
env:
@@ -2574,13 +2574,14 @@ jobs:
jq -e \
--arg runId "$GITHUB_RUN_ID" \
--argjson runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg targetSha "$TARGET_SHA" \
'
.version == 1 and
.kind == "telegram-sut-boundary" and
.runId == $runId and
.runAttempt == $runAttempt and
(.workflowSha | test("^[a-f0-9]{40}$")) and
(.targetSha | test("^[a-f0-9]{40}$")) and
.targetSha == $targetSha and
(.candidateArtifact.id | test("^[1-9][0-9]*$")) and
(.candidateArtifact.name | length > 0) and
(.candidateArtifact.digest | test("^[a-f0-9]{64}$")) and
@@ -2588,15 +2589,24 @@ jobs:
.candidateArtifact.runAttempt == $runAttempt and
(.candidateArtifact.fileName | length > 0) and
(.candidateArtifact.sha256 | test("^[a-f0-9]{64}$")) and
(.candidateArtifact.sourceSha | test("^[a-f0-9]{40}$")) and
.candidateArtifact.sourceSha == $targetSha and
(.candidateArtifact.version | length > 0)
' "$context_path" >/dev/null
# This frozen extended-stable candidate predates the boundary protocol.
# Every other target fails closed until it emits the required evidence.
legacy_process_boundary_target_sha="2dbfe013e511d0c7e0720356f5af5c7bb210db19"
if [[ "$TARGET_SHA" == "$legacy_process_boundary_target_sha" ]]; then
jq --arg executionMode "legacy-runner" \
# The archive and source tree were independently attested before this step.
# A tracked contract opts legacy candidates into the pre-boundary evidence shape.
legacy_contract_path="${CANDIDATE_ROOT}/qa/contracts/telegram-execution-evidence.json"
if [[ -e "$legacy_contract_path" || -L "$legacy_contract_path" ]]; then
[[ -f "$legacy_contract_path" && ! -L "$legacy_contract_path" ]]
candidate_version="$(jq -er '.candidateArtifact.version' "$context_path")"
jq -e --arg candidateVersion "$candidate_version" '
(keys | sort) == ["candidateVersion", "kind", "mode", "version"] and
.version == 1 and
.kind == "openclaw-release-telegram-execution-evidence" and
.mode == "legacy-direct-runner-v1" and
.candidateVersion == $candidateVersion
' "$legacy_contract_path" >/dev/null
jq --arg executionMode "legacy-direct-runner-v1" \
'. + {executionMode: $executionMode}' "$context_path" >"$aggregate_path"
chmod 0600 "$aggregate_path"
echo "aggregate_path=$aggregate_path" >>"$GITHUB_OUTPUT"
@@ -293,6 +293,7 @@ jobs:
OPENCLAW_QA_CREDENTIAL_ACQUIRE_TIMEOUT_MS: "120000"
OPENCLAW_QA_CREDENTIAL_ROLE: ci
OPENCLAW_QA_CREDENTIAL_SOURCE: convex
OPENCLAW_QA_ALLOW_UPDATE_RUN_SELF: "1"
shell: bash
run: |
set -euo pipefail
+160 -1
View File
@@ -38,7 +38,7 @@ on:
default: false
type: boolean
run_windows_ci:
description: "Run the focused Windows-native CI test shard after probing"
description: "Run the focused Windows CI shard and native Scheduled Task proof"
required: false
default: false
type: boolean
@@ -281,6 +281,165 @@ jobs:
export PATH="$NODE_BIN:$PATH"
pnpm test:windows:ci
- name: Preflight native Scheduled Task session
if: ${{ inputs.run_windows_ci }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
$isAdmin = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$sessionId = (Get-Process -Id $PID).SessionId
Write-Host "identity=$($identity.Name)"
Write-Host "session_id=$sessionId"
Write-Host "user_interactive=$([Environment]::UserInteractive)"
Write-Host "administrator=$isAdmin"
query user 2>&1 | Write-Host
if (-not [Environment]::UserInteractive) {
throw "Native Scheduled Task proof requires an interactive Windows runner session."
}
- name: Run native Scheduled Task lifecycle proof
id: native_schtasks
if: ${{ inputs.run_windows_ci }}
timeout-minutes: 5
shell: bash
env:
CI_WINDOWS_SCHTASKS_PROOF_PATH: ${{ github.workspace }}\.artifacts\windows-schtasks\proof.json
CI_WINDOWS_SCHTASKS_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
CI_WINDOWS_SCHTASKS_TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
EXPECTED_HEAD: ${{ inputs.target_ref }}
run: |
set -euo pipefail
export PATH="$NODE_BIN:$PATH"
if [[ ! "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then
echo "Native Scheduled Task proof requires target_ref to be an exact 40-character commit SHA." >&2
exit 1
fi
CI_WINDOWS_SCHTASKS_HEAD="$(git rev-parse HEAD)"
if [[ "$CI_WINDOWS_SCHTASKS_HEAD" != "$EXPECTED_HEAD" ]]; then
echo "Checked out $CI_WINDOWS_SCHTASKS_HEAD, expected frozen target $EXPECTED_HEAD." >&2
exit 1
fi
export CI_WINDOWS_SCHTASKS_HEAD
mkdir -p .artifacts/windows-schtasks
pnpm test:windows:schtasks:integration
- name: Clean native Scheduled Task residue
id: native_cleanup
if: ${{ always() && inputs.run_windows_ci }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$ErrorActionPreference = "Continue"
$cleanupErrors = @()
$profile = "schtasks-int-$env:TEST_ID"
$taskName = "OpenClaw Gateway ($profile)"
$stateDir = Join-Path $env:USERPROFILE ".openclaw-$profile"
New-Item -ItemType Directory -Force -Path $env:TEST_ROOT | Out-Null
schtasks.exe /End /TN $taskName 2>$null
Start-Sleep -Milliseconds 200
$activePidPath = Join-Path $env:TEST_ROOT "active-pid.txt"
if (Test-Path -LiteralPath $activePidPath) {
try {
$probePid = 0
$activePid = (Get-Content -LiteralPath $activePidPath -Raw).Trim()
if (-not [int]::TryParse($activePid, [ref]$probePid) -or $probePid -le 1) {
throw "Invalid Scheduled Task active process id: $activePid"
}
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not inspect Scheduled Task probe process $probePid."
}
if ($process) {
$probePath = Join-Path $env:TEST_ROOT "probe.cjs"
$eventsPath = Join-Path $env:TEST_ROOT "runs.txt"
if (
$process.CommandLine -like "*$probePath*" -and
$process.CommandLine -like "*$eventsPath*"
) {
taskkill.exe /F /T /PID $probePid 2>$null
$deadline = [DateTime]::UtcNow.AddSeconds(30)
do {
Start-Sleep -Milliseconds 200
$processQueryError = @()
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $probePid" -ErrorAction SilentlyContinue -ErrorVariable processQueryError
if ($processQueryError.Count -gt 0) {
throw "Could not verify Scheduled Task probe process $probePid exited."
}
} while ($process -and [DateTime]::UtcNow -lt $deadline)
if ($process) {
throw "Scheduled Task probe process $probePid survived cleanup."
}
} else {
throw "Refusing to kill reused or unverifiable process id $probePid."
}
}
} catch {
$cleanupErrors += $_.Exception.Message
}
}
schtasks.exe /Delete /F /TN $taskName 2>$null
$deleteExit = $LASTEXITCODE
try {
$service = New-Object -ComObject "Schedule.Service"
$service.Connect()
$null = $service.GetFolder("\").GetTask($taskName)
$taskExists = $true
} catch {
$exception = $_.Exception
while ($null -ne $exception.InnerException) {
$exception = $exception.InnerException
}
if ($exception.HResult -eq -2147024894 -or $exception.HResult -eq -2147024893) {
$taskExists = $false
} else {
$cleanupErrors += "Could not verify Scheduled Task cleanup for $taskName (HRESULT $($exception.HResult))."
$taskExists = $null
}
}
if ($taskExists -eq $true) {
$cleanupErrors += "Scheduled Task cleanup left $taskName registered (delete exit $deleteExit)."
}
@(
"task_name=$taskName"
"delete_exit=$deleteExit"
"task_exists=$taskExists"
"proof_outcome=${{ steps.native_schtasks.outcome }}"
"cleanup_errors=$($cleanupErrors -join ' ')"
) | Set-Content -LiteralPath (Join-Path $env:TEST_ROOT "cleanup-summary.txt")
if ($cleanupErrors.Count -gt 0) {
throw ($cleanupErrors -join " ")
}
exit 0
- name: Upload native Scheduled Task proof
id: native_proof_upload
if: ${{ always() && inputs.run_windows_ci }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: windows-schtasks-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: |
.artifacts/windows-schtasks/proof.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\failure-diagnostics.json
${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}\cleanup-summary.txt
if-no-files-found: warn
retention-days: 7
- name: Remove retained native Scheduled Task evidence
if: ${{ always() && inputs.run_windows_ci && steps.native_cleanup.outcome == 'success' && steps.native_proof_upload.outcome == 'success' }}
shell: pwsh
env:
TEST_ID: ${{ github.run_id }}-${{ github.run_attempt }}
TEST_ROOT: ${{ runner.temp }}\openclaw-schtasks-${{ github.run_id }}-${{ github.run_attempt }}
run: |
$profile = "schtasks-int-$env:TEST_ID"
Remove-Item -LiteralPath (Join-Path $env:USERPROFILE ".openclaw-$profile") -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $env:TEST_ROOT -Recurse -Force -ErrorAction SilentlyContinue
- name: Keep runner alive for SSH inspection
if: ${{ always() && !cancelled() }}
env:
-1
View File
@@ -50,7 +50,6 @@ apps/swabble/Package.resolved
**/ModuleCache/
bin/
bin/clawdbot-mac
bin/docs-list
apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/
+4
View File
@@ -58,6 +58,9 @@ Docs: https://docs.openclaw.ai
### Fixes
- **Control UI archived session deletion:** send archive-gated delete requests from Sessions-page row and mixed-selection actions so write-scoped operators can remove archived threads while active-session deletion remains admin-only. Thanks @shakkernerd.
- **Control UI command recovery:** keep delayed detached and immediate command failures scoped to their submitting session, preserving failed drafts and attachments for that pane without overwriting the active session. Fixes #116846. Thanks @shakkernerd.
- **Microsoft Teams message-tool replies:** keep automatic live previews from duplicating a message already delivered to the current Teams conversation, while preserving distinct follow-up text and cross-conversation sends. Fixes #116397. (#116398) Thanks @a-tokyo.
- **Buzz plugin packaging:** keep the live QA runner on the shipped QA runner SDK surface and remove the obsolete package shrinkwrap so standalone npm and ClawHub package builds use current host exports and dependency resolutions. Thanks @shakkernerd.
- **Control UI sharing connection isolation:** discard stale visibility and membership mutation results after switching gateways or accounts so previous-connection refreshes and errors cannot update the replacement connection. Fixes #116800. Thanks @shakkernerd.
- **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd.
@@ -362,6 +365,7 @@ Docs: https://docs.openclaw.ai
- **Signal native quote replies:** preserve the active inbound message as a native quote across agent, explicit, durable, and chunked sends while keeping reply-mode policy inside the Signal plugin. (#105347) Thanks @jesse-merhi.
- **Media-store remote downloads:** bound response-header waits and stalled bodies, close abandoned redirect and error responses, and remove partial temp files so hung sources cannot pin callers. (#104624) Thanks @hugenshen.
- **Cron llama.cpp tool schemas:** keep the model-facing cron declaration schema compatible with llama.cpp while retaining gateway and runtime nonblank validation. Fixes #107449. (#108360) Thanks @lee-xydt.
- **System-agent recovery guidance:** direct browser and app users to Settings or the OpenClaw host instead of terminal-only exit guidance while preserving the required stop, onboard, and restart lifecycle. (#114633) Thanks @jesse-merhi.
## 2026.7.1
File diff suppressed because it is too large Load Diff
+73 -73
View File
@@ -10291,7 +10291,7 @@
},
{
"kind": "ui-call",
"line": 63,
"line": 52,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt",
"source": "Search sessions",
"surface": "android",
@@ -10299,7 +10299,7 @@
},
{
"kind": "ui-call",
"line": 75,
"line": 78,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt",
"source": "Clear session search",
"surface": "android",
@@ -10307,7 +10307,7 @@
},
{
"kind": "ui-call",
"line": 224,
"line": 227,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt",
"source": "Working",
"surface": "android",
@@ -10315,7 +10315,7 @@
},
{
"kind": "ui-call",
"line": 225,
"line": 228,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt",
"source": "Needs attention",
"surface": "android",
@@ -10323,7 +10323,7 @@
},
{
"kind": "ui-call",
"line": 226,
"line": 229,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt",
"source": "Selected",
"surface": "android",
@@ -10331,7 +10331,7 @@
},
{
"kind": "ui-call",
"line": 73,
"line": 78,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Home",
"surface": "android",
@@ -10339,7 +10339,7 @@
},
{
"kind": "ui-call",
"line": 74,
"line": 79,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Overview",
"surface": "android",
@@ -10347,7 +10347,7 @@
},
{
"kind": "ui-call",
"line": 75,
"line": 80,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Usage",
"surface": "android",
@@ -10355,7 +10355,7 @@
},
{
"kind": "ui-call",
"line": 76,
"line": 81,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Automations",
"surface": "android",
@@ -10363,7 +10363,7 @@
},
{
"kind": "ui-call",
"line": 77,
"line": 82,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Threads",
"surface": "android",
@@ -10371,7 +10371,7 @@
},
{
"kind": "ui-named-argument",
"line": 216,
"line": 224,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "OpenClaw",
"surface": "android",
@@ -10379,7 +10379,7 @@
},
{
"kind": "ui-call",
"line": 225,
"line": 250,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Open Settings",
"surface": "android",
@@ -10387,7 +10387,7 @@
},
{
"kind": "ui-call",
"line": 234,
"line": 259,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Hide Sidebar",
"surface": "android",
@@ -10395,7 +10395,7 @@
},
{
"kind": "ui-call",
"line": 249,
"line": 282,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Agents",
"surface": "android",
@@ -10403,7 +10403,7 @@
},
{
"kind": "ui-call",
"line": 261,
"line": 294,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "More Agents",
"surface": "android",
@@ -10411,7 +10411,7 @@
},
{
"kind": "ui-call",
"line": 291,
"line": 324,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Pages",
"surface": "android",
@@ -10419,7 +10419,7 @@
},
{
"kind": "ui-call",
"line": 301,
"line": 334,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "Recent sessions",
"surface": "android",
@@ -10427,7 +10427,7 @@
},
{
"kind": "ui-call",
"line": 304,
"line": 337,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarContent.kt",
"source": "No recent sessions",
"surface": "android",
@@ -32051,7 +32051,7 @@
},
{
"kind": "ui-named-argument",
"line": 246,
"line": 268,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard reconnecting",
"surface": "apple",
@@ -32059,7 +32059,7 @@
},
{
"kind": "ui-named-argument",
"line": 247,
"line": 269,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "The selected Gateway changed.",
"surface": "apple",
@@ -32067,7 +32067,7 @@
},
{
"kind": "ui-named-argument",
"line": 248,
"line": 270,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Waiting for a fresh authenticated connection.",
"surface": "apple",
@@ -32075,7 +32075,7 @@
},
{
"kind": "ui-named-argument",
"line": 404,
"line": 458,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Dashboard unavailable",
"surface": "apple",
@@ -32083,7 +32083,7 @@
},
{
"kind": "ui-named-argument",
"line": 406,
"line": 460,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Check Settings → Connection or use Debug → Reset Remote Tunnel, then try again.",
"surface": "apple",
@@ -32091,7 +32091,7 @@
},
{
"kind": "ui-named-argument",
"line": 612,
"line": 640,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Switch Gateway",
"surface": "apple",
@@ -32099,7 +32099,7 @@
},
{
"kind": "ui-named-argument",
"line": 640,
"line": 668,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Open Gateway Window",
"surface": "apple",
@@ -32107,7 +32107,7 @@
},
{
"kind": "conditional-branch",
"line": 744,
"line": 775,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "\\(base)-\\(UUID().uuidString)",
"surface": "apple",
@@ -32115,7 +32115,7 @@
},
{
"kind": "ui-named-argument",
"line": 984,
"line": 1129,
"path": "apps/macos/Sources/OpenClaw/DashboardManager.swift",
"source": "Could Not Set Primary Gateway",
"surface": "apple",
@@ -32123,7 +32123,7 @@
},
{
"kind": "conditional-branch",
"line": 903,
"line": 898,
"path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift",
"source": "[\\(host)]",
"surface": "apple",
@@ -34939,7 +34939,7 @@
},
{
"kind": "conditional-branch",
"line": 748,
"line": 749,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Finish",
"surface": "apple",
@@ -34947,7 +34947,7 @@
},
{
"kind": "conditional-branch",
"line": 748,
"line": 749,
"path": "apps/macos/Sources/OpenClaw/Onboarding.swift",
"source": "Next",
"surface": "apple",
@@ -36939,7 +36939,7 @@
},
{
"kind": "ui-localized-call",
"line": 311,
"line": 312,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw updated",
"surface": "apple",
@@ -36947,7 +36947,7 @@
},
{
"kind": "ui-localized-call",
"line": 330,
"line": 331,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Finishing your OpenClaw update",
"surface": "apple",
@@ -36955,7 +36955,7 @@
},
{
"kind": "ui-localized-call",
"line": 331,
"line": 332,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Checking the Mac app and Gateway…",
"surface": "apple",
@@ -36963,7 +36963,7 @@
},
{
"kind": "ui-localized-call",
"line": 439,
"line": 440,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway recovery failed.",
"surface": "apple",
@@ -36971,7 +36971,7 @@
},
{
"kind": "ui-localized-call",
"line": 440,
"line": 441,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The managed OpenClaw runtime could not be reinstalled.",
"surface": "apple",
@@ -36979,7 +36979,7 @@
},
{
"kind": "ui-localized-call",
"line": 447,
"line": 448,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Restarting and verifying the Gateway…",
"surface": "apple",
@@ -36987,7 +36987,7 @@
},
{
"kind": "ui-localized-call",
"line": 448,
"line": 449,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Verifying the Mac node runtime…",
"surface": "apple",
@@ -36995,7 +36995,7 @@
},
{
"kind": "ui-localized-call",
"line": 462,
"line": 463,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Letting your agent know youre back…",
"surface": "apple",
@@ -37003,7 +37003,7 @@
},
{
"kind": "ui-localized-call",
"line": 494,
"line": 495,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Welcome back",
"surface": "apple",
@@ -37011,7 +37011,7 @@
},
{
"kind": "ui-localized-call",
"line": 496,
"line": 497,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw \\(receipt.toVersion) and its Gateway are ready.",
"surface": "apple",
@@ -37019,7 +37019,7 @@
},
{
"kind": "ui-localized-call",
"line": 497,
"line": 498,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw \\(receipt.toVersion) and its Mac node runtime are ready.",
"surface": "apple",
@@ -37027,7 +37027,7 @@
},
{
"kind": "ui-localized-call",
"line": 500,
"line": 501,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Your agent could not be notified yet. OpenClaw will retry after the next app launch.",
"surface": "apple",
@@ -37035,7 +37035,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 504,
"line": 505,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not notify your agent automatically. The app and Gateway update are complete.",
"surface": "apple",
@@ -37043,7 +37043,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 510,
"line": 511,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not notify your agent automatically. The app and Mac node update are complete.",
"surface": "apple",
@@ -37051,7 +37051,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 517,
"line": 518,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not confirm the agent notification. It will not retry, to avoid a duplicate welcome.",
"surface": "apple",
@@ -37059,7 +37059,7 @@
},
{
"kind": "ui-localized-call",
"line": 523,
"line": 524,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The remote Gateway is older than this Mac app, so OpenClaw skipped the agent notification.",
"surface": "apple",
@@ -37067,7 +37067,7 @@
},
{
"kind": "ui-localized-call",
"line": 525,
"line": 526,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway remains paused, so OpenClaw did not wake your agent.",
"surface": "apple",
@@ -37075,7 +37075,7 @@
},
{
"kind": "ui-localized-call",
"line": 553,
"line": 554,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway verification failed.",
"surface": "apple",
@@ -37083,7 +37083,7 @@
},
{
"kind": "ui-localized-call",
"line": 554,
"line": 555,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The managed runtime does not match the updated Mac app.",
"surface": "apple",
@@ -37091,7 +37091,7 @@
},
{
"kind": "ui-localized-call",
"line": 560,
"line": 561,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node did not restart.",
"surface": "apple",
@@ -37099,7 +37099,7 @@
},
{
"kind": "ui-localized-call",
"line": 566,
"line": 567,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node did not become ready.",
"surface": "apple",
@@ -37107,7 +37107,7 @@
},
{
"kind": "ui-localized-call",
"line": 567,
"line": 568,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The node service restarted but did not remain running.",
"surface": "apple",
@@ -37115,7 +37115,7 @@
},
{
"kind": "ui-localized-call",
"line": 578,
"line": 579,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway did not start.",
"surface": "apple",
@@ -37123,7 +37123,7 @@
},
{
"kind": "ui-localized-call",
"line": 579,
"line": 580,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The update is installed, but Gateway health did not become ready.",
"surface": "apple",
@@ -37131,7 +37131,7 @@
},
{
"kind": "ui-localized-call",
"line": 591,
"line": 592,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway could not reconnect.",
"surface": "apple",
@@ -37139,7 +37139,7 @@
},
{
"kind": "ui-localized-call",
"line": 593,
"line": 594,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw installed the update but could not verify the Gateway connection.",
"surface": "apple",
@@ -37147,7 +37147,7 @@
},
{
"kind": "ui-localized-call",
"line": 798,
"line": 799,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Gateway could not be checked.",
"surface": "apple",
@@ -37155,7 +37155,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 800,
"line": 801,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not read the Gateway service ownership record. Retry after checking the Gateway LaunchAgent.",
"surface": "apple",
@@ -37163,7 +37163,7 @@
},
{
"kind": "ui-localized-call",
"line": 806,
"line": 807,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "The Mac node could not be checked.",
"surface": "apple",
@@ -37171,7 +37171,7 @@
},
{
"kind": "ui-localized-call-multiline",
"line": 808,
"line": 809,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "OpenClaw could not read the node service ownership record. Retry after checking the node LaunchAgent.",
"surface": "apple",
@@ -37179,7 +37179,7 @@
},
{
"kind": "ui-localized-call",
"line": 819,
"line": 820,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Gateway update needs help",
"surface": "apple",
@@ -37187,7 +37187,7 @@
},
{
"kind": "ui-call",
"line": 876,
"line": 877,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Update guide",
"surface": "apple",
@@ -37195,7 +37195,7 @@
},
{
"kind": "ui-call",
"line": 877,
"line": 878,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Ask Discord",
"surface": "apple",
@@ -37203,7 +37203,7 @@
},
{
"kind": "ui-call",
"line": 879,
"line": 880,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Retry",
"surface": "apple",
@@ -37211,7 +37211,7 @@
},
{
"kind": "ui-call",
"line": 885,
"line": 886,
"path": "apps/macos/Sources/OpenClaw/PostUpdate.swift",
"source": "Continue",
"surface": "apple",
@@ -40243,7 +40243,7 @@
},
{
"kind": "ui-call",
"line": 1011,
"line": 1037,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Loading commands",
"surface": "apple",
@@ -40251,7 +40251,7 @@
},
{
"kind": "ui-call",
"line": 1020,
"line": 1046,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Commands unavailable",
"surface": "apple",
@@ -40259,7 +40259,7 @@
},
{
"kind": "ui-call",
"line": 1029,
"line": 1055,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Retry",
"surface": "apple",
@@ -40267,7 +40267,7 @@
},
{
"kind": "ui-call",
"line": 1038,
"line": 1064,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "No matching commands",
"surface": "apple",
@@ -40275,7 +40275,7 @@
},
{
"kind": "ui-modifier",
"line": 1270,
"line": 1296,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Stop response",
"surface": "apple",
@@ -40283,7 +40283,7 @@
},
{
"kind": "ui-modifier",
"line": 1295,
"line": 1321,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Send message",
"surface": "apple",
@@ -40291,7 +40291,7 @@
},
{
"kind": "ui-modifier",
"line": 1309,
"line": 1335,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Refresh",
"surface": "apple",
@@ -40299,7 +40299,7 @@
},
{
"kind": "conditional-branch",
"line": 1435,
"line": 1461,
"path": "apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift",
"source": "Message…",
"surface": "apple",
+1
View File
@@ -28,6 +28,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O
## Open in Android Studio
- Run `pnpm install` from the repository root so native Canvas resources can be generated.
- Open the folder `apps/android`.
## Wear OS companion
+67
View File
@@ -1,12 +1,25 @@
import com.android.build.api.variant.impl.VariantOutputImpl
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.Properties
import javax.inject.Inject
val dnsjavaInetAddressResolverService = "META-INF/services/java.net.spi.InetAddressResolverProvider"
val openClawAndroidVersionFile = rootProject.file("Config/Version.properties")
val thirdPartyLicensesDir = rootProject.file("THIRD_PARTY_LICENSES")
val openClawRepositoryRoot = rootProject.projectDir.resolve("../..").canonicalFile
val canvasA2uiAssetsDir = layout.buildDirectory.dir("generated/canvasA2uiAssets")
val openClawAndroidVersionProperties =
Properties().apply {
if (!openClawAndroidVersionFile.isFile) {
@@ -119,6 +132,56 @@ plugins {
alias(libs.plugins.ksp)
}
abstract class StageCanvasA2uiTask
@Inject
constructor(
private val execOperations: ExecOperations,
) : DefaultTask() {
@get:Internal abstract val repoRoot: DirectoryProperty
@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceFiles: ConfigurableFileCollection
@get:OutputDirectory abstract val outputDirectory: DirectoryProperty
@TaskAction
fun stage() {
val root = repoRoot.get().asFile
execOperations.exec {
workingDir(root)
commandLine(
"node",
"scripts/sync-native-a2ui.mjs",
"--write",
"--output",
outputDirectory
.get()
.dir("CanvasA2UI")
.asFile.absolutePath,
)
}
}
}
val stageCanvasA2ui =
tasks.register<StageCanvasA2uiTask>("stageCanvasA2ui") {
group = "build"
description = "Stages the plugin-owned Canvas A2UI renderer for native apps."
repoRoot.set(openClawRepositoryRoot)
sourceFiles.from(
openClawRepositoryRoot.resolve("package.json"),
openClawRepositoryRoot.resolve("pnpm-lock.yaml"),
openClawRepositoryRoot.resolve("scripts/bundle-a2ui.mjs"),
openClawRepositoryRoot.resolve("scripts/sync-native-a2ui.mjs"),
openClawRepositoryRoot.resolve("extensions/canvas/package.json"),
openClawRepositoryRoot.resolve("extensions/canvas/scripts/bundle-a2ui.mjs"),
openClawRepositoryRoot.resolve("extensions/canvas/src/host/a2ui/index.html"),
)
sourceFiles.from(openClawRepositoryRoot.resolve("extensions/canvas/src/host/a2ui-app"))
outputDirectory.set(canvasA2uiAssetsDir)
}
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
@@ -269,6 +332,10 @@ android {
androidComponents {
onVariants { variant ->
variant.sources.assets?.addGeneratedSourceDirectory(
stageCanvasA2ui,
StageCanvasA2uiTask::outputDirectory,
)
variant.outputs
.filterIsInstance<VariantOutputImpl>()
.forEach { output ->
@@ -48,6 +48,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
internal fun sidebarSearchLabel(): String = nativeString("Search sessions")
@Composable
internal fun SidebarSearchField(
query: String,
@@ -60,7 +63,7 @@ internal fun SidebarSearchField(
onValueChange = onQueryChange,
modifier = modifier.fillMaxWidth().testTag("sidebar-search"),
singleLine = true,
label = { Text(nativeString("Search sessions")) },
label = { Text(sidebarSearchLabel()) },
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
@@ -27,6 +27,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.outlined.AccessTime
@@ -39,6 +40,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -47,9 +49,12 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.semantics
@@ -187,7 +192,10 @@ internal fun OpenClawSidebar(
val palette = sidebarPalette()
val roster = sidebarAgentRoster(agents, selectedAgentId)
var query by rememberSaveable { mutableStateOf("") }
var isSearchActive by rememberSaveable { mutableStateOf(false) }
var agentsExpanded by remember { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val recentSessions = sidebarRecentSessions(sessions, query)
val connectionLabel = gatewayStatusLabel(connection)
@@ -219,6 +227,23 @@ internal fun OpenClawSidebar(
modifier = Modifier.weight(1f),
maxLines = 1,
)
IconButton(
onClick = {
isSearchActive = !isSearchActive
if (!isSearchActive) {
query = ""
focusManager.clearFocus()
}
},
modifier = Modifier.size(48.dp).testTag("sidebar-search-toggle"),
) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = sidebarSearchLabel(),
tint = palette.text,
modifier = Modifier.size(20.dp),
)
}
IconButton(onClick = onOpenSettings, modifier = Modifier.size(48.dp)) {
Icon(
imageVector = Icons.Default.Settings,
@@ -239,12 +264,20 @@ internal fun OpenClawSidebar(
}
}
SidebarSearchField(
query = query,
onQueryChange = { query = it },
palette = palette,
modifier = Modifier.padding(top = 4.dp, bottom = 12.dp),
)
if (isSearchActive) {
LaunchedEffect(searchFocusRequester) {
searchFocusRequester.requestFocus()
}
SidebarSearchField(
query = query,
onQueryChange = { query = it },
palette = palette,
modifier =
Modifier
.focusRequester(searchFocusRequester)
.padding(top = 4.dp, bottom = 12.dp),
)
}
SidebarSectionTitle(nativeString("Agents"), palette)
roster.selected?.let { selected ->
+2 -2
View File
@@ -1684,9 +1684,9 @@ private func overrideNotificationServingPreference(_ enabled: Bool) -> () -> Voi
}
appModel._test_setUnifiedExecApprovalGetResponse(makePendingExecApprovalJSON(approvalID))
await appModel._test_reconcileWatchExecApprovalCache(reason: "operator_reconnected")
let deadline = ContinuousClock().now.advanced(by: .seconds(2))
let deadline = ContinuousClock().now.advanced(by: .seconds(10))
while await !writeGate.hasStarted(), ContinuousClock().now < deadline {
await Task.yield()
try await Task.sleep(for: .milliseconds(10))
}
let writeCount = await writeGate.callCount()
#expect(writeCount == 1)
+34 -11
View File
@@ -429,22 +429,23 @@ final class OpenClawSnapshotUITests: XCTestCase {
initialDestination: "chat",
name: "chat-composer-growth"))
let textField = try XCTUnwrap(app?.textFields["chat-message-input"])
let app = try XCTUnwrap(self.app)
let textField = self.chatMessageInput(in: app)
XCTAssertTrue(textField.waitForExistence(timeout: 8))
let talkButton = try XCTUnwrap(app?.buttons["chat-realtime-control"])
let talkButton = app.buttons["chat-realtime-control"]
XCTAssertTrue(talkButton.waitForExistence(timeout: 5))
let attachmentButton = try XCTUnwrap(app?.buttons["chat-attachment-picker"])
let attachmentButton = app.buttons["chat-attachment-picker"]
XCTAssertTrue(attachmentButton.waitForExistence(timeout: 5))
let dictationButton = try XCTUnwrap(app?.buttons["chat-dictation-control"])
let dictationButton = app.buttons["chat-dictation-control"]
XCTAssertTrue(dictationButton.waitForExistence(timeout: 5))
let composerSurface = try XCTUnwrap(app?.otherElements["chat-composer-surface"])
let composerSurface = app.otherElements["chat-composer-surface"]
XCTAssertTrue(composerSurface.waitForExistence(timeout: 5))
let agentIdentity = try self.agentIdentity(in: XCTUnwrap(self.app))
let agentIdentity = self.agentIdentity(in: app)
XCTAssertTrue(agentIdentity.waitForExistence(timeout: 5))
XCTAssertEqual(agentIdentity.value as? String, "Collapsed")
agentIdentity.tap()
self.waitForValue("Expanded", of: agentIdentity)
let sendButton = try XCTUnwrap(app?.buttons["chat-send-message"])
let sendButton = app.buttons["chat-send-message"]
XCTAssertFalse(sendButton.exists)
XCTAssertLessThanOrEqual(agentIdentity.frame.maxY, composerSurface.frame.minY)
XCTAssertGreaterThanOrEqual(attachmentButton.frame.minX, composerSurface.frame.minX)
@@ -483,6 +484,24 @@ final class OpenClawSnapshotUITests: XCTestCase {
XCTAssertTrue(self.app?.keyboards.firstMatch.waitForNonExistence(timeout: 3) == true)
}
func testChatComposerReturnInsertsNewlineWithoutSending() throws {
self.launchApp(for: ScreenshotTarget(
initialTab: "chat",
initialDestination: "chat",
name: "chat-composer-return"))
let app = try XCTUnwrap(self.app)
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText("first line\nsecond line")
XCTAssertEqual(input.value as? String, "first line\nsecond line")
XCTAssertTrue(app.buttons["chat-send-message"].waitForExistence(timeout: 3))
XCTAssertFalse(app.staticTexts["first line\nsecond line"].exists)
self.attachScreenshot(named: "chat-composer-return")
}
func testVoiceNoteDraftKeepsStopAvailableDuringActiveResponse() throws {
try XCTSkipIf(UIDevice.current.userInterfaceIdiom != .phone, "Phone voice-note composer proof only")
self.launchApp(
@@ -493,7 +512,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
additionalArguments: ["--openclaw-hold-initial-chat-run"])
let app = try XCTUnwrap(self.app)
let input = app.textFields["chat-message-input"]
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText("Keep this response running while I record a voice note.")
@@ -546,7 +565,7 @@ final class OpenClawSnapshotUITests: XCTestCase {
name: "keyboard-follow"))
let app = try XCTUnwrap(self.app)
let input = app.textFields["chat-message-input"]
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(
@@ -1303,7 +1322,7 @@ extension OpenClawSnapshotUITests {
expecting replyMarker: String,
in app: XCUIApplication)
{
let input = app.textFields["chat-message-input"]
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(text)
@@ -1449,7 +1468,7 @@ extension OpenClawSnapshotUITests {
XCTFail("Fixture app is unavailable")
return
}
let input = app.textFields["chat-message-input"]
let input = self.chatMessageInput(in: app)
XCTAssertTrue(input.waitForExistence(timeout: 8))
input.tap()
input.typeText(text)
@@ -1474,6 +1493,10 @@ extension OpenClawSnapshotUITests {
add(attachment)
}
private func chatMessageInput(in app: XCUIApplication) -> XCUIElement {
app.descendants(matching: .any)["chat-message-input"]
}
private func attachFullScreenScreenshot(named name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name
+16
View File
@@ -106,6 +106,22 @@ targets:
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"
"$SRCROOT/../../scripts/check-swift-tools.sh" swiftlint
swiftlint lint --strict --config "$SRCROOT/.swiftlint.yml" --use-script-input-file-lists
postBuildScripts:
- name: Stage Canvas A2UI resources
basedOnDependencyAnalysis: false
script: |
set -euo pipefail
export PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"
repo_root="$(cd "$SRCROOT/../.." && pwd -P)"
resource_product="$BUILT_PRODUCTS_DIR/OpenClawKit_OpenClawKit.bundle"
resource_bundle="$TARGET_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/OpenClawKit_OpenClawKit.bundle"
test -d "$resource_product"
test -d "$resource_bundle"
node "$repo_root/scripts/sync-native-a2ui.mjs" \
--write \
--output "$resource_product/CanvasA2UI"
rm -rf "$resource_bundle/CanvasA2UI"
cp -R "$resource_product/CanvasA2UI" "$resource_bundle/CanvasA2UI"
settings:
base:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+7 -2
View File
@@ -33,9 +33,11 @@ workflow instead of relying on GStreamer packages from the user's system.
## Develop and build
The frontend is static HTML, CSS, and JavaScript. It has no package install or build step.
The companion frontend is static HTML, CSS, and JavaScript. The shared Canvas A2UI renderer is
generated from the Canvas plugin, so install repository dependencies once before building:
```bash
pnpm install
cd apps/linux/src-tauri
cargo run
cargo build
@@ -55,7 +57,10 @@ The companion checks the latest GitHub release shortly after launch and from **C
The running app gives the headless `openclaw node run` host a single Canvas WebView. The bundled `linux-canvas` plugin advertises `canvas.*` only while the app socket exists. The app listens at `$XDG_RUNTIME_DIR/openclaw-canvas.sock` (or `/tmp/openclaw-canvas-$UID.sock`) with mode `0600`; a headless Linux node without the app does not advertise Canvas.
The plugin-generated A2UI renderer in `extensions/canvas/src/host/a2ui/` remains the source of truth. The app embeds its committed, synced OpenClawKit mirror from `apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/`. Run `node scripts/sync-native-a2ui.mjs --check` from the repository root after changing those assets.
The Canvas plugin sources remain the source of truth for the A2UI renderer. Each native build
generates `index.html` and `a2ui.bundle.js` into its isolated build output before compiling. Run
`node scripts/sync-native-a2ui.mjs --check` from the repository root to verify fresh bundles are
byte-identical and every native build owner is wired.
## Quick Chat widgets
+41
View File
@@ -1,4 +1,8 @@
use std::path::PathBuf;
use std::process::Command;
fn main() {
stage_canvas_a2ui();
link_macos_swift_runtime();
// Command metadata generates capability permissions independently of the
// target's invoke handler, so keep the Linux-only command permission known.
@@ -22,6 +26,43 @@ fn main() {
.expect("Tauri build configuration should be valid");
}
fn stage_canvas_a2ui() {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..");
let output_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo must set OUT_DIR"))
.join("canvas-a2ui");
for input in [
"package.json",
"pnpm-lock.yaml",
"scripts/bundle-a2ui.mjs",
"scripts/sync-native-a2ui.mjs",
"extensions/canvas/package.json",
"extensions/canvas/scripts/bundle-a2ui.mjs",
"extensions/canvas/src/host/a2ui/index.html",
"extensions/canvas/src/host/a2ui-app",
] {
println!("cargo:rerun-if-changed={}", repo_root.join(input).display());
}
let status = Command::new("node")
.args(["scripts/sync-native-a2ui.mjs", "--write", "--output"])
.arg(&output_dir)
.current_dir(&repo_root)
.status()
.expect("Canvas A2UI staging requires Node.js; run pnpm install from the repository root");
assert!(
status.success(),
"Canvas A2UI resource staging failed; run pnpm install from the repository root"
);
println!(
"cargo:rustc-env=OPENCLAW_CANVAS_A2UI_INDEX_HTML={}",
output_dir.join("index.html").display()
);
println!(
"cargo:rustc-env=OPENCLAW_CANVAS_A2UI_BUNDLE_JS={}",
output_dir.join("a2ui.bundle.js").display()
);
}
/// tauri-plugin-notifications links a Swift static library into us, but nothing
/// adds an rpath for the Swift runtime it pulls in. Bundled apps get one from
/// the bundler; plain `cargo run` and `cargo test` binaries do not, so they die
+2 -6
View File
@@ -30,12 +30,8 @@ const A2UI_READY_TIMEOUT: Duration = Duration::from_secs(6);
const A2UI_READY_INTERVAL: Duration = Duration::from_millis(100);
const A2UI_READY_EVAL_TIMEOUT: Duration = Duration::from_millis(100);
const A2UI_INDEX: &[u8] = include_bytes!(
"../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/index.html"
);
const A2UI_BUNDLE: &[u8] = include_bytes!(
"../../../../apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js"
);
const A2UI_INDEX: &[u8] = include_bytes!(env!("OPENCLAW_CANVAS_A2UI_INDEX_HTML"));
const A2UI_BUNDLE: &[u8] = include_bytes!(env!("OPENCLAW_CANVAS_A2UI_BUNDLE_JS"));
const ACTION_BRIDGE_SCRIPT: &str = r#"
(() => {
@@ -3,20 +3,7 @@ import AppKit
@MainActor
enum AppNavigationActions {
static func openDashboard() {
NSApp.activate(ignoringOtherApps: true)
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
DashboardManager.shared.presentDashboard()
}
static func openChat(sessionKey: String? = nil, agentID: String? = nil, draft: String? = nil) {
@@ -14,6 +14,7 @@ extension CanvasWindowController {
defer: false)
window.title = "OpenClaw Canvas"
window.isReleasedWhenClosed = false
window.isRestorable = false
window.contentView = contentView
window.center()
window.minSize = NSSize(width: 880, height: 680)
@@ -24,21 +24,28 @@ final class DashboardManager {
let displayName: String
}
private struct SupersededDashboardPresentation: Error {}
@ObservationIgnored private var controller: DashboardWindowController?
@ObservationIgnored private var mainTarget = DashboardGatewayTarget.primary
@ObservationIgnored private var auxiliaryWindows: [UUID: AuxiliaryWindowInstance] = [:]
@ObservationIgnored private var auxiliaryWindowOrder: [UUID] = []
@ObservationIgnored private var endpointTask: Task<Void, Never>?
@ObservationIgnored private var presentationTask: Task<Void, Error>?
@ObservationIgnored private var pendingOpenCommands: [DashboardNativeCommand] = []
@ObservationIgnored private var openForCommandTask: Task<Void, Never>?
@ObservationIgnored private var navigationGeneration: UInt64 = 0
@ObservationIgnored private var updater: UpdaterProviding?
@ObservationIgnored private var displayedRouteRevision: UInt64?
@ObservationIgnored private var displayedRouteAuthority: UInt64?
@ObservationIgnored private var endpointGeneration: UInt64 = 0
@ObservationIgnored private var presentationGeneration: UInt64 = 0
@ObservationIgnored private var switchGenerations: [ObjectIdentifier: UInt64] = [:]
@ObservationIgnored private let authTokenProvider: @Sendable (GatewayConnection.Config) async -> String?
@ObservationIgnored private let routeProbe: @Sendable () async -> Void
@ObservationIgnored private let endpointStateProvider: @Sendable () async -> GatewayEndpointState
@ObservationIgnored private let mainWindowAutosaveName: String
@ObservationIgnored private let observesGatewayChanges: Bool
private(set) var gatewayEntries: [DashboardGatewayEntry] = []
private(set) var frontmostDashboardTarget: DashboardGatewayTarget?
@ObservationIgnored private var gatewayRefreshObservers: [NSObjectProtocol] = []
@@ -72,6 +79,7 @@ final class DashboardManager {
self.routeProbe = routeProbe
self.endpointStateProvider = endpointStateProvider
self.mainWindowAutosaveName = mainWindowAutosaveName
self.observesGatewayChanges = observeGatewayChanges
if observeGatewayChanges {
let names: [Notification.Name] = [
MacGatewayProfileStore.didChangeNotification,
@@ -111,10 +119,10 @@ final class DashboardManager {
private func handleControlChannelStateChange(_ state: ControlChannel.ConnectionState) async {
guard state == .connected else { return }
// Endpoint readiness can precede device authentication. Replay the
// unchanged route once the control socket owns a usable credential.
// Endpoint readiness can precede device authentication. Reconcile the
// existing document after auth arrives without inventing a route change.
let endpointState = await self.endpointStateProvider()
await self.handleEndpointState(endpointState, forceRouteReplacement: true)
await self.handleEndpointState(endpointState)
}
func configure(updater: UpdaterProviding) {
@@ -140,7 +148,7 @@ final class DashboardManager {
/// the dashboard stays open; without following endpoint changes the WebView
/// keeps reconnecting to the dead old port forever (#100476).
private func observeEndpointChanges() {
guard self.endpointTask == nil else { return }
guard self.observesGatewayChanges, self.endpointTask == nil else { return }
self.endpointTask = Task { [weak self] in
let stream = await GatewayEndpointStore.shared.subscribe()
for await state in stream {
@@ -150,28 +158,30 @@ final class DashboardManager {
}
}
func handleEndpointState(
_ state: GatewayEndpointState,
forceRouteReplacement: Bool = false) async
{
func handleEndpointState(_ state: GatewayEndpointState) async {
// The shared endpoint stream owns only the main window's primary route.
// Profile-targeted documents keep their saved endpoint and credentials.
guard self.mainTarget == .primary else { return }
self.endpointGeneration &+= 1
let generation = self.endpointGeneration
guard let controller, controller.isWindowOpen else { return }
guard case let .ready(mode, url, token, password, routeRevision) = state else {
self.replaceWithRouteFailure(controller)
if controller.currentURL != Self.failureURL || controller.auth.hasCredential {
self.replaceWithRouteFailure(controller)
}
self.displayedRouteRevision = nil
self.displayedRouteAuthority = nil
return
}
let config: GatewayConnection.Config = (url, token, password)
let tlsParams = Self.primaryTLSParams(for: config, mode: mode)
let routeChanged = forceRouteReplacement ||
(self.displayedRouteRevision.map { $0 != routeRevision }
?? (routeRevision > 0) || !controller.hasTLSParams(tlsParams))
var authToken = await self.authTokenProvider(config)
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
if authToken == nil, password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty == nil {
await self.routeProbe()
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
authToken = await self.authTokenProvider(config)
guard self.endpointTransitionIsCurrent(generation, controller: controller) else { return }
}
guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(
for: config,
@@ -184,7 +194,13 @@ final class DashboardManager {
gatewayUrl: Self.websocketURLString(for: dashboardURL),
token: authToken,
password: password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
if routeChanged {
let routeChanged = self.displayedRouteRevision.map { $0 != routeRevision }
?? (routeRevision > 0) || !controller.hasTLSParams(tlsParams)
let credentialChanged = controller.auth.token != auth.token || controller.auth.password != auth.password
if routeChanged || credentialChanged {
if routeChanged {
self.displayedRouteAuthority = nil
}
self.displayedRouteRevision = routeRevision
guard auth.hasCredential else {
self.replaceWithRouteFailure(controller)
@@ -215,37 +231,60 @@ final class DashboardManager {
url: URL,
auth: DashboardWindowAuth,
mode: AppState.ConnectionMode,
tlsParams: GatewayTLSParams?)
tlsParams: GatewayTLSParams?,
present: Bool = false)
{
guard self.controller === current else { return }
self.switchGenerations[ObjectIdentifier(current)] = nil
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let window = current.detachWindowForReplacement()
let replacement = DashboardWindowController(
url: url,
auth: auth,
updater: self.updater,
updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode),
tlsParams: tlsParams,
gatewaySnapshot: self.snapshot(for: .primary))
gatewaySnapshot: self.snapshot(for: .primary),
reusingWindow: window)
self.controller = replacement
replacement.show(url: url, auth: auth)
replacement.loadInBackground(url: url, auth: auth)
if present {
replacement.show()
}
}
private func replaceWithRouteFailure(_ current: DashboardWindowController) {
guard self.controller === current else { return }
self.switchGenerations[ObjectIdentifier(current)] = nil
current.releaseFrameAutosaveForReplacement()
current.closeDashboard()
let window = current.detachWindowForReplacement()
let replacement = DashboardWindowController(
url: Self.failureURL,
auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil),
updater: self.updater,
updateBridgeEnabled: false,
gatewaySnapshot: self.snapshot(for: .primary))
gatewaySnapshot: self.snapshot(for: .primary),
reusingWindow: window)
self.controller = replacement
replacement.showFailure(
title: "Dashboard reconnecting",
message: "The selected Gateway changed.",
detail: "Waiting for a fresh authenticated connection.")
detail: "Waiting for a fresh authenticated connection.",
present: false)
}
func presentDashboard() {
if self.showConfiguredWindowIfPossible() {
return
}
guard self.presentationTask == nil else { return }
let presentation = self.currentPresentationTask()
Task { @MainActor [weak self] in
do {
try await presentation.value
} catch {
guard !Task.isCancelled, !presentation.isCancelled, let self else { return }
self.showFailure(error)
}
}
}
@discardableResult
@@ -268,13 +307,15 @@ final class DashboardManager {
guard auth.hasCredential else {
return false
}
if let controller, !controller.hasTLSParams(endpoint.tls?.params) {
self.endpointGeneration &+= 1
if let controller, self.requiresIsolatedDashboardDocument(controller, auth: auth, endpoint: endpoint) {
self.replaceController(
controller,
url: url,
auth: auth,
mode: mode,
tlsParams: endpoint.tls?.params)
tlsParams: endpoint.tls?.params,
present: true)
} else if let controller {
controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
} else {
@@ -287,6 +328,7 @@ final class DashboardManager {
self.controller = controller
controller.show(url: url, auth: auth)
}
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
Task { await self.refreshGatewaySnapshots() }
Task { _ = try? await ControlChannel.shared.health(timeout: 3) }
@@ -313,41 +355,52 @@ final class DashboardManager {
controller.loadInBackground(url: url, auth: auth)
}
func show() async throws {
if let controller, self.mainTarget != .primary {
if controller.isWindowOpen {
controller.show()
await self.refreshGatewaySnapshots()
return
}
await self.switchTarget(self.mainTarget, in: controller, forceReload: true, present: true)
return
}
private func showResolvedPrimaryDashboard() async throws {
let mode = AppStateStore.shared.connectionMode
self.endpointGeneration &+= 1
let generation = self.endpointGeneration
let originalController = self.controller
dashboardManagerLogger.info("dashboard show requested mode=\(String(describing: mode), privacy: .public)")
let endpoint = try await self.primaryEndpoint(mode: mode)
let endpoint: GatewayConnection.EndpointSnapshot
do {
endpoint = try await self.primaryEndpoint(mode: mode)
} catch {
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
throw error
}
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
let config = endpoint.config
dashboardManagerLogger.info("dashboard config url=\(config.url.absoluteString, privacy: .public)")
let token = await GatewayConnection.shared.controlUiAutoAuthToken(config: config)
let token = await self.authTokenProvider(config)
guard self.presentationIsCurrent(generation, controller: originalController) else {
throw SupersededDashboardPresentation()
}
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: token)
let auth = DashboardWindowAuth(
gatewayUrl: Self.websocketURLString(for: url),
token: token,
password: config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
if let controller, !controller.hasTLSParams(endpoint.tls?.params) {
if let controller, self.requiresIsolatedDashboardDocument(controller, auth: auth, endpoint: endpoint) {
self.replaceController(
controller,
url: url,
auth: auth,
mode: mode,
tlsParams: endpoint.tls?.params)
tlsParams: endpoint.tls?.params,
present: true)
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
await self.refreshGatewaySnapshots()
return
} else if let controller {
dashboardManagerLogger.info("dashboard reuse window url=\(dashboardLogString(for: url), privacy: .public)")
controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode))
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
await self.refreshGatewaySnapshots()
return
@@ -363,6 +416,7 @@ final class DashboardManager {
gatewaySnapshot: self.snapshot(for: .primary))
self.controller = controller
controller.show(url: url, auth: auth)
self.rememberPresentedEndpoint(endpoint)
self.observeEndpointChanges()
await self.refreshGatewaySnapshots()
@@ -407,6 +461,14 @@ final class DashboardManager {
}
func close() {
self.endpointGeneration &+= 1
self.presentationGeneration &+= 1
self.presentationTask?.cancel()
self.presentationTask = nil
self.navigationGeneration &+= 1
self.openForCommandTask?.cancel()
self.openForCommandTask = nil
self.pendingOpenCommands.removeAll()
self.switchGenerations.removeAll()
self.controller?.closeDashboard()
let controllers = self.auxiliaryWindows.values.map(\.controller)
@@ -418,34 +480,6 @@ final class DashboardManager {
self.frontmostDashboardTarget = nil
}
func handleOnboardingCompletion() {
self.controller?.handleOnboardingCompletion()
}
func navigateBack() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateBack()
}
func navigateForward() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateForward()
}
func handleGatewayRequest(_ request: DashboardGatewaysRequest, from source: DashboardWindowController) {
switch request {
case let .select(target):
Task { await self.switchTarget(target, in: source) }
case let .openWindow(target):
Task { await self.openWindow(for: target) }
case let .setPrimary(target):
guard self.target(for: source) == target else { return }
self.presentSetPrimaryConfirmation(target, source: source)
case .openSettings:
AppNavigationActions.openSettings(tab: .gateways)
}
}
func dispatchNativeCommand(_ command: DashboardNativeCommand) {
if command.supersedesPendingNavigation {
// This also invalidates a handoff still suspended in show(atPath:).
@@ -467,6 +501,7 @@ final class DashboardManager {
do {
try await self.show()
} catch {
guard !Task.isCancelled else { return }
// Commands are moment-bound; drop them with the failed open.
self.pendingOpenCommands = []
self.showFailure(error)
@@ -562,45 +597,38 @@ final class DashboardManager {
// explicit show/open callers opt back into presentation.
let shouldPresent = present ?? source.isWindowOpen
if self.controller === source {
let frame = source.window?.frame
if self.mainTarget == .primary, target != .primary {
self.displayedRouteRevision = nil
self.displayedRouteAuthority = nil
}
source.releaseFrameAutosaveForReplacement()
source.closeDashboard()
let windowAutosaveName = self.availableAutosaveName(for: target, replacing: source)
let window = source.detachWindowForReplacement()
self.mainTarget = target
let replacement = self.makeController(
configuration: configuration,
target: target,
windowAutosaveName: self.availableAutosaveName(for: target, replacing: source),
auxiliary: false)
// In-place switches preserve the frame the user is viewing;
// target autosaves seed only newly opened windows.
if let frame { replacement.window?.setFrame(frame, display: false) }
windowAutosaveName: windowAutosaveName,
auxiliary: false,
reusingWindow: window)
self.controller = replacement
if shouldPresent {
replacement.show(url: configuration.url, auth: configuration.auth)
} else {
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
if shouldPresent, present == true || !replacement.isWindowOpen {
replacement.show()
}
} else if let windowID = self.auxiliaryWindows.first(where: { $0.value.controller === source })?.key {
let frame = source.window?.frame
let autosaveName = self.availableAutosaveName(for: target, replacing: source)
source.onClosed = nil
source.releaseFrameAutosaveForReplacement()
source.closeDashboard()
let window = source.detachWindowForReplacement()
let replacement = self.makeController(
configuration: configuration,
target: target,
windowAutosaveName: autosaveName,
auxiliary: true)
if let frame { replacement.window?.setFrame(frame, display: false) }
auxiliary: true,
reusingWindow: window)
self.installAuxiliaryWindowCloseHandler(replacement, windowID: windowID)
self.auxiliaryWindows[windowID] = AuxiliaryWindowInstance(target: target, controller: replacement)
if shouldPresent {
replacement.show(url: configuration.url, auth: configuration.auth)
} else {
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
replacement.loadInBackground(url: configuration.url, auth: configuration.auth)
if shouldPresent, present == true || !replacement.isWindowOpen {
replacement.show()
}
}
self.finishSwitch(generation, for: source)
@@ -683,7 +711,8 @@ final class DashboardManager {
configuration: WindowConfiguration,
target: DashboardGatewayTarget,
windowAutosaveName: String,
auxiliary: Bool) -> DashboardWindowController
auxiliary: Bool,
reusingWindow: NSWindow? = nil) -> DashboardWindowController
{
let primaryLocal = !auxiliary && target == .primary && configuration.mode == .local
if primaryLocal {
@@ -695,7 +724,8 @@ final class DashboardManager {
tlsParams: configuration.tlsParams,
gatewaySnapshot: self.snapshot(for: target),
windowTitle: configuration.displayName,
windowAutosaveName: windowAutosaveName)
windowAutosaveName: windowAutosaveName,
reusingWindow: reusingWindow)
}
return DashboardWindowController(
url: configuration.url,
@@ -706,6 +736,7 @@ final class DashboardManager {
gatewaySnapshot: self.snapshot(for: target),
windowTitle: configuration.displayName,
windowAutosaveName: windowAutosaveName,
reusingWindow: reusingWindow,
requestBrowserProfileImportOffer: { _ in false })
}
@@ -841,6 +872,63 @@ final class DashboardManager {
return nil
}
}
extension DashboardManager {
func show() async throws {
try await self.currentPresentationTask().value
}
private func showResolvedDashboard() async throws {
if let controller, self.mainTarget != .primary {
if controller.isWindowOpen {
controller.show()
await self.refreshGatewaySnapshots()
return
}
await self.switchTarget(self.mainTarget, in: controller, forceReload: true, present: true)
return
}
self.observeEndpointChanges()
while true {
do {
try await self.showResolvedPrimaryDashboard()
return
} catch is SupersededDashboardPresentation {
guard !Task.isCancelled, self.mainTarget == .primary else {
throw CancellationError()
}
if let controller, controller.isWindowOpen {
controller.show()
return
}
}
}
}
private func currentPresentationTask() -> Task<Void, Error> {
if let presentationTask {
return presentationTask
}
self.presentationGeneration &+= 1
let generation = self.presentationGeneration
let presentationTask = Task<Void, Error> { @MainActor [weak self] in
guard let self else { throw CancellationError() }
defer {
if self.presentationGeneration == generation {
self.presentationTask = nil
}
}
try await self.showResolvedDashboard()
}
self.presentationTask = presentationTask
return presentationTask
}
private func endpointTransitionIsCurrent(_ generation: UInt64, controller: DashboardWindowController) -> Bool {
self.endpointGeneration == generation && self.controller === controller &&
self.mainTarget == .primary && controller.isWindowOpen
}
private static func primaryTLSParams(
for config: GatewayConnection.Config,
@@ -872,9 +960,66 @@ final class DashboardManager {
password: (config.password?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty))
return auth.hasCredential ? (mode, url, auth, endpoint.tls?.params) : nil
}
}
extension DashboardManager {
private func presentationIsCurrent(
_ generation: UInt64,
controller originalController: DashboardWindowController?) -> Bool
{
guard !Task.isCancelled, self.mainTarget == .primary else {
return false
}
let originalControllerIsCurrent = originalController.map { self.controller === $0 } ?? (self.controller == nil)
return self.endpointGeneration == generation && originalControllerIsCurrent
}
private func requiresIsolatedDashboardDocument(
_ controller: DashboardWindowController,
auth: DashboardWindowAuth,
endpoint: GatewayConnection.EndpointSnapshot) -> Bool
{
!controller.hasTLSParams(endpoint.tls?.params) ||
controller.auth.gatewayUrl != auth.gatewayUrl ||
controller.auth.token != auth.token ||
controller.auth.password != auth.password ||
endpoint.routeAuthority != self.displayedRouteAuthority ||
endpoint.revision.map { $0 != self.displayedRouteRevision } == true
}
private func rememberPresentedEndpoint(_ endpoint: GatewayConnection.EndpointSnapshot) {
if let revision = endpoint.revision {
self.displayedRouteRevision = revision
}
self.displayedRouteAuthority = endpoint.routeAuthority
}
func handleOnboardingCompletion() {
self.controller?.handleOnboardingCompletion()
}
func navigateBack() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateBack()
}
func navigateForward() {
guard self.controller?.window?.isKeyWindow == true else { return }
self.controller?.navigateForward()
}
func handleGatewayRequest(_ request: DashboardGatewaysRequest, from source: DashboardWindowController) {
switch request {
case let .select(target):
Task { await self.switchTarget(target, in: source) }
case let .openWindow(target):
Task { await self.openWindow(for: target) }
case let .setPrimary(target):
guard self.target(for: source) == target else { return }
self.presentSetPrimaryConfirmation(target, source: source)
case .openSettings:
AppNavigationActions.openSettings(tab: .gateways)
}
}
func openOrFocusDashboard(for target: DashboardGatewayTarget) {
Task { await self.performOpenOrFocusDashboard(for: target) }
}
@@ -1062,6 +1207,7 @@ extension DashboardManager {
self.mainTarget = target
if target != .primary {
self.displayedRouteRevision = nil
self.displayedRouteAuthority = nil
}
}
@@ -126,6 +126,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
gatewaySnapshot: DashboardGatewaySnapshot? = nil,
windowTitle: String = "OpenClaw",
windowAutosaveName: String = DashboardWindowLayout.windowFrameAutosaveName,
reusingWindow: NSWindow? = nil,
requestBrowserProfileImportOffer:
@escaping @MainActor (@escaping @MainActor () -> Bool) async -> Bool = { shouldApply in
await BrowserProfileImportModel.shared.requestAutomaticOfferIfEligible(while: shouldApply)
@@ -208,15 +209,21 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.linkBrowserSplitView = linkBrowserSplitView
self.splitViewController = splitViewController
let preservedWindowFrame = reusingWindow?.frame
let restoreKeyboardFocus = reusingWindow?.isKeyWindow == true
let window = Self.makeWindow(
contentView: splitViewController.view,
title: windowTitle,
frameAutosaveName: windowAutosaveName)
frameAutosaveName: windowAutosaveName,
reusing: reusingWindow)
super.init(window: window)
// NSWindowController adopts its own frame state during initialization;
// keep it aligned with the autosave name installed by makeWindow, then
// re-correct placement in case the assignment re-applied a stale frame.
self.windowFrameAutosaveName = windowAutosaveName
if let preservedWindowFrame {
window.setFrame(preservedWindowFrame, display: false)
}
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
// Width is autosaved, while each new dashboard window starts with the
@@ -238,6 +245,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
self.window?.delegate = self
self.installHistoryStateBridge()
if restoreKeyboardFocus {
window.makeFirstResponder(self.webView)
}
}
func setUpdateBridgeEnabled(_ enabled: Bool) {
@@ -340,26 +350,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
return nil
}
private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
let alert = NSAlert()
alert.messageText = "OpenClaw Dashboard"
if let host, !host.isEmpty {
alert.informativeText = "\(host) is asking:\n\n\(message)"
} else {
alert.informativeText = message
}
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
return alert
}
private static func javaScriptConfirmResult(
for response: NSApplication.ModalResponse)
-> Bool
{
response == .alertFirstButtonReturn
}
@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) is not supported")
@@ -426,14 +416,21 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window?.performClose(nil)
}
func releaseFrameAutosaveForReplacement() {
// AppKit rejects duplicate autosave owners. Release only when the manager
// replaces this controller so the successor can restore the saved frame.
self.window?.saveFrame(usingName: self.dashboardFrameAutosaveName)
func detachWindowForReplacement() -> NSWindow? {
guard let window else { return nil }
// Route changes replace the privileged document, not its native shell;
// detaching first transfers AppKit ownership without a close/focus cycle.
self.webView.stopLoading()
self.closeLinkBrowser(focusDashboard: false)
self.onClosed = nil
window.delegate = nil
window.saveFrame(usingName: self.dashboardFrameAutosaveName)
self.windowFrameAutosaveName = ""
self.window = nil
return window
}
func showFailure(title: String, message: String, detail: String? = nil) {
func showFailure(title: String, message: String, detail: String? = nil, present: Bool = true) {
self.hasLiveContent = false
self.isShowingFailurePage = true
self.advanceNavigationGeneration()
@@ -449,7 +446,9 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
self.webView.loadHTMLString(
DashboardFailurePage.html(title: title, message: message, detail: detail, url: nil),
baseURL: nil)
self.show()
if present {
self.show()
}
}
private func load(_ url: URL) {
@@ -695,12 +694,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
return scheme == "cursor" || scheme == "vscode" || scheme == "windsurf" || scheme == "zed"
}
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() &&
lhs.host?.lowercased() == rhs.host?.lowercased() &&
lhs.port == rhs.port
}
private func refreshNativeAuthScript(url: URL, auth: DashboardWindowAuth) {
let controller = self.webView.configuration.userContentController
controller.removeAllUserScripts()
@@ -739,14 +732,6 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
""")
}
func navigateBack() {
self.activeNavigationWebView.goBack()
}
func navigateForward() {
self.activeNavigationWebView.goForward()
}
private var activeNavigationWebView: WKWebView {
guard let linkWebView = self.linkBrowser.activeWebView,
let firstResponder = self.window?.firstResponder as? NSView,
@@ -760,13 +745,15 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
private static func makeWindow(
contentView: NSView,
title: String,
frameAutosaveName: String) -> NSWindow
frameAutosaveName: String,
reusing existingWindow: NSWindow?) -> NSWindow
{
let window = DashboardWindow(
let window = existingWindow ?? DashboardWindow(
contentRect: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false)
let existingFrame = existingWindow?.frame
let container = DashboardWindowContentView(frame: NSRect(origin: .zero, size: DashboardWindowLayout.windowSize))
contentView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(contentView)
@@ -805,18 +792,26 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
window.titlebarSeparatorStyle = .none
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
// The singleton manager, not AppKit state restoration, owns this window.
window.isRestorable = false
window.hasShadow = true
window.backgroundColor = .windowBackgroundColor
window.isOpaque = true
let viewController = NSViewController()
viewController.view = container
window.contentViewController = viewController
window.center()
if existingWindow == nil {
window.center()
}
window.minSize = DashboardWindowLayout.windowMinSize
// Autosave restore first, placement correction last: a frame saved on
// a since-disconnected monitor must not leave the window off-screen.
window.setFrameAutosaveName(frameAutosaveName)
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
if let existingFrame {
window.setFrame(existingFrame, display: false)
} else {
WindowPlacement.ensureOnScreen(window: window, defaultSize: DashboardWindowLayout.windowSize)
}
return window
}
@@ -1033,6 +1028,40 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
}
extension DashboardWindowController {
func navigateBack() {
self.activeNavigationWebView.goBack()
}
func navigateForward() {
self.activeNavigationWebView.goForward()
}
private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme?.lowercased() == rhs.scheme?.lowercased() &&
lhs.host?.lowercased() == rhs.host?.lowercased() &&
lhs.port == rhs.port
}
private static func makeJavaScriptConfirmAlert(message: String, host: String?) -> NSAlert {
let alert = NSAlert()
alert.messageText = "OpenClaw Dashboard"
if let host, !host.isEmpty {
alert.informativeText = "\(host) is asking:\n\n\(message)"
} else {
alert.informativeText = message
}
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Cancel")
return alert
}
private static func javaScriptConfirmResult(
for response: NSApplication.ModalResponse)
-> Bool
{
response == .alertFirstButtonReturn
}
/// Commands are deliverable when a document is live or a load is in flight
/// (the queue flushes at `didFinish`). A failure page, or a terminally
/// cancelled load with no successor, needs a reload before dispatch
@@ -15,6 +15,7 @@ enum DebugActions {
defer: false)
window.title = "Agent Events"
window.isReleasedWhenClosed = false
window.isRestorable = false
window.contentView = NSHostingView(rootView: AgentEventsWindow())
window.center()
window.makeKeyAndOrderFront(nil)
+1 -5
View File
@@ -180,11 +180,7 @@ final class DeepLinkHandler {
// MARK: - UI
private func openDashboard() async {
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
AppNavigationActions.openDashboard()
}
private func confirm(title: String, message: String) -> Bool {
@@ -39,11 +39,11 @@ final class DockIconManager: NSObject, @unchecked Sendable {
} ?? []
let hasVisibleWindows = !visibleWindows.isEmpty
if !userWantsDockHidden || hasVisibleWindows {
NSApp?.setActivationPolicy(.regular)
} else {
NSApp?.setActivationPolicy(.accessory)
}
let policy: NSApplication.ActivationPolicy = !userWantsDockHidden || hasVisibleWindows
? .regular
: .accessory
guard NSApp.activationPolicy() != policy else { return }
NSApp.setActivationPolicy(policy)
}
}
@@ -53,6 +53,7 @@ final class DockIconManager: NSObject, @unchecked Sendable {
self.logger.warning("NSApp not ready, cannot show Dock icon")
return
}
guard NSApp.activationPolicy() != .regular else { return }
NSApp.setActivationPolicy(.regular)
}
}
+1 -10
View File
@@ -621,16 +621,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
if launchPolicy.shouldAutoOpenDashboard(arguments: CommandLine.arguments) {
self.webChatAutoLogger.info("Auto-opening dashboard via CLI flag")
Task { @MainActor in
if DashboardManager.shared.showConfiguredWindowIfPossible() {
return
}
do {
try await DashboardManager.shared.show()
} catch {
DashboardManager.shared.showFailure(error)
}
}
self.openDashboardAction()
}
}
@@ -297,6 +297,10 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
guard fcntl(stdinPipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 else {
self.finishStartLocked(.failure(WorkerError.unavailable("could not protect worker input pipe")))
return
}
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = Array(command.dropFirst())
var environment = ProcessInfo.processInfo.environment
@@ -389,14 +393,16 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable {
}
private func consumeStdoutLocked(_ data: Data) {
var searchStart = self.stdoutBuffer.count
self.stdoutBuffer.append(data)
guard self.stdoutBuffer.count <= 25 * 1024 * 1024 else {
self.stopLocked(reason: "worker response exceeded limit", notifyUnexpectedExit: true)
return
}
while let newline = self.stdoutBuffer.firstIndex(of: 0x0A) {
while let newline = self.stdoutBuffer[searchStart...].firstIndex(of: 0x0A) {
let line = self.stdoutBuffer.prefix(upTo: newline)
self.stdoutBuffer.removeSubrange(...newline)
searchStart = 0
guard !line.isEmpty,
let message = try? JSONSerialization.jsonObject(with: Data(line)) as? [String: Any]
else { continue }
@@ -526,6 +526,7 @@ final class OnboardingController: NSObject, NSWindowDelegate {
}
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.isRestorable = false
window.title = UIStrings.welcomeTitle
window.styleMask = Self.windowStyleMask
window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight))
@@ -308,6 +308,7 @@ final class PostUpdateController: NSObject, NSWindowDelegate {
}
let hosting = NSHostingController(rootView: PostUpdateView(model: model))
let window = NSWindow(contentViewController: hosting)
window.isRestorable = false
window.title = String(localized: "OpenClaw updated")
window.setContentSize(NSSize(width: 560, height: 600))
window.styleMask = OnboardingController.windowStyleMask
@@ -1410,6 +1410,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate {
(contentViewController as? NSHostingController<MacChatSurface>)?
.sceneBridgingOptions = [.toolbars]
window.isReleasedWhenClosed = false
window.isRestorable = false
// Keep the SwiftUI toolbar controls, but merge their unified row
// with the traffic lights instead of stacking it below a title band.
window.titleVisibility = .hidden
@@ -58,6 +58,7 @@ struct CanvasWindowSmokeTests {
root: root,
presentation: .window)
#expect(controller.window?.isRestorable == false)
controller.showCanvas(path: "/")
controller.windowWillClose(Notification(name: NSWindow.willCloseNotification))
controller.hideCanvas()
@@ -294,6 +294,7 @@ struct DashboardManagerGatewayTargetTests {
token: "current",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
let originalWindow = try #require(controller.window)
let entries = DashboardGatewayTestEntries.withProfiles(["first", "second"])
let manager = DashboardManager._testMake(
profileEndpointProvider: { profileID in
@@ -316,6 +317,7 @@ struct DashboardManagerGatewayTargetTests {
#expect(manager._testMainTarget() == .profile("second"))
#expect(manager._testController()?.currentURL.port == 60003)
#expect(manager._testController()?.window === originalWindow)
}
@Test func `main menu switch replaces the frontmost dashboard in place`() async throws {
@@ -331,7 +333,8 @@ struct DashboardManagerGatewayTargetTests {
controller.window?.setFrame(frame, display: false)
controller.show()
// CI display bounds clamp window frames during show, so compare replacement against the actual source frame.
let sourceFrame = try #require(controller.window).frame
let originalWindow = try #require(controller.window)
let sourceFrame = originalWindow.frame
let entries = DashboardGatewayTestEntries.withProfiles(["studio"])
let manager = DashboardManager._testMake(
profileEndpointProvider: { profileID in
@@ -350,6 +353,7 @@ struct DashboardManagerGatewayTargetTests {
#expect(manager.frontmostDashboardTarget == .profile("studio"))
#expect(manager._testController() !== controller)
#expect(manager._testController()?.currentURL.port == 60002)
#expect(manager._testController()?.window === originalWindow)
#expect(manager._testController()?.window?.frame == sourceFrame)
}
@@ -0,0 +1,493 @@
import AppKit
import Foundation
import Testing
@testable import OpenClaw
private actor DashboardWindowOwnershipAuthGate {
private var value: String?
func authToken() -> String? {
self.value
}
func update(_ value: String) {
self.value = value
}
}
private actor DashboardWindowOwnershipEndpointGate {
private var firstRequested = false
private var firstContinuation: CheckedContinuation<Void, Never>?
func authToken(for config: GatewayConnection.Config) async -> String? {
if config.url.port == 60002 {
self.firstRequested = true
await withCheckedContinuation { continuation in
self.firstContinuation = continuation
}
return "stale"
}
return "current"
}
func waitUntilFirstRequested() async {
while !self.firstRequested {
await Task.yield()
}
}
func releaseFirst() {
self.firstContinuation?.resume()
self.firstContinuation = nil
}
}
private actor DashboardWindowOwnershipPresentationGate {
private var requested = false
private var released = false
private var requestCount = 0
private var continuations: [CheckedContinuation<Void, Never>] = []
func waitForRelease() async {
self.requested = true
self.requestCount += 1
guard !self.released else { return }
await withCheckedContinuation { continuation in
self.continuations.append(continuation)
}
}
func waitUntilRequested() async {
while !self.requested {
await Task.yield()
}
}
func numberOfRequests() -> Int {
self.requestCount
}
func release() {
self.released = true
for continuation in self.continuations {
continuation.resume()
}
self.continuations.removeAll()
}
}
private struct DashboardWindowOwnershipEndpointFailure: Error {}
@MainActor
private final class DashboardWindowOwnershipTrackingWindow: NSWindow {
var simulatesKeyWindow = false
private(set) var foregroundRequestCount = 0
override var isKeyWindow: Bool {
self.simulatesKeyWindow
}
override func makeKeyAndOrderFront(_ sender: Any?) {
self.foregroundRequestCount += 1
super.makeKeyAndOrderFront(sender)
}
}
@Suite(.serialized)
@MainActor
struct DashboardWindowOwnershipTests {
private static let primaryGateway = DashboardGatewayEntry(
id: "primary",
name: "Local Gateway",
kind: "local",
isPrimary: true,
canPromote: false,
health: .ok)
@Test func `disconnect and auth recovery preserve one native window`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipAuthGate()
let readyState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002")),
token: nil,
password: nil,
routeRevision: 2)
let manager = DashboardManager._testMake(
authTokenProvider: { _ in await gate.authToken() },
endpointStateProvider: { readyState })
manager._testSetController(controller)
defer { manager.close() }
await manager.handleEndpointState(readyState)
let failureController = try #require(manager._testController())
#expect(failureController !== controller)
#expect(failureController.window === originalWindow)
#expect(failureController.isWindowOpen)
#expect(failureController.currentURL == URL(string: "about:blank"))
await manager.handleEndpointState(.connecting(mode: .remote, detail: "Connecting"))
await manager.handleEndpointState(.unavailable(mode: .remote, reason: "Unavailable"))
#expect(manager._testController() === failureController)
#expect(failureController.window === originalWindow)
await gate.update("after")
await manager._testHandleControlChannelStateChange(.connected)
let recoveredController = try #require(manager._testController())
#expect(recoveredController !== failureController)
#expect(recoveredController.window === originalWindow)
#expect(recoveredController.currentURL.absoluteString ==
"http://127.0.0.1:60002/#token=after")
let authScripts = recoveredController._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("after"))
#expect(!authScripts[0].source.contains("before"))
await manager._testHandleControlChannelStateChange(.connected)
#expect(manager._testController() === recoveredController)
#expect(recoveredController.window === originalWindow)
}
@Test func `overlapping endpoint updates cannot orphan a dashboard window`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipEndpointGate()
let manager = DashboardManager._testMake(
authTokenProvider: { config in await gate.authToken(for: config) })
manager._testSetController(controller)
defer { manager.close() }
let staleState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002")),
token: nil,
password: nil,
routeRevision: 1)
let currentState = try GatewayEndpointState.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003")),
token: nil,
password: nil,
routeRevision: 2)
let staleUpdate = Task { @MainActor in
await manager.handleEndpointState(staleState)
}
await gate.waitUntilFirstRequested()
await manager.handleEndpointState(currentState)
let currentController = try #require(manager._testController())
await gate.releaseFirst()
await staleUpdate.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
let authScripts = currentController._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("current"))
#expect(!authScripts[0].source.contains("stale"))
}
@Test func `reopening after credential changes isolates the privileged document`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let originalDocument = controller._testDashboardWebViewIdentity
originalWindow.orderOut(nil)
let endpointURL = try #require(URL(string: "ws://127.0.0.1:60001/"))
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
GatewayConnection.EndpointSnapshot(
config: (url: endpointURL, token: "after", password: nil),
routeAuthority: 2,
revision: 2)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
try await manager.show()
let replacement = try #require(manager._testController())
#expect(replacement !== controller)
#expect(replacement.window === originalWindow)
#expect(replacement._testDashboardWebViewIdentity != originalDocument)
let authScripts = replacement._testUserScripts
.filter { $0.source.contains("__OPENCLAW_NATIVE_CONTROL_AUTH__") }
#expect(authScripts.count == 1)
#expect(authScripts[0].source.contains("after"))
#expect(!authScripts[0].source.contains("before"))
}
@Test func `replacing a key dashboard transfers keyboard ownership`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let originalWindow = DashboardWindowOwnershipTrackingWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)",
reusingWindow: originalWindow)
controller.show()
originalWindow.simulatesKeyWindow = true
let manager = DashboardManager._testMake()
manager._testSetController(controller)
defer { manager.close() }
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60002/")),
token: "after",
password: nil,
routeRevision: 2))
let replacement = try #require(manager._testController())
let responder = try #require(originalWindow.firstResponder as? NSView)
#expect(ObjectIdentifier(responder) == replacement._testDashboardWebViewIdentity)
}
@Test func `stale async presentation cannot overwrite a newer endpoint`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let originalWindow = DashboardWindowOwnershipTrackingWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false)
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)",
reusingWindow: originalWindow)
controller.show()
let staleEndpointURL = try #require(URL(string: "ws://127.0.0.1:60002/"))
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
return GatewayConnection.EndpointSnapshot(
config: (url: staleEndpointURL, token: "stale", password: nil),
routeAuthority: 1,
revision: 1)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003/")),
token: "current",
password: nil,
routeRevision: 2))
let currentController = try #require(manager._testController())
let backgroundForegroundCount = originalWindow.foregroundRequestCount
await gate.release()
try await presentation.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(originalWindow.foregroundRequestCount > backgroundForegroundCount)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `hidden dashboard invalidates stale reopening authority`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let staleEndpointURL = try #require(URL(string: "ws://127.0.0.1:60002/"))
let currentEndpointURL = try #require(URL(string: "ws://127.0.0.1:60003/"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
originalWindow.orderOut(nil)
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
let request = await gate.numberOfRequests()
let url = request == 1 ? staleEndpointURL : currentEndpointURL
let token = request == 1 ? "stale" : "current"
return GatewayConnection.EndpointSnapshot(
config: (url: url, token: token, password: nil),
routeAuthority: UInt64(request),
revision: UInt64(request))
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
await manager.handleEndpointState(.ready(
mode: .remote,
url: currentEndpointURL,
token: "current",
password: nil,
routeRevision: 2))
await gate.release()
try await presentation.value
let replacement = try #require(manager._testController())
#expect(await gate.numberOfRequests() == 2)
#expect(replacement.window === originalWindow)
#expect(replacement.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `superseded endpoint failure preserves a newer live dashboard`() async throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=initial"))
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "initial",
password: nil),
windowAutosaveName: "OpenClawDashboardWindow-Test-\(UUID().uuidString)")
controller.show()
let originalWindow = try #require(controller.window)
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
throw DashboardWindowOwnershipEndpointFailure()
},
gatewayEntriesProvider: { [Self.primaryGateway] })
manager._testSetController(controller)
defer { manager.close() }
let presentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
try await manager.handleEndpointState(.ready(
mode: .remote,
url: #require(URL(string: "ws://127.0.0.1:60003/")),
token: "current",
password: nil,
routeRevision: 2))
let currentController = try #require(manager._testController())
await gate.release()
try await presentation.value
#expect(manager._testController() === currentController)
#expect(currentController.window === originalWindow)
#expect(currentController.currentURL.absoluteString ==
"http://127.0.0.1:60003/#token=current")
}
@Test func `window handoff ignores a conflicting target autosave frame`() throws {
let url = try #require(URL(string: "http://127.0.0.1:60001/#token=before"))
let originalAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)"
let targetAutosaveName = "OpenClawDashboardWindow-Test-\(UUID().uuidString)"
defer {
NSWindow.removeFrame(usingName: originalAutosaveName)
NSWindow.removeFrame(usingName: targetAutosaveName)
}
let conflictingWindow = NSWindow(
contentRect: NSRect(x: 30, y: 30, width: 1200, height: 800),
styleMask: [.titled, .closable, .resizable],
backing: .buffered,
defer: false)
conflictingWindow.isReleasedWhenClosed = false
conflictingWindow.saveFrame(usingName: targetAutosaveName)
conflictingWindow.close()
let controller = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "before",
password: nil),
windowAutosaveName: originalAutosaveName)
controller.show()
let originalWindow = try #require(controller.window)
let originalFrame = originalWindow.frame
let transferredWindow = try #require(controller.detachWindowForReplacement())
let replacement = DashboardWindowController(
url: url,
auth: DashboardWindowAuth(
gatewayUrl: "ws://127.0.0.1:60001/",
token: "after",
password: nil),
windowAutosaveName: targetAutosaveName,
reusingWindow: transferredWindow)
defer { replacement.closeDashboard() }
#expect(replacement.window === originalWindow)
#expect(originalWindow.frame == originalFrame)
}
@Test func `concurrent explicit opens share one presentation owner`() async throws {
let endpointURL = try #require(URL(string: "ws://127.0.0.1:60004/"))
let gate = DashboardWindowOwnershipPresentationGate()
let manager = DashboardManager._testMake(
primaryEndpointProvider: { _ in
await gate.waitForRelease()
return GatewayConnection.EndpointSnapshot(
config: (url: endpointURL, token: "shared", password: nil),
routeAuthority: 1,
revision: 1)
},
gatewayEntriesProvider: { [Self.primaryGateway] })
defer { manager.close() }
let firstPresentation = Task { @MainActor in try await manager.show() }
await gate.waitUntilRequested()
let secondPresentation = Task { @MainActor in try await manager.show() }
await Task.yield()
#expect(await gate.numberOfRequests() == 1)
await gate.release()
try await firstPresentation.value
try await secondPresentation.value
let controller = try #require(manager._testController())
#expect(controller.isWindowOpen)
#expect(controller.currentURL.absoluteString ==
"http://127.0.0.1:60004/#token=shared")
}
}
@@ -55,6 +55,7 @@ struct DashboardWindowSmokeTests {
controller.show()
#expect(controller.window?.styleMask.contains(.titled) == true)
#expect(controller.window?.styleMask.contains(.closable) == true)
#expect(controller.window?.isRestorable == false)
#expect(controller.window?.contentViewController != nil)
#expect(controller.window?.standardWindowButton(.closeButton) != nil)
// The empty unified toolbar is what grows the titlebar to 52pt so the
@@ -0,0 +1,25 @@
import Foundation
import OpenClawKit
import Testing
@testable import OpenClaw
@Suite(.serialized)
struct MacNodeHostWorkerPipeTests {
@Test func `closed worker input cannot terminate the app with SIGPIPE`() async throws {
let worker = MacNodeHostWorker(session: GatewayNodeSession())
let script = """
exec 0<&-
printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":[],"commands":[],"pathEnv":"/bin"}}'
sleep 1
"""
_ = try await worker.start(command: ["/bin/sh", "-c", script])
let response = await worker.invoke(BridgeInvokeRequest(
id: "closed",
command: "system.run",
paramsJSON: #"{"command":["/usr/bin/true"]}"#))
#expect(!response.ok)
await worker.stop()
}
}
@@ -155,6 +155,7 @@ struct WebChatSwiftUISmokeTests {
#expect(window.toolbarStyle == .unified)
#expect(window.titlebarSeparatorStyle == .none)
#expect(window.isMovableByWindowBackground)
#expect(window.isRestorable == false)
#expect(window.title == "Studio — OpenClaw")
window.title = "main"
#expect(window.title == "Studio — OpenClaw")
@@ -938,6 +938,32 @@ struct OpenClawChatComposer: View {
.onChange(of: self.viewModel.input) { _, _ in
self.updateSlashPopoverPresentation()
}
#elseif os(iOS)
ChatComposerTextViewIOS(
text: self.$viewModel.input,
shouldFocus: self.isFocused,
isEnabled: self.isComposerEnabled,
minHeight: self.textMinHeight,
maxHeight: self.textMaxHeight,
onFocusChange: { focused in
self.isFocused = focused
},
onHistoryUp: {
!self.isSlashPopoverPresented && self.viewModel.recallPreviousInput(caretOnFirstLine: $0)
},
onHistoryDown: { !self.isSlashPopoverPresented && self.viewModel.recallNextInput() })
.padding(.horizontal, self.cleanFieldTextInset)
.padding(.vertical, self.composerChrome == .clean ? 0 : 6)
.onChange(of: self.viewModel.input) { _, _ in
self.updateSlashPopoverPresentation()
}
.onChange(of: self.isFocused) { _, focused in
if focused {
self.updateSlashPopoverPresentation()
} else {
self.setSlashPanelPresented(false)
}
}
#else
TextField(
"",
@@ -0,0 +1,162 @@
#if os(iOS)
import SwiftUI
import UIKit
@MainActor
struct ChatComposerTextViewIOS: UIViewRepresentable {
@Binding var text: String
var shouldFocus: Bool
var isEnabled: Bool
var minHeight: CGFloat
var maxHeight: CGFloat
var onFocusChange: (Bool) -> Void
var onHistoryUp: (Bool) -> Bool
var onHistoryDown: () -> Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> ChatComposerUITextView {
let textView = ChatComposerTextViewIOSFactory.makeConfiguredTextView()
textView.delegate = context.coordinator
textView.text = self.text
self.configureHistoryHandlers(textView)
return textView
}
func updateUIView(_ textView: ChatComposerUITextView, context: Context) {
context.coordinator.parent = self
textView.isEditable = self.isEnabled
textView.isSelectable = self.isEnabled
self.configureHistoryHandlers(textView)
if self.shouldFocus, self.isEnabled, !textView.isFirstResponder {
textView.becomeFirstResponder()
} else if !self.shouldFocus || !self.isEnabled, textView.isFirstResponder {
textView.resignFirstResponder()
}
let isEcho = context.coordinator.lastReportedText == self.text
if textView.isFirstResponder, isEcho {
return
}
if textView.text != self.text {
context.coordinator.isProgrammaticUpdate = true
defer { context.coordinator.isProgrammaticUpdate = false }
textView.text = self.text
if textView.isFirstResponder {
textView.selectedRange = NSRange(location: (self.text as NSString).length, length: 0)
}
textView.invalidateIntrinsicContentSize()
}
context.coordinator.lastReportedText = self.text
}
private func configureHistoryHandlers(_ textView: ChatComposerUITextView) {
textView.onHistoryUp = self.onHistoryUp
textView.onHistoryDown = self.onHistoryDown
}
func sizeThatFits(
_ proposal: ProposedViewSize,
uiView: ChatComposerUITextView,
context _: Context) -> CGSize?
{
guard let width = proposal.width else { return nil }
let fitting = uiView.sizeThatFits(
CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
return CGSize(
width: width,
height: min(max(fitting.height, self.minHeight), self.maxHeight))
}
@MainActor
final class Coordinator: NSObject, UITextViewDelegate {
var parent: ChatComposerTextViewIOS
var isProgrammaticUpdate = false
var lastReportedText: String?
init(_ parent: ChatComposerTextViewIOS) {
self.parent = parent
}
func textViewDidBeginEditing(_ textView: UITextView) {
self.parent.onFocusChange(true)
}
func textViewDidEndEditing(_ textView: UITextView) {
self.parent.onFocusChange(false)
}
func textViewDidChange(_ textView: UITextView) {
guard !self.isProgrammaticUpdate, textView.isFirstResponder else { return }
self.lastReportedText = textView.text
self.parent.text = textView.text
textView.invalidateIntrinsicContentSize()
}
}
}
@MainActor
final class ChatComposerUITextView: UITextView {
var onHistoryUp: ((Bool) -> Bool)?
var onHistoryDown: (() -> Bool)?
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
var unhandledPresses = presses
for press in presses {
guard let key = press.key else { continue }
if self.handleHardwareKey(key.keyCode, modifierFlags: key.modifierFlags) {
unhandledPresses.remove(press)
}
}
guard !unhandledPresses.isEmpty else { return }
super.pressesBegan(unhandledPresses, with: event)
}
/// Internal for focused responder-level keyboard routing coverage.
func handleHardwareKey(
_ keyCode: UIKeyboardHIDUsage,
modifierFlags: UIKeyModifierFlags) -> Bool
{
let commandModifiers: UIKeyModifierFlags = [.shift, .control, .alternate, .command]
guard modifierFlags.isDisjoint(with: commandModifiers) else { return false }
switch keyCode {
case .keyboardUpArrow:
return self.onHistoryUp?(self.caretOnFirstLine) == true
case .keyboardDownArrow:
return self.onHistoryDown?() == true
default:
return false
}
}
private var caretOnFirstLine: Bool {
let location = min(max(self.selectedRange.location, 0), (self.text as NSString).length)
let prefix = (self.text as NSString).substring(to: location)
return !prefix.contains("\n") && !prefix.contains("\r")
}
}
enum ChatComposerTextViewIOSFactory {
/// Internal for @testable import coverage of native multiline input defaults.
@MainActor
static func makeConfiguredTextView() -> ChatComposerUITextView {
let textView = ChatComposerUITextView()
textView.backgroundColor = .clear
textView.font = OpenClawChatTypography.bodyUIFont
textView.adjustsFontForContentSizeCategory = true
textView.allowsEditingTextAttributes = false
textView.isScrollEnabled = true
textView.showsVerticalScrollIndicator = false
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.returnKeyType = .default
textView.accessibilityIdentifier = "chat-message-input"
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
}
#endif
@@ -2,6 +2,8 @@ import Foundation
import SwiftUI
#if os(macOS)
import AppKit
#elseif os(iOS)
import UIKit
#endif
enum OpenClawChatTypography {
@@ -36,6 +38,14 @@ enum OpenClawChatTypography {
body(size: self.bodySize, weight: .regular, relativeTo: .body)
}
#if os(iOS)
static var bodyUIFont: UIFont {
let base = UIFont(name: self.bodyPostScriptName, size: self.bodySize) ??
UIFont.systemFont(ofSize: self.bodySize)
return UIFontMetrics(forTextStyle: .body).scaledFont(for: base)
}
#endif
static var footnote: Font {
body(size: 13, weight: .regular, relativeTo: .footnote)
}
File diff suppressed because it is too large Load Diff
@@ -1,311 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenClaw Canvas</title>
<script>
(() => {
const normalizeLower = (value) => {
const trimmed = String(value || "").trim();
return trimmed.toLocaleLowerCase();
};
try {
const params = new URLSearchParams(window.location.search);
const platform = normalizeLower(params.get("platform"));
if (platform) {
document.documentElement.dataset.platform = platform;
return;
}
if (/android/i.test(navigator.userAgent || "")) {
document.documentElement.dataset.platform = "android";
}
} catch (_) {}
})();
</script>
<style>
:root {
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
body::before,
body::after {
animation: none !important;
}
}
html,
body {
height: 100%;
margin: 0;
}
body {
font:
14px system-ui,
-apple-system,
BlinkMacSystemFont,
"Roboto",
sans-serif;
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0, 0, 0, 0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0, 0, 0, 0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.1), rgba(0, 0, 0, 0) 60%),
#000;
color: #e5e7eb;
overflow: hidden;
}
:root[data-platform="android"] body {
background:
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0, 0, 0, 0) 55%),
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0, 0, 0, 0) 60%),
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0, 0, 0, 0) 60%),
#0b1328;
}
body::before {
content: "";
position: fixed;
inset: -20%;
background:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0,
rgba(255, 255, 255, 0.03) 1px,
transparent 1px,
transparent 48px
);
transform: translate3d(0, 0, 0) rotate(-7deg);
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
opacity: 0.45;
pointer-events: none;
animation: openclaw-grid-drift 140s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::before {
opacity: 0.8;
}
body::after {
content: "";
position: fixed;
inset: -35%;
background:
radial-gradient(900px 700px at 30% 30%, rgba(42, 113, 255, 0.16), rgba(0, 0, 0, 0) 60%),
radial-gradient(800px 650px at 70% 35%, rgba(255, 0, 138, 0.12), rgba(0, 0, 0, 0) 62%),
radial-gradient(900px 800px at 55% 75%, rgba(0, 209, 255, 0.1), rgba(0, 0, 0, 0) 62%);
filter: blur(28px);
opacity: 0.52;
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform: translate3d(0, 0, 0);
pointer-events: none;
animation: openclaw-glow-drift 110s ease-in-out infinite alternate;
}
:root[data-platform="android"] body::after {
opacity: 0.85;
}
@supports (mix-blend-mode: screen) {
body::after {
mix-blend-mode: screen;
}
}
@supports not (mix-blend-mode: screen) {
body::after {
opacity: 0.7;
}
}
@keyframes openclaw-grid-drift {
0% {
transform: translate3d(-12px, 8px, 0) rotate(-7deg);
opacity: 0.4;
}
50% {
transform: translate3d(10px, -7px, 0) rotate(-6.6deg);
opacity: 0.56;
}
100% {
transform: translate3d(-8px, 6px, 0) rotate(-7.2deg);
opacity: 0.42;
}
}
@keyframes openclaw-glow-drift {
0% {
transform: translate3d(-18px, 12px, 0) scale(1.02);
opacity: 0.4;
}
50% {
transform: translate3d(14px, -10px, 0) scale(1.05);
opacity: 0.52;
}
100% {
transform: translate3d(-10px, 8px, 0) scale(1.03);
opacity: 0.43;
}
}
canvas {
position: fixed;
inset: 0;
display: block;
width: 100vw;
height: 100vh;
touch-action: none;
z-index: 1;
}
:root[data-platform="android"] #openclaw-canvas {
background:
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0, 0, 0, 0) 58%),
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0, 0, 0, 0) 62%),
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0, 0, 0, 0) 62%),
#141c33;
}
#openclaw-status {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 24px;
box-sizing: border-box;
pointer-events: none;
z-index: 3;
}
#openclaw-status .card {
width: min(560px, 88vw);
text-align: left;
padding: 14px 16px 12px;
border-radius: 16px;
background: linear-gradient(140deg, rgba(23, 24, 35, 0.78), rgba(18, 19, 28, 0.55));
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 16px 46px rgba(0, 0, 0, 0.52),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
-webkit-backdrop-filter: blur(18px) saturate(140%);
backdrop-filter: blur(18px) saturate(140%);
}
#openclaw-status .title {
font:
600 12px/1.2 -apple-system,
BlinkMacSystemFont,
"SF Pro Text",
system-ui,
sans-serif;
letter-spacing: 0.45px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.7);
}
#openclaw-status .subtitle {
margin-top: 8px;
font:
500 13px/1.45 -apple-system,
BlinkMacSystemFont,
"SF Pro Text",
system-ui,
sans-serif;
color: rgba(255, 255, 255, 0.9);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
openclaw-a2ui-host {
display: block;
height: 100%;
position: fixed;
inset: 0;
z-index: 4;
--openclaw-a2ui-inset-top: 28px;
--openclaw-a2ui-inset-right: 0px;
--openclaw-a2ui-inset-bottom: 0px;
--openclaw-a2ui-inset-left: 0px;
--openclaw-a2ui-scroll-pad-bottom: 0px;
--openclaw-a2ui-status-top: calc(50% - 18px);
--openclaw-a2ui-empty-top: 18px;
}
</style>
</head>
<body>
<canvas id="openclaw-canvas"></canvas>
<div id="openclaw-status" role="status" aria-live="polite">
<section class="card">
<div class="title" id="openclaw-status-title">Ready</div>
<div class="subtitle" id="openclaw-status-subtitle">Waiting for agent</div>
</section>
</div>
<openclaw-a2ui-host></openclaw-a2ui-host>
<script src="a2ui.bundle.js"></script>
<script>
(() => {
const canvas = document.getElementById("openclaw-canvas");
const ctx = canvas.getContext("2d");
const statusEl = document.getElementById("openclaw-status");
const titleEl = document.getElementById("openclaw-status-title");
const subtitleEl = document.getElementById("openclaw-status-subtitle");
const debugStatusEnabledByQuery = (() => {
try {
const params = new URLSearchParams(window.location.search);
const raw = params.get("debugStatus") ?? params.get("debug");
if (!raw) return false;
const normalized = normalizeLower(raw);
return normalized === "1" || normalized === "true" || normalized === "yes";
} catch (_) {
return false;
}
})();
let debugStatusEnabled = debugStatusEnabledByQuery;
function resize() {
const dpr = window.devicePixelRatio || 1;
const w = Math.max(1, Math.floor(window.innerWidth * dpr));
const h = Math.max(1, Math.floor(window.innerHeight * dpr));
canvas.width = w;
canvas.height = h;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener("resize", resize);
resize();
const setDebugStatusEnabled = (enabled) => {
debugStatusEnabled = !!enabled;
if (!statusEl) return;
if (!debugStatusEnabled) {
statusEl.style.display = "none";
}
};
if (statusEl && !debugStatusEnabled) {
statusEl.style.display = "none";
}
window.__openclaw = {
canvas,
ctx,
setDebugStatusEnabled,
setStatus: (title, subtitle) => {
if (!statusEl || !debugStatusEnabled) return;
if (!title && !subtitle) {
statusEl.style.display = "none";
return;
}
statusEl.style.display = "flex";
if (titleEl && typeof title === "string") titleEl.textContent = title;
if (subtitleEl && typeof subtitle === "string") subtitleEl.textContent = subtitle;
if (!debugStatusEnabled) {
clearTimeout(window.__statusTimeout);
window.__statusTimeout = setTimeout(() => {
statusEl.style.display = "none";
}, 3000);
} else {
clearTimeout(window.__statusTimeout);
}
},
};
})();
</script>
</body>
</html>
@@ -742,6 +742,28 @@ public struct BoardCanvasDocumentSource: Codable, Sendable {
}
}
public struct BoardWidgetGeneratedIdentity: Codable, Sendable {
public let source: String
public let key: String
public let fallbackname: String
public init(
source: String,
key: String,
fallbackname: String)
{
self.source = source
self.key = key
self.fallbackname = fallbackname
}
private enum CodingKeys: String, CodingKey {
case source
case key
case fallbackname = "fallbackName"
}
}
public struct BoardGetParams: Codable, Sendable {
public let sessionkey: String
@@ -783,6 +805,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
public let heightmode: AnyCodable?
public let placement: [String: AnyCodable]?
public let declared: BoardWidgetDeclared?
public let generatedidentity: BoardWidgetGeneratedIdentity?
public init(
sessionkey: String,
@@ -792,7 +815,8 @@ public struct BoardWidgetPutParams: Codable, Sendable {
presentation: AnyCodable? = nil,
heightmode: AnyCodable? = nil,
placement: [String: AnyCodable]? = nil,
declared: BoardWidgetDeclared? = nil)
declared: BoardWidgetDeclared? = nil,
generatedidentity: BoardWidgetGeneratedIdentity? = nil)
{
self.sessionkey = sessionkey
self.name = name
@@ -802,6 +826,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
self.heightmode = heightmode
self.placement = placement
self.declared = declared
self.generatedidentity = generatedidentity
}
private enum CodingKeys: String, CodingKey {
@@ -813,6 +838,37 @@ public struct BoardWidgetPutParams: Codable, Sendable {
case heightmode = "heightMode"
case placement
case declared
case generatedidentity = "generatedIdentity"
}
}
public struct BoardWidgetPutResult: Codable, Sendable {
public let sessionkey: String
public let revision: Int
public let tabs: [BoardTab]
public let widgets: [BoardWidget]
public let resolvedwidgetname: String
public init(
sessionkey: String,
revision: Int,
tabs: [BoardTab],
widgets: [BoardWidget],
resolvedwidgetname: String)
{
self.sessionkey = sessionkey
self.revision = revision
self.tabs = tabs
self.widgets = widgets
self.resolvedwidgetname = resolvedwidgetname
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case revision
case tabs
case widgets
case resolvedwidgetname = "resolvedWidgetName"
}
}
@@ -0,0 +1,66 @@
#if os(iOS)
import Testing
import UIKit
@testable import OpenClawChatUI
@Suite
@MainActor
struct ChatComposerTextViewIOSTests {
@Test func configuredComposerUsesNativeMultilineInput() {
let textView = ChatComposerTextViewIOSFactory.makeConfiguredTextView()
#expect(textView.isEditable)
#expect(textView.isSelectable)
#expect(!textView.allowsEditingTextAttributes)
#expect(textView.returnKeyType == .default)
#expect(textView.textContainerInset == .zero)
#expect(textView.textContainer.lineFragmentPadding == 0)
#expect(textView.accessibilityIdentifier == "chat-message-input")
}
@Test func returnInsertionRespectsCaretAndSelection() {
let textView = ChatComposerTextViewIOSFactory.makeConfiguredTextView()
textView.text = "firstsecond"
textView.selectedRange = NSRange(location: 5, length: 0)
textView.insertText("\n")
#expect(textView.text == "first\nsecond")
#expect(textView.selectedRange == NSRange(location: 6, length: 0))
textView.selectedRange = NSRange(location: 0, length: 5)
textView.insertText("\n")
#expect(textView.text == "\n\nsecond")
#expect(textView.selectedRange == NSRange(location: 1, length: 0))
}
@Test func physicalArrowKeysRouteThroughTheFocusedEditor() {
let textView = ChatComposerTextViewIOSFactory.makeConfiguredTextView()
var upContexts: [Bool] = []
var downCalls = 0
textView.onHistoryUp = { caretOnFirstLine in
upContexts.append(caretOnFirstLine)
return true
}
textView.onHistoryDown = {
downCalls += 1
return true
}
textView.text = "first\nsecond"
textView.selectedRange = NSRange(location: 2, length: 0)
#expect(textView.handleHardwareKey(.keyboardUpArrow, modifierFlags: []))
textView.selectedRange = NSRange(location: 8, length: 0)
#expect(textView.handleHardwareKey(.keyboardUpArrow, modifierFlags: []))
#expect(textView.handleHardwareKey(.keyboardDownArrow, modifierFlags: []))
#expect(upContexts == [true, false])
#expect(downCalls == 1)
#expect(!textView.handleHardwareKey(.keyboardUpArrow, modifierFlags: .shift))
#expect(textView.handleHardwareKey(.keyboardUpArrow, modifierFlags: .alphaShift))
#expect(!textView.handleHardwareKey(.keyboardReturnOrEnter, modifierFlags: []))
}
}
#endif
+1 -1
View File
@@ -1,3 +1,3 @@
# Distinct OPENCLAW_* names in production source under src, packages, and extensions.
# Ratchet: lower this number when cleanup removes names; never raise it.
517
515
+4
View File
@@ -54,6 +54,8 @@ const ROOT_TEST_ENTRY_GLOBS = [
"test/e2e/qa-lab/runtime/system-agent-first-run-docker-client.ts!",
// QA scenario YAML dispatches these scripts/tests by path rather than import.
...QA_SCENARIO_EXECUTION_ENTRIES,
// Invoked directly by the sandbox bind-conflict E2E verification script.
"scripts/e2e-sandbox-bind-conflict.mjs!",
// The Voice Call QA scenario loads this fixture through a generated plugin directory.
"test/e2e/qa-lab/runtime/fixtures/voice-call-runtime-plugin/index.js!",
// Loaded with cache-busting query strings so configuration fallback tests
@@ -121,6 +123,8 @@ const config = {
// This fixture deliberately mixes used, aliased, and unused exports so the
// topology analyzer can prove each classification.
ignoreIssues: {
// The memory-state compatibility facade must retain its pre-registry-bundle type export.
"src/plugins/memory-state.ts": ["types"],
// Cache-busting dynamic imports are real consumers, but Knip cannot map
// their query-suffixed module ids back to these named test-support exports.
"test/helpers/config/bundled-channel-config-runtime.ts": ["exports"],
+13 -1
View File
@@ -21,6 +21,8 @@ const repositoryScriptEntries = [
"scripts/check-package-dist-imports.mjs!",
"scripts/dev/ios-node-e2e.ts!",
"scripts/diffs-shiki-curated.ts!",
// Reusable Docker workflows invoke this from the downloaded .release-harness tree.
"scripts/docker-e2e.mjs!",
"scripts/e2e/lib/browser-cdp-snapshot/assert-snapshot.mjs!",
"scripts/e2e/lib/browser-cdp-snapshot/fixture-server.mjs!",
"scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs!",
@@ -169,7 +171,6 @@ const rootEntries = [
"apps/android/app/src/main/assets/katex/renderer.js!",
"apps/linux/ui/main.js!",
"apps/linux/ui/quickchat.js!",
"apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/CanvasA2UI/a2ui.bundle.js!",
"scripts/qa/render-maturity-docs.ts!",
bundledPluginFile("telegram", "src/audit.ts", "!"),
bundledPluginFile("telegram", "src/token.ts", "!"),
@@ -368,6 +369,17 @@ const config = {
"src/gateway/board-view-ticket.ts": ["exports"],
// Focused startup tests consume this explicit seam; production imports only the bootstrap.
"src/gateway/server-startup-bootstrap.ts": ["exports"],
// Registry facades retain direct registration/reset compatibility seams used by focused
// tests; the full-tree scan still audits every named export against those consumers.
"src/agents/harness/registry.ts": ["exports"],
"src/context-engine/registry.ts": ["exports", "types"],
"src/plugins/command-registration.ts": ["exports"],
"src/plugins/compaction-provider.ts": ["exports"],
"src/plugins/interactive-registry.ts": ["exports"],
"src/plugins/loader-module-runtime.ts": ["exports"],
"src/plugins/memory-state.ts": ["exports", "types"],
"src/plugins/session-discussion-registry.ts": ["exports"],
"src/tasks/detached-task-runtime-state.ts": ["exports"],
// Focused media tests consume these explicit seams; production uses the helpers in-module.
"src/agents/embedded-agent-subscribe.handlers.lifecycle.ts": ["exports"],
"src/gateway/server-methods/chat-webchat-media.ts": ["exports"],
-11
View File
@@ -87,7 +87,6 @@ extensions/discord/src/monitor/message-handler.preflight.test.ts
extensions/discord/src/monitor/message-handler.preflight.ts
extensions/discord/src/monitor/model-picker.test.ts
extensions/discord/src/monitor/model-picker.view.ts
extensions/discord/src/monitor/native-command-model-picker-interaction.ts
extensions/discord/src/monitor/native-command.model-picker.test.ts
extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts
extensions/discord/src/monitor/native-command.ts
@@ -119,7 +118,6 @@ extensions/file-transfer/src/shared/node-invoke-policy.ts
extensions/firecrawl/src/firecrawl-tools.test.ts
extensions/github-copilot/index.test.ts
extensions/google-meet/index.test.ts
extensions/google/oauth.test.ts
extensions/google/realtime-voice-provider.test.ts
extensions/google/realtime-voice-provider.ts
extensions/google/transport-stream.test.ts
@@ -127,7 +125,6 @@ extensions/google/transport-stream.ts
extensions/imessage/src/actions.test.ts
extensions/imessage/src/actions.ts
extensions/imessage/src/approval-reactions.test.ts
extensions/imessage/src/approval-reactions.ts
extensions/imessage/src/monitor.last-route.test.ts
extensions/imessage/src/monitor/inbound-processing.ts
extensions/imessage/src/monitor/monitor-provider.ts
@@ -462,8 +459,6 @@ src/agents/runtime-plan/prepare-auth.test.ts
src/agents/sandbox/ssh.ts
src/agents/session-tool-result-guard.ts
src/agents/session-transcript-repair.test.ts
src/agents/session-write-lock.test.ts
src/agents/session-write-lock.ts
src/agents/sessions/extensions/runner.ts
src/agents/sessions/extensions/types.ts
src/agents/sessions/model-registry.ts
@@ -507,7 +502,6 @@ src/agents/transcript-redact.test.ts
src/agents/workspace.ts
src/agents/worktrees/service.ts
src/auto-reply/command-control.test.ts
src/auto-reply/commands-registry.shared.ts
src/auto-reply/inbound.test.ts
src/auto-reply/reply/abort.test.ts
src/auto-reply/reply/agent-runner-memory.test.ts
@@ -519,7 +513,6 @@ src/auto-reply/reply/commands-acp.test.ts
src/auto-reply/reply/commands-approve.test.ts
src/auto-reply/reply/commands-models.ts
src/auto-reply/reply/commands-status.test.ts
src/auto-reply/reply/directive-handling.impl.ts
src/auto-reply/reply/directive-handling.model.test.ts
src/auto-reply/reply/dispatch-acp.test.ts
src/auto-reply/reply/dispatch-acp.ts
@@ -644,7 +637,6 @@ src/config/config-misc.test.ts
src/config/config.plugin-validation.test.ts
src/config/env-preserve.ts
src/config/io.observe-recovery.test.ts
src/config/io.observe-recovery.ts
src/config/io.write-config.test.ts
src/config/io.write-prepare.test.ts
src/config/io.write-prepare.ts
@@ -974,18 +966,15 @@ src/wizard/setup.test.ts
src/worker/worker.runtime.test.ts
ui/src/api/gateway.node.test.ts
ui/src/api/types.ts
ui/src/app/app-host.ts
ui/src/lib/config/index.test.ts
ui/src/lib/config/index.ts
ui/src/lib/cron/index.test.ts
ui/src/lib/cron/index.ts
ui/src/lib/nodes/index.ts
ui/src/lib/sessions/index.ts
ui/src/lib/skills/index.test.ts
ui/src/lib/workboard/index.test.ts
ui/src/pages/agents/agents-page.ts
ui/src/pages/agents/memory/dreaming.test.ts
ui/src/pages/agents/memory/dreaming.ts
ui/src/pages/agents/memory/view.ts
ui/src/pages/agents/panels-tools-skills.ts
ui/src/pages/chat/chat-command-executor.test.ts
+3 -3
View File
@@ -1,5 +1,5 @@
{
"core": 2307,
"channel": 3664,
"plugin": 4055
"core": 2309,
"channel": 3692,
"plugin": 4057
}
+4 -4
View File
@@ -1,4 +1,4 @@
820fe810979007010e2ade951ac178d204fcac10e48a6657cdd4b75de6ce0aee config-baseline.json
b89715475e4b18a0d32765fda42bcce38537f6d49f949bb4ff0c7d1630101882 config-baseline.core.json
26077716f773821c1ad07160632c3a5ed48f7bdcc95ea82cf77c99bb8bba5834 config-baseline.channel.json
c02f1b49ac814cb27fd692a8107528b6ccfa7ea9e5aea854fc81830abca38eb4 config-baseline.plugin.json
f1b338718b9a058fc3b472a2004c15d146aa83303804c76f7ea831ed3f5593b8 config-baseline.json
063a781ea045218fcf4f8ddc7df152b89eaf9a2ab8ed8049cafce75301425a3a config-baseline.core.json
e9a81ee89ff032033012413e161316e4d07e8f6b206382a25fed2f8485151b5e config-baseline.channel.json
c097c0bee74849e691bf295b070ce189d4e0e501bd770e448922d9f399c4901f config-baseline.plugin.json
+12 -12
View File
@@ -4,7 +4,7 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account
8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution
e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
c706f6f39070110ad2ac9140e2962249f95aee7d4e2a2fa2f7276beb1dfc7efc module/agent-harness-runtime
98c427c987ed9ed54c8ba82c54b9ff0d019d87d7b52066de79aa7f34c449adee module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
2dcb4d62d90e5d71594f6b843c97534509a154784e378fb1c75bd86b5122b710 module/agent-runtime
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
@@ -25,17 +25,17 @@ ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean
16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives
c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel-config-schema
2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract
23fba2ab304d27b33fb8923e542741a03dd93b5b60cffc0337d89c46090ace95 module/channel-core
6e7bb72d2fe65cd2541af6c057dba4faa5079f410f34e08fcb5348ab38e7a666 module/channel-core
9a5aaf650f9242523bb57bdc2556c323ab64e55aa11e25e2685b73b23ee12534 module/channel-dm-policy
fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract
ba41c40956d6b4565605fa38c2d12f4b8471a0f8afe842798716b9032ee4d74a module/channel-entry-contract
982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback
d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel-inbound
76bb7f531f3702c801e8fe7479e9e499f601fb361a4303afdcb45fc0da440e4b module/channel-inbound-debounce
4a7ada095f0f483525dcbd848fbccab26473749eba87e6a6c6e5074fd04ee1d1 module/channel-ingress-runtime
c97dd36cdf8f83c2893c33e9430a93cd131a03d855725783ca5b545de0cf84f8 module/channel-lifecycle
0e6efb79730fae59bb549ad00d9af2848c139b4bb1762e83b66886284e1c1421 module/channel-lifecycle
159d034b431d113f3a6dc41ec0bcadba2d6664051f158330b0e3dd3da8b5d42f module/channel-logging
83297cb5672b5923ad7b288a27c29e265e71584e9a4828a6fa3641d847ce8179 module/channel-message
cb95167f43ad2ebc272ee675df416c2870fd3ff1876a48c5dbeadb5a186422d1 module/channel-outbound
1c7230faa8acc20731781a605d71f991bb3743bec8208650991b45973a9bfdc5 module/channel-message
f04a59d4231b2df8800bf3a6b18183664574d7d145df48fa41f2e3da1ec4697d module/channel-outbound
930beff13ed42a138f65164013c82f4f4c96422292c5fc634a5edee1dce71367 module/channel-pairing
ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel-plugin-common
94ef57c8f6087fcaa56e59e493c391a04377ed03f23c681edd8d8f6e2d64e0da module/channel-policy
@@ -45,7 +45,7 @@ bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel
0ceb4378709eb2d92a62a275f87fa04e18f77df8f942a9a0acef81019ebc1e24 module/channel-secret-runtime
7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result
fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup
8de651a14a46014dc86fb8ccaff819e0854cf6bb2e3b59e642f1e55b37ef43c2 module/channel-status
aeac9bb8127faf636dde79772a577d9cb003660ef3d1df03ddf606101020c402 module/channel-status
d904e33114f056022ef9fec184edcfe37c30b539b268e28b0ff485f0a02b9c59 module/channel-streaming
14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv
c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime
@@ -58,12 +58,12 @@ eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command
20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation
189fa5a240cad0404cd281ad0a14a105a8f3231278d87b71cfbc4f96fb8e48ef module/config-runtime
c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime
9782f7c9fdaec5887dea5f1ef1e2ee4e2dbcb98d7a3dd76726925962d3c2e527 module/core
dea96213010cbc816345b1229534f1acb8b0bcfdbd7814c62eefc77030fa9cd8 module/core
4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime
f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap
6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime
ea81ef06956c1bc0853fa00afbbc2b5a4019116aaf8a436e1b27d06f7a2c9e88 module/directory-runtime
da97e1dc476d65758734ec482d45aa732d195622ef97b3806d4d60ca9c069eca module/discord
e8adcff47c1b677cd2c01a2130fe4d226ac30ab3c88a3ceb4df5a8660316c4a9 module/discord
f65408d85477bb362ebe6ed9148c1bb6b9eb7839733e5e3119f4ff8f1cfd0567 module/error-runtime
b013053a61e7d9be3d0c683c02baf57fa7e4393ec54e0df6a46ab0f2fe2348fd module/extension-shared
ceacad83db01c66e7be6aa21a291597020f13f737b697690eae7d47098e6499a module/gateway-method-runtime
@@ -88,7 +88,7 @@ c5e3eb1a584f4b8126d9d6c177a840ec9103671e8d1242634ee67db9b5b9e573 module/media-u
c0ffaed532578cf33493992e1ff806b2268b8e3774a92edbaede5cf5bda162a6 module/media-understanding-runtime
bebd2931dc51d67c063ff19fa1c278f8dcfe00ab23cfbd480d47329ea8e5088e module/meeting-runtime
aec2225e0341aea994c2d5dd0e642e9c44281ba0aee445574e2355eda4225945 module/memory-core-host-engine-foundation
5d4d709d5ae573186459462fe5119bd253c13104554eb94aefbdab5f5c7ad46f module/memory-host-core
00d6f8bc78256558972d431b7983d7b774e0a98d802a1eec533d2cae1e1f1986 module/memory-host-core
87b7a3206346c0d4b294fb3a2395cbaabc3e73ff8b1b9ea925bc3aade3e52687 module/messaging-targets
5011823e5530df577d800e2b910c4b4f49b59084aa1df3bc03e3962ad3aee279 module/model-session-runtime
44655a08ce111a036c837b8d2e796cf0036941379ab317e5764c6c01e73b39f3 module/models-provider-runtime
@@ -122,7 +122,7 @@ aa8a411ad37c1d1143b67376bf2d20255b9eedff61d80815f42e4f8ed7bd8e58 module/secret-
e576b537880f63b3a91f3608f7e84c873bce6c6a3d9a0ba98c247f46de788d25 module/secret-ref-runtime
62ccaafc8e0677e850339f4a4333f9f16ae9fed979bcef003890b2a47507147f module/security-runtime
673c64502fdffb2d6361a7cf2ad0c33ffe15707b5e5027de1d88701ce3d8ade1 module/session-catalog
31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion
50f5e344f98c27570b7a30e32a906b612e2383d21f102e88cd93e1d5425a6de9 module/session-discussion
f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime
36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup
21071e8c2ef020685aba09b5661e37e7415938ff6602053fa831ac9d58673248 module/setup-runtime
@@ -132,7 +132,7 @@ ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-
9e521fe9073dfd1a6a6855f909fa6befe8613e18403f0a65faaba973a8b630c1 module/ssrf-policy
f85d5be0f635de6a77bbd9224a37c373998e098a8dcf2048ddc2c9ac735b47ef module/ssrf-runtime
ff35f9f74d35d37a2eb6126b57f3dc5a4d580b6222fe3a34c9368779ad32eab7 module/state-paths
9d44964935615a6c2cd83f2dd2307690f15d714b13b85422f711808e0cf4afcc module/status-helpers
44cc071d9ba2baa26d6f33ba9407867fb07c33384f49d3e3d5210232022c1b5f module/status-helpers
537047854c21ad20ea0572f8019503cbda3bbafece8194a28c30b0639bde2fde module/string-coerce-runtime
1b5b9a4532db991fce807ba736b550de27aa5f9de2e1f508a8f4f09cae76ea29 module/telegram-account
110944726884fca94f38c9c329b5950629438b9a719f4782c4beeade8bd67746 module/temp-path
+2 -1
View File
@@ -15,7 +15,8 @@ This directory owns docs authoring, Mintlify link rules, and docs i18n policy.
- For docs, UI copy, and picker lists, order services/providers alphabetically unless the section is explicitly describing runtime order or auto-detection order.
- Keep bundled plugin naming consistent with the repo-wide plugin terminology rules in the root `AGENTS.md`.
- Generated docs, never hand-edit: `docs/plugins/reference/**`, `docs/plugins/reference.md`, and `docs/plugins/plugin-inventory.md` come from `pnpm plugins:inventory:gen`; `docs/docs_map.md` from `pnpm docs:map:gen`; `docs/maturity/**` from `pnpm maturity:render`.
- Generated docs, never hand-edit: `docs/plugins/reference/**`, `docs/plugins/reference.md`, and `docs/plugins/plugin-inventory.md` come from `pnpm plugins:inventory:gen`; `docs/maturity/**` from `pnpm maturity:render`.
- The public and packaged docs map is generated from `pnpm docs:list --headings` during publishing and packaging. Keep only the small source stub at `docs/docs_map.md`; never commit the expanded heading mirror.
## Internal Docs
+13 -7
View File
@@ -1,5 +1,5 @@
---
summary: "BlueBubbles support was removed from OpenClaw. Use the bundled iMessage plugin with imsg for new and migrated iMessage setups."
summary: "BlueBubbles support was removed from OpenClaw. Use the official iMessage plugin with imsg for new and migrated iMessage setups."
read_when:
- You used the old BlueBubbles channel and need to move to iMessage
- You are choosing the supported OpenClaw iMessage setup
@@ -9,7 +9,7 @@ title: "BlueBubbles removal and the imsg iMessage path"
# BlueBubbles removal and the imsg iMessage path
OpenClaw no longer ships the BlueBubbles channel. iMessage support runs through the bundled `imessage` plugin: the Gateway spawns [`imsg`](https://github.com/steipete/imsg) as a child process, locally or through an SSH wrapper, and talks JSON-RPC over stdin/stdout. No server, no webhook, no port.
OpenClaw no longer ships the BlueBubbles channel. iMessage support runs through the official `@openclaw/imessage` plugin: the Gateway spawns [`imsg`](https://github.com/steipete/imsg) as a child process, locally or through an SSH wrapper, and talks JSON-RPC over stdin/stdout. No server, no webhook, no port.
If your config still contains `channels.bluebubbles`, migrate it to `channels.imessage`. The legacy `/channels/bluebubbles` docs URL redirects to [Coming from BlueBubbles](/channels/imessage-from-bluebubbles), which has the full config translation table and cutover checklist.
@@ -23,7 +23,13 @@ If your config still contains `channels.bluebubbles`, migrate it to `channels.im
## What to do
1. Install and verify `imsg` on the Messages Mac:
1. Install the official plugin on the Gateway host, then restart the Gateway:
```bash
openclaw plugins install @openclaw/imessage
```
2. Install and verify `imsg` on the Messages Mac:
```bash
brew install steipete/tap/imsg
@@ -32,9 +38,9 @@ If your config still contains `channels.bluebubbles`, migrate it to `channels.im
imsg rpc --help
```
2. Grant Full Disk Access and Automation permissions to the process context that runs `imsg` and OpenClaw.
3. Grant Full Disk Access and Automation permissions to the process context that runs `imsg` and OpenClaw.
3. Translate the old config:
4. Translate the old config:
```json5
{
@@ -55,13 +61,13 @@ If your config still contains `channels.bluebubbles`, migrate it to `channels.im
}
```
4. Restart the gateway and verify:
5. Restart the gateway and verify:
```bash
openclaw channels status --probe
```
5. Test DMs, groups, attachments, and any private API actions you depend on before deleting your old BlueBubbles server.
6. Test DMs, groups, attachments, and any private API actions you depend on before deleting your old BlueBubbles server.
## Migration notes
+2 -3
View File
@@ -739,7 +739,6 @@ Use the latest-generation, best-tier model available from your provider for untr
{
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
triggers: {
enabled: false,
},
@@ -753,7 +752,7 @@ Use the latest-generation, best-tier model available from your provider for untr
Webhook URLs must not include embedded username/password credentials; use
`webhookToken` when the receiver supports bearer authentication.
`cron.store` is a logical store key and doctor migration path, not a live JSON file to hand-edit. Job data lives in SQLite; use the CLI or Gateway API for changes.
Automation jobs, run history, and quarantined malformed jobs live in the shared SQLite state database. Use the CLI or Gateway API to change jobs; `cron.store` is retired.
Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
@@ -768,7 +767,7 @@ Disable automations: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
`cron.sessionRetention` (default `24h`, `false` disables) prunes isolated run-session entries. Run history keeps the newest 2000 terminal rows per job; lost rows retain their 24-hour cleanup window.
</Accordion>
<Accordion title="Legacy store migration">
On upgrade, run `openclaw doctor --fix` to import legacy `~/.openclaw/cron/jobs.json`, `jobs-state.json`, and `runs/*.jsonl` files into SQLite and rename them with a `.migrated` suffix. Malformed job rows are skipped from runtime and copied to `jobs-quarantine.json` for later repair or review.
On upgrade, run `openclaw doctor --fix` to import historical `~/.openclaw/cron/jobs.json`, `jobs-state.json`, `jobs-quarantine.json`, and `runs/*.jsonl` files into SQLite and archive the originals with a `.migrated` suffix. Malformed job rows remain recoverable in SQLite while valid jobs keep running.
</Accordion>
</AccordionGroup>
+1 -1
View File
@@ -121,7 +121,7 @@ Access groups work in the shared message-channel authorization paths:
- channel-specific per-room sender allowlists that use the same sender matching rules (for example Google Chat `groups.<space>.users`)
- command authorization paths that reuse message-channel sender allowlists
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current bundled support includes ClickClack, Discord, Feishu, Google Chat, iMessage, IRC, LINE, Mattermost, Microsoft Teams, Nextcloud Talk, Nostr, QQ Bot, Signal, Slack, SMS, Telegram, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are channel-agnostic, so new message channels get them by using the shared plugin SDK ingress helpers instead of custom allowlist expansion.
Channel support depends on whether that channel is wired through the shared OpenClaw sender-authorization helpers. Current supported channel integrations include ClickClack, Discord, Feishu, Google Chat, iMessage, IRC, LINE, Mattermost, Microsoft Teams, Nextcloud Talk, Nostr, QQ Bot, Signal, Slack, SMS, Telegram, WhatsApp, Zalo, and Zalo Personal. Static `message.senders` groups are channel-agnostic, so new message channels get them by using the shared plugin SDK ingress helpers instead of custom allowlist expansion.
## Discord channel audiences
+1 -1
View File
@@ -14,7 +14,7 @@ channel converge on the agent's [main session](/concepts/main-session).
## Key terms
- **Channel**: a bundled channel plugin such as `discord`, `googlechat`, `imessage`, `irc`, `line`, `signal`, `slack`, `telegram`, or `whatsapp`, plus installed plugin channels. `webchat` is the internal WebChat UI channel and is not a configurable outbound channel.
- **Channel**: a channel plugin such as `discord`, `googlechat`, `imessage`, `irc`, `line`, `signal`, `slack`, `telegram`, or `whatsapp`. `webchat` is the internal WebChat UI channel and is not a configurable outbound channel.
- **AccountId**: per-channel account instance (when supported).
- Optional channel default account: `channels.<channel>.defaultAccount` chooses
which account is used when an outbound path does not specify `accountId`.
+7 -1
View File
@@ -34,7 +34,7 @@ Create a Discord application with a bot, add the bot to your server, and pair it
<Step title="Enable privileged intents">
Still on the **Bot** page, under **Privileged Gateway Intents** enable:
- **Message Content Intent** (required)
- **Message Content Intent** (required for normal guild messages)
- **Server Members Intent** (recommended; required for role allowlists, name-to-ID matching, and channel-audience access groups)
- **Presence Intent** (optional; only for presence updates)
@@ -203,6 +203,12 @@ openclaw pairing approve discord <CODE>
</Step>
</Steps>
If Discord cannot grant Message Content Intent, OpenClaw can still operate in DMs and in
guild channels where users explicitly mention the bot. Set
`channels.discord.intents.messageContent: false` so the Gateway does not request the
unavailable privileged intent, and keep `requireMention: true` on every configured guild
channel. Discord omits user-authored content from other guild messages in this mode.
<Note>
Token resolution is account-aware. Config token values win over the env fallback, and `DISCORD_BOT_TOKEN` is only used for the default account.
If two enabled Discord accounts resolve to the same bot token, OpenClaw starts only one gateway monitor for that token: a config-sourced token wins over the env fallback; otherwise the first enabled account wins and the duplicate account is reported disabled with reason `duplicate bot token`.
+15 -14
View File
@@ -1,13 +1,13 @@
---
summary: "Translate old BlueBubbles configs to the bundled iMessage plugin: key mapping, group allowlist gates, and cutover verification."
summary: "Translate old BlueBubbles configs to the official iMessage plugin: key mapping, group allowlist gates, and cutover verification."
read_when:
- Planning a move from BlueBubbles to the bundled iMessage plugin
- Planning a move from BlueBubbles to the official iMessage plugin
- Translating BlueBubbles config keys to iMessage equivalents
- Verifying imsg before enabling the iMessage plugin
title: "Coming from BlueBubbles"
---
BlueBubbles support was removed. OpenClaw supports iMessage only through the bundled `imessage` plugin, which drives [`steipete/imsg`](https://github.com/steipete/imsg) over JSON-RPC and reaches the same private API surface BlueBubbles had (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, native polls, group management, attachments). One CLI binary replaces the BlueBubbles server + client app + webhook plumbing: no REST endpoint, no webhook auth.
BlueBubbles support was removed. OpenClaw supports iMessage only through the official `@openclaw/imessage` plugin, which drives [`steipete/imsg`](https://github.com/steipete/imsg) over JSON-RPC and reaches the same private API surface BlueBubbles had (`react`, `edit`, `unsend`, `reply`, `sendWithEffect`, native polls, group management, attachments). One CLI binary replaces the BlueBubbles server + client app + webhook plumbing: no REST endpoint, no webhook auth.
This guide migrates old `channels.bluebubbles` configs to `channels.imessage`. There is no other supported migration path. On current OpenClaw a leftover `channels.bluebubbles` block is inert — no runtime reads it.
@@ -19,13 +19,14 @@ For the short announcement and operator summary, see [BlueBubbles removal and th
The shortest safe path when you already know your old BlueBubbles config:
1. Verify `imsg` directly on the Mac that runs Messages.app (`imsg chats`, `imsg history`, `imsg send`, `imsg rpc --help`).
2. Copy behavior keys from `channels.bluebubbles` to `channels.imessage`: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `includeAttachments`, `attachmentRoots`, `mediaMaxMb`, `textChunkLimit`, and `actions`.
3. Drop transport keys that no longer exist: `serverUrl`, `password`, webhook URLs, and BlueBubbles server setup.
4. If the Gateway is not running on the Messages Mac, set `channels.imessage.cliPath` to an SSH wrapper and set `remoteHost` for remote attachment fetches.
5. Enable `channels.imessage`, restart the Gateway, then run `openclaw channels status --probe --channel imessage`.
6. Test one DM, one allowed group, attachments if enabled, and every private API action you expect the agent to use.
7. Delete the BlueBubbles server and the old `channels.bluebubbles` config after the iMessage path is verified.
1. Install the official plugin with `openclaw plugins install @openclaw/imessage`, then restart the Gateway.
2. Verify `imsg` directly on the Mac that runs Messages.app (`imsg chats`, `imsg history`, `imsg send`, `imsg rpc --help`).
3. Copy behavior keys from `channels.bluebubbles` to `channels.imessage`: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `includeAttachments`, `attachmentRoots`, `mediaMaxMb`, `textChunkLimit`, and `actions`.
4. Drop transport keys that no longer exist: `serverUrl`, `password`, webhook URLs, and BlueBubbles server setup.
5. If the Gateway is not running on the Messages Mac, set `channels.imessage.cliPath` to an SSH wrapper and set `remoteHost` for remote attachment fetches.
6. Enable `channels.imessage`, restart the Gateway, then run `openclaw channels status --probe --channel imessage`.
7. Test one DM, one allowed group, attachments if enabled, and every private API action you expect the agent to use.
8. Delete the BlueBubbles server and the old `channels.bluebubbles` config after the iMessage path is verified.
## What imsg does
@@ -89,7 +90,7 @@ The shortest safe path when you already know your old BlueBubbles config:
iMessage and BlueBubbles share most channel-level behavior keys. What changes is transport (REST server vs local CLI) and the group registry key format.
| BlueBubbles | bundled iMessage | Notes |
| BlueBubbles | iMessage plugin | Notes |
| ---------------------------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `channels.bluebubbles.enabled` | `channels.imessage.enabled` | Same semantics (default `true` once the block exists). |
| `channels.bluebubbles.serverUrl` | _(removed)_ | No REST server — the plugin spawns `imsg rpc` over stdio. |
@@ -102,7 +103,7 @@ iMessage and BlueBubbles share most channel-level behavior keys. What changes is
| `channels.bluebubbles.groupPolicy` | `channels.imessage.groupPolicy` | Same values (`allowlist` / `open` / `disabled`); default `allowlist`. |
| `channels.bluebubbles.groupAllowFrom` | `channels.imessage.groupAllowFrom` | Same. When unset, iMessage falls back to `allowFrom`; an explicitly empty `groupAllowFrom: []` blocks all groups under `groupPolicy: "allowlist"`. |
| `channels.bluebubbles.groups` | `channels.imessage.groups` | Copy the `"*"` wildcard entry verbatim; re-key per-group entries by numeric iMessage `chat_id` — see "Group registry footgun". `requireMention`, `tools`, `toolsBySender`, `systemPrompt` carry over. |
| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. With the bundled plugin this only fires when the private API probe is up. |
| `channels.bluebubbles.sendReadReceipts` | `channels.imessage.sendReadReceipts` | Default `true`. This only fires when the private API probe is up. |
| `channels.bluebubbles.includeAttachments` | `channels.imessage.includeAttachments` | Same shape, same off-by-default. If attachments flowed on BlueBubbles, set this explicitly — inbound photos/media are silently dropped (no `Inbound message` log line) until you do. |
| `channels.bluebubbles.attachmentRoots` | `channels.imessage.attachmentRoots` | Local roots; same wildcard rules. |
| _(N/A)_ | `channels.imessage.remoteAttachmentRoots` | Only used when `remoteHost` is set for SCP fetches. |
@@ -116,7 +117,7 @@ Multi-account configs (`channels.bluebubbles.accounts.*`) translate one-to-one t
## Group registry footgun
The bundled iMessage plugin runs two group gates back to back. A group message must pass both to reach the agent:
The iMessage plugin runs two group gates back to back. A group message must pass both to reach the agent:
1. **Sender / chat-target allowlist** (`channels.imessage.groupAllowFrom`) — matches the sender handle or the chat target (`chat_id:`, `chat_guid:`, `chat_identifier:` entries). When `groupAllowFrom` is unset, this gate falls back to `allowFrom`; an explicit `groupAllowFrom: []` disables that fallback and drops every group message under `groupPolicy: "allowlist"`.
2. **Group registry** (`channels.imessage.groups`) — keyed by numeric iMessage `chat_id`:
@@ -188,7 +189,7 @@ This admits the configured senders in any group. Add `groups` entries to scope a
## Action parity at a glance
| Action | legacy BlueBubbles | bundled iMessage |
| Action | legacy BlueBubbles | iMessage plugin |
| --------------------------------------------------- | ------------------ | ----------------------------------------------------------------------------- |
| Send text / SMS fallback | ✅ | ✅ |
| Send media (photo, video, file, voice) | ✅ | ✅ |
+9 -1
View File
@@ -20,6 +20,14 @@ Status: native external CLI integration. The Gateway spawns `imsg rpc` and speak
For the common local setup, OpenClaw setup can offer a user-confirmed Homebrew install or update for `imsg` on the signed-in Messages Mac. Manual setup and SSH-wrapper topologies remain operator-managed: install or update `imsg` in the same user context that will run the Gateway or wrapper.
## Install the plugin
Install the official iMessage plugin on the Gateway host, then restart the Gateway:
```bash
openclaw plugins install @openclaw/imessage
```
<CardGroup cols={3}>
<Card title="Private API actions" icon="wand-sparkles" href="#private-api-actions">
Replies, tapbacks, effects, polls, attachments, and group management.
@@ -200,7 +208,7 @@ The helper-injection technique uses `imsg`'s own dylib to reach Messages private
<Warning>
**Disabling SIP is a real security tradeoff.** SIP is one of macOS's core protections against running modified system code; turning it off system-wide opens up additional attack surface and side effects. Notably, **disabling SIP on Apple Silicon Macs also disables the ability to install and run iOS apps on your Mac**.
Treat this as a deliberate operational choice, especially on a primary personal Mac. For production-quality OpenClaw iMessage, prefer a dedicated Mac or bot macOS user where you are comfortable enabling the bridge. If your threat model cannot tolerate SIP being off anywhere, bundled iMessage is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops.
Treat this as a deliberate operational choice, especially on a primary personal Mac. For production-quality OpenClaw iMessage, prefer a dedicated Mac or bot macOS user where you are comfortable enabling the bridge. If your threat model cannot tolerate SIP being off anywhere, the iMessage plugin is limited to basic mode — text and media send/receive only, no reactions / edit / unsend / effects / group ops.
</Warning>
### Setup
+2 -2
View File
@@ -9,7 +9,7 @@ title: "Chat channels"
OpenClaw can talk to you on any chat app you already use. Each channel connects via the Gateway.
Text is supported everywhere; media and reactions vary by channel.
iMessage, Telegram, and the WebChat UI ship with the core install. Channels marked
Telegram and the WebChat UI ship with the core install. Channels marked
"official plugin" install with one command (`openclaw plugins install @openclaw/<id>`)
or on demand during `openclaw onboard` / `openclaw channels add`, then need a Gateway
restart. "External plugin" channels are maintained outside the OpenClaw repo.
@@ -20,7 +20,7 @@ restart. "External plugin" channels are maintained outside the OpenClaw repo.
- [Discord](/channels/discord) - Discord Bot API + Gateway; supports servers, channels, and DMs (official plugin).
- [Feishu](/channels/feishu) - Feishu/Lark bot via WebSocket (official plugin).
- [Google Chat](/channels/googlechat) - Google Chat API app via HTTP webhook (official plugin).
- [iMessage](/channels/imessage) - Included in core. Native macOS integration via the `imsg` bridge on a signed-in Mac (or SSH wrapper when the Gateway runs elsewhere), including private API actions for replies, tapbacks, effects, attachments, and group management.
- [iMessage](/channels/imessage) - Native macOS integration via the `imsg` bridge on a signed-in Mac (or SSH wrapper when the Gateway runs elsewhere), including private API actions for replies, tapbacks, effects, attachments, and group management (official plugin).
- [IRC](/channels/irc) - Classic IRC servers; channels + DMs with pairing/allowlist controls (official plugin).
- [LINE](/channels/line) - LINE Messaging API bot (official plugin).
- [Matrix](/channels/matrix) - Matrix protocol (official plugin).
+1 -1
View File
@@ -101,7 +101,7 @@ Separate iOS and macOS Periphery workflows enforce a zero-findings dead-code pol
- **CI workflow edits** validate the Node CI graph, workflow linting, and the Windows lane (`ci.yml` executes it), but do not force iOS, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
- **TUI PTY** splits by runtime ownership. The Linux Node shard runs the deterministic source-level `TuiBackend` fixture lane. The `build-artifacts` job reruns `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and `OPENCLAW_TUI_PTY_USE_BUILT_CLI=1`, so the slower `tui --local` smoke exercises the exact-head built CLI while mocking only the external model endpoint.
- **CI routing-only edits, the small set of core-test fixtures the fast task runs directly, and narrow plugin contract helper edits** use a fast Node-only manifest path: `preflight`, `security-fast`, and only the fast lanes the change touches — a single `checks-fast-core` CI-routing task, the two plugin contract shards, or both. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
+2 -2
View File
@@ -381,7 +381,7 @@ openclaw config set channels.discord.token \
{
"ok": true,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"configPath": "/home/user/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
@@ -398,7 +398,7 @@ openclaw config set channels.discord.token \
{
"ok": false,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"configPath": "/home/user/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
+7 -2
View File
@@ -360,7 +360,12 @@ it writes the local support report and prints a prefilled issue URL.
`restore` remains the lower-level undo operation. It uses manifest
`sourcePath -> archivePath` records, moves archived artifacts back only when the
original path is missing, reports conflicts when both paths exist, and leaves
the SQLite database in place.
the SQLite database in place. When several manifests recorded the same original
path, restore plans all candidates before moving any of them. Identical archives
are safe duplicates, and one nonempty legacy `sessions.json` may supersede empty
copies created by older writers. Distinct nonempty indexes, distinct transcript
archives, invalid archives, and archives missing without a recorded prior
restore fail closed so restore cannot silently replace or hide recoverable data.
### Downgrading After Session SQLite Migration
@@ -396,7 +401,7 @@ compare restored legacy artifacts with the SQLite rows before importing.
- On Linux, doctor ignores inactive extra gateway-like systemd units and does not rewrite command/entrypoint metadata for a running systemd gateway service during repair. Stop the service first, or use `openclaw gateway install --force` to replace the active launcher.
- `doctor --fix --non-interactive` reports missing or stale gateway service definitions but does not install or rewrite them outside update repair mode. Run `openclaw gateway install` for a missing service, or `openclaw gateway install --force` to replace the launcher.
- State integrity checks detect orphan transcript files in the sessions directory. Archiving them as `.deleted.<timestamp>` requires interactive confirmation; `--fix`, `--yes`, and headless runs leave them in place.
- Doctor scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and rewrites them before importing canonical rows into SQLite.
- Doctor scans historical `~/.openclaw/cron/jobs.json` stores and previously configured legacy store locations for old cron job shapes, imports jobs and quarantine records into SQLite, and archives the migrated JSON files.
- Doctor reports cron jobs with an explicit `payload.model` override, including provider-namespace counts and mismatches against `agents.defaults.model`, so scheduled jobs that do not inherit the default model are visible during auth or billing investigations.
- Doctor reports cron jobs still marked in-flight (`state.runningAtMs`), which can make `openclaw cron list` show them as `running`. This check is read-only: if no Gateway is currently executing a marked job, the next cron service startup records the interrupted run and clears the marker.
- On Linux, doctor warns when the user's crontab still runs the unmaintained legacy `~/.openclaw/bin/ensure-whatsapp.sh`, which can misreport `Gateway inactive` when cron lacks the systemd user-bus environment.
+10
View File
@@ -129,6 +129,16 @@ openclaw gateway restart --wait 30s
Inline `--password` can be exposed in local process listings. Prefer `--password-file`, env, or a SecretRef-backed `gateway.auth.password`.
</Warning>
### Install identity
Service management (`install`, `start`, `stop`, `restart`, `uninstall`, Doctor service repair, and self-update service handling) belongs to the install that owns the host service. That is the canonical `.openclaw` directory under the OS account home, or the `.openclaw-<profile>` directory a named profile projects there. Named profiles use distinct native service identities.
`OPENCLAW_HOME`, or an `OPENCLAW_STATE_DIR` or `OPENCLAW_CONFIG_PATH` that points elsewhere, is treated as isolated state and skipped. A relocated or copied state tree cannot adopt and rewrite the account's host service.
On macOS and Windows, native service-managed profile names must be lowercase. Runtime-only profiles may still use uppercase, but case-distinct names such as `Main` and `main` share paths on normal case-insensitive filesystems and cannot safely own separate native services. On macOS, the lowercase names `gateway` and `node` are also unavailable for native service management because their historical LaunchAgent labels collide with the default Gateway and node-host services.
Named profiles must also use the native service identity derived from `OPENCLAW_PROFILE`. Unset `OPENCLAW_LAUNCHD_LABEL`, `OPENCLAW_SYSTEMD_UNIT`, or `OPENCLAW_WINDOWS_TASK_NAME` before service management; custom identities remain available for the default profile or runtime-only/external-supervisor setups.
### External supervisors
Set `OPENCLAW_SUPERVISOR_MODE=external` only when another process manager owns the Gateway lifecycle. In this mode:
+19
View File
@@ -47,6 +47,9 @@ Running `openclaw migrate <provider>` with no other flags plans, previews, and (
<ParamField path="--from <path>" type="string">
Override the source state directory. Hermes follows `$HERMES_HOME` and the active profile, then uses the platform default (`~/.hermes` or `%LOCALAPPDATA%\hermes`). Codex defaults to `~/.codex` (or `$CODEX_HOME`), Claude defaults to `~/.claude`.
</ParamField>
<ParamField path="--agent <id>" type="string">
Import into a configured agent. Omit this only when the configured default agent is the intended owner. Invalid and unknown agent IDs are rejected.
</ParamField>
<ParamField path="--include-secrets" type="boolean">
Import supported credentials without prompting. Interactive apply asks before importing detected auth credentials, with yes selected by default; non-interactive `--yes` requires `--include-secrets` to import them.
</ParamField>
@@ -65,6 +68,9 @@ Running `openclaw migrate <provider>` with no other flags plans, previews, and (
<ParamField path="--plugin <name>" type="string">
Select one Codex plugin install item by plugin name or item id. Repeat the flag to migrate multiple Codex plugins. When omitted, interactive Codex migrations show a native Codex plugin checkbox selector and non-interactive migrations keep all planned plugins. Applies only to source-installed `openai-curated` Codex plugins discovered by the Codex app-server inventory.
</ParamField>
<ParamField path="--item <id>" type="string">
Select one exact migration item by its plan ID. Repeat the flag to migrate multiple items. For example, `--item auth:openai` limits a Codex migration to the detected OpenAI credential item.
</ParamField>
<ParamField path="--verify-plugin-apps" type="boolean">
Codex only. Forces a fresh source Codex app-server `app/installed` snapshot read before planning native plugin activation. Off by default to keep migration planning fast.
</ParamField>
@@ -132,6 +138,16 @@ The bundled Codex provider detects Codex CLI state at `~/.codex` by default, or
Use this provider when moving to the OpenClaw Codex harness and you want to promote useful personal Codex CLI assets deliberately. Local Codex app-server launches use a per-agent `CODEX_HOME`, so they do not read your personal `~/.codex` by default. The normal process `HOME` is still inherited, so Codex can see shared `$HOME/.agents/*` skills/plugin marketplace entries and subprocesses can find user-home config and tokens.
Codex `auth.json` credentials are sensitive migration inputs. The default
agent-scoped runtime does not consume a copied or mounted `auth.json` directly;
import those credentials into the owning agent's OpenClaw auth store explicitly.
Replace `<agent-id>` with that configured agent's ID:
```bash
openclaw migrate plan codex --from <codex-home> --agent <agent-id> --include-secrets --item auth:openai
openclaw migrate apply codex --from <codex-home> --agent <agent-id> --include-secrets --item auth:openai --yes
```
Running `openclaw migrate codex` in an interactive terminal previews the full plan, then opens checkbox selectors before the final apply confirmation. Skill copy items are prompted first. Use `Toggle all on` or `Toggle all off` for bulk selection. Press Space to toggle rows, or Enter to activate the highlighted row and continue. Planned skills start checked, conflict skills start unchecked, and `Skip for now` skips skill copies for this run while still continuing to plugin selection. When source-installed curated Codex plugins are migratable and `--plugin` was not supplied, migration then prompts for native Codex plugin activation by plugin name. Plugin items start checked unless the target OpenClaw Codex plugin config already has that plugin. Existing target plugins start unchecked and show a conflict hint such as `conflict: plugin exists`; choose `Toggle all off` to migrate no native Codex plugins in that run, or `Skip for now` to stop before applying.
For scripted or exact runs, select one or more skills or plugins explicitly:
@@ -145,6 +161,9 @@ openclaw migrate apply codex --yes --plugin google-calendar
### What Codex imports
- ChatGPT OAuth or OpenAI API-key credentials from `$CODEX_HOME/auth.json`,
imported into the agent's OpenClaw auth store only when `--include-secrets`
is set.
- Consolidated Codex `MEMORY.md` and `memory_summary.md` from
`$CODEX_HOME/memories`, copied under `memory/imports/codex/` for indexed
recall. Raw rollout memory is not imported.
+1 -1
View File
@@ -508,7 +508,7 @@ The `--json` flag outputs a machine-readable report suitable for scripting and a
openclaw plugins doctor
```
`doctor` reports plugin load errors, manifest/discovery diagnostics, compatibility notices, and stale plugin config references such as missing plugin slots. When the install tree and plugin config are clean it prints `No plugin issues detected.` If stale config remains but the install tree is otherwise healthy, the summary says so instead of implying full plugin health.
`doctor` reports plugin load errors, manifest/discovery diagnostics, compatibility notices, and stale plugin config references such as missing plugin slots. It loads plugin modules without activating plugins and does not query the running Gateway. When these local checks pass, it prints `Plugin discovery, module loading, compatibility, and configuration checks passed. Run "openclaw health" to check the running Gateway, including runtime quarantines and fallbacks.` The [health command](/cli/health) reads current runtime quarantine and fallback state from the Gateway. If stale config remains but the install tree is otherwise healthy, the summary says so instead of implying full plugin health.
If a configured plugin is present on disk but blocked by the loader's path-safety checks, config validation keeps the plugin entry and reports it as `present but blocked`. Fix the preceding blocked-plugin diagnostic, such as path ownership or world-writable permissions, instead of removing the `plugins.entries.<id>` or `plugins.allow` config.
+3 -3
View File
@@ -251,7 +251,7 @@ returns the latest sentinel.
Dev only.
</Step>
<Step title="Preflight build (dev only)">
Runs the TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest buildable commit. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners.
Runs the TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest buildable commit. Content-addressed declaration outputs from the successful candidate are reused by the final checkout build; rebased source changes automatically invalidate the affected cache groups. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners.
</Step>
<Step title="Rebase">
Rebases onto the selected commit (dev only).
@@ -259,8 +259,8 @@ returns the latest sentinel.
<Step title="Install dependencies">
Uses the repo package manager. For pnpm checkouts, the updater bootstraps `pnpm` on demand (via `corepack` first, then a temporary `npm install pnpm@11` fallback) instead of running `npm run build` inside a pnpm workspace. If pnpm bootstrap still fails, the updater stops early with a package-manager-specific error instead of trying `npm run build` in the checkout.
</Step>
<Step title="Build Control UI">
Builds the gateway and the Control UI.
<Step title="Build checkout">
Builds the gateway and Control UI once in the final checkout. The updater runs the standalone Control UI build only when a target build omitted those assets or doctor later removes them.
</Step>
<Step title="Run doctor">
`openclaw doctor` runs as the final safe-update check.
+2 -2
View File
@@ -32,10 +32,10 @@ title: "Features"
**Channels:**
- iMessage, Telegram, and WebChat ship with the core install; every other channel is an
- Telegram and WebChat ship with the core install; every other channel is an
official plugin installed with `openclaw plugins install @openclaw/<id>` (or on demand
during `openclaw onboard` / `openclaw channels add`)
- Official plugin channels: Discord, Feishu, Google Chat, IRC, LINE, Matrix, Mattermost,
- Official plugin channels: Discord, Feishu, Google Chat, iMessage, IRC, LINE, Matrix, Mattermost,
Microsoft Teams, Nextcloud Talk, Nostr, QQ Bot, Raft, Signal, Slack, SMS, Synology Chat,
Tlon, Twitch, Voice Call, WhatsApp, Zalo, and Zalo Personal
- External plugin channels maintained outside the OpenClaw repo: WeChat, Yuanbao, and Zalo ClawBot
+2 -2
View File
@@ -288,7 +288,7 @@ Channels supporting multiple accounts: `discord`, `feishu`, `googlechat`, `imess
guilds: {
"123456789012345678": {
channels: {
"222222222222222222": { allow: true, requireMention: false },
"222222222222222222": { enabled: true, requireMention: false },
},
},
},
@@ -298,7 +298,7 @@ Channels supporting multiple accounts: `discord`, `feishu`, `googlechat`, `imess
guilds: {
"123456789012345678": {
channels: {
"333333333333333333": { allow: true, requireMention: false },
"333333333333333333": { enabled: true, requireMention: false },
},
},
},
+7 -6
View File
@@ -1230,12 +1230,13 @@ The minimum adoption bar for a new channel:
4. Mount the runner as `openclaw qa <runner>` instead of registering a
competing root command. Runner plugins should declare `qaRunners` in
`openclaw.plugin.json` and export a matching `qaRunnerCliRegistrations`
array from `runtime-api.ts`. Keep `runtime-api.ts` light; lazy CLI and
runner execution should stay behind separate entrypoints. An optional
`adapterFactory` exposes the transport to shared scenarios without changing
the command's existing scenario catalog. Same-channel partitions are serial
unless the factory declares that every instance owns isolated credentials or
disposable servers, Gateway state, and artifact paths.
array from a lightweight `qa-runner-api.ts` surface. Installed plugins using
the shipped `runtime-api.ts` contract remain supported through 2026-10-01
while authors migrate. Keep runner execution behind lazy entrypoints. An
optional `adapterFactory` exposes the transport to shared scenarios without
changing the command's existing scenario catalog. Same-channel partitions
are serial unless the factory declares that every instance owns isolated
credentials or disposable servers, Gateway state, and artifact paths.
5. Author or adapt YAML scenarios under the themed `qa/scenarios/`
directories.
6. Use the generic scenario helpers for new scenarios.
+3 -11067
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -111,6 +111,29 @@ Capability-gated agent tools are a separate use of the same declaration. If an
agent tool requires a client capability, the Gateway omits that tool unless the
originating client advertised every required capability.
## Validate attachments before sending
Attachment limits are operator-tunable, so do not hardcode them. Read
`hello-ok.policy.attachments` and validate locally before uploading:
```ts
const attachments = hello.policy.attachments;
if (attachments) {
const ceiling = isImage ? attachments.maxImageBytes : attachments.maxBytes;
if (file.byteLength > ceiling) rejectLocally();
}
```
Both values are decoded per-attachment ceilings. Still check the serialized
request against `policy.maxPayload`: attachments travel as base64, so a file near
`maxBytes` can exceed the frame limit on its own. Older gateways omit
`policy.attachments`; when it is absent, send and handle the server outcome.
Accepted MIME types and per-message handling are not advertised because they
depend on the entrypoint and the resolved model. The gateway can return a typed
rejection, while text-only model runs can omit additional images after their
offload cap and still complete the request. The values are a connection-time
snapshot, so re-read them on every reconnect.
## Recover state after reconnect
Treat every successful reconnect as a new projection over durable history and
+8 -26
View File
@@ -13,7 +13,7 @@ For agents, tools, gateway runtime, and other top-level keys, see [Configuration
## Channels
Each channel starts automatically when its config section exists (unless `enabled: false`). Telegram and iMessage ship inside the core `openclaw` package. Other official channels (Discord, Slack, WhatsApp, Matrix, Microsoft Teams, IRC, Google Chat, Signal, Mattermost, and more) install as separate plugins with `openclaw plugins install <spec>`; see [Channels](/channels) for the full list and install specs.
Each channel starts automatically when its config section exists (unless `enabled: false`). Telegram ships inside the core `openclaw` package. Other official channels (iMessage, Discord, Slack, WhatsApp, Matrix, Microsoft Teams, IRC, Google Chat, Signal, Mattermost, and more) install as separate plugins with `openclaw plugins install <spec>`; see [Channels](/channels) for the full list and install specs.
### DM and group access
@@ -268,9 +268,9 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
reactionNotifications: "own",
users: ["987654321098765432"],
channels: {
general: { allow: true },
general: { enabled: true },
help: {
allow: true,
enabled: true,
requireMention: true,
users: ["987654321098765432"],
skills: ["docs"],
@@ -293,11 +293,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
},
},
maxLinesPerMessage: 17,
ui: {
components: {
accentColor: "#5865F2",
},
},
threadBindings: {
enabled: true,
idleHours: 24,
@@ -319,7 +314,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
reconnectGraceMs: 15000,
tts: {
provider: "openai",
openai: { voice: "alloy" },
providers: { openai: { speakerVoice: "alloy" } },
},
},
execApprovals: {
@@ -330,19 +325,13 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
target: "dm", // dm | channel | both
cleanupAfterResolve: false,
},
retry: {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30000,
jitter: 0.1,
},
},
},
}
```
- Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account retry/policy settings still come from the selected account in the active runtime snapshot.
- Direct outbound calls that provide an explicit Discord `token` use that token for the call; account policy settings still come from the selected account in the active runtime snapshot.
- Optional `channels.discord.defaultAccount` overrides default account selection when it matches a configured account id.
- Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
- Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
@@ -359,7 +348,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `spawnSessions`: switch for `sessions_spawn({ thread: true })` and ACP thread-spawn auto thread creation/binding (default: `true`)
- `defaultSpawnContext`: native subagent context for thread-bound spawns (`"fork"` by default)
- Top-level `bindings[]` entries with `type: "acp"` configure persistent ACP bindings for channels and threads (use channel/thread id in `match.peer.id`). Field semantics are shared in [ACP Agents](/tools/acp-agents#persistent-channel-bindings).
- `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
- `channels.discord.agentComponents.ttlMs` controls how long sent Discord component callbacks remain registered. Default `1800000` (30 minutes), maximum `86400000` (24 hours). Per-account overrides live under `channels.discord.accounts.<accountId>.agentComponents.ttlMs`. Prefer the shortest TTL that fits the workflow.
- `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + LLM + TTS overrides. Text-only Discord configs leave voice off by default; set `channels.discord.voice.enabled=true` to opt in.
- `channels.discord.voice.model` optionally overrides the LLM model used for Discord voice channel responses.
@@ -371,6 +359,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
- `channels.discord.streaming` is the canonical stream mode key. Discord defaults to `streaming.mode: "progress"` so tool/work progress appears in one edited preview message; set `streaming.mode: "off"` to disable it. Legacy flat keys (`streamMode`, `chunkMode`, `blockStreaming`, `draftChunk`, `blockStreamingCoalesce`) are no longer read at runtime; run `openclaw doctor --fix` to migrate persisted config.
- `channels.discord.autoPresence` maps runtime availability to bot presence (healthy => online, degraded => idle, exhausted => dnd) and allows optional status text overrides.
- `channels.discord.guilds.<id>.presenceEvents` routes human availability arrivals into one configured Discord channel as agent system events. Eligible members must be able to view `channelId`; public threads inherit parent visibility, while private threads additionally require membership or Manage Threads. `users` can further narrow that audience. It seeds current online members from complete `GUILD_CREATE` snapshots, routes observed offline-to-online transitions, and treats a first later online signal for an unseen member as newly available without asserting whether they came online or joined after the snapshot. Guilds above Discord's 75,000-member snapshot limit require an explicit offline update first. Throttling knobs: `reconnectSuppressSeconds` (quiet window after a new Gateway session while guild presence state is rebuilt, default 300, `0` disables) and `burstLimit`/`burstWindowSeconds` (per-guild successfully queued event rate limit, default 8 events per 60s sliding window). Resumed sessions do not start the reconnect suppression window. The existing per-user re-greet cooldown remains eight hours. It requires `channels.discord.intents.presence=true`, the privileged Presence Intent in Discord's Developer Portal, and an enabled agent heartbeat.
- `channels.discord.intents.messageContent` defaults to `true`. Set it to `false` only for mention-only operation when Discord cannot grant the privileged Message Content intent; DMs and explicit bot mentions still carry message content, while other guild messages do not. Keep `requireMention: true` on every configured guild channel in this mode.
- `channels.discord.dangerouslyAllowNameMatching` re-enables mutable name/tag matching (break-glass compatibility mode).
- `channels.discord.execApprovals`: Discord-native exec approval delivery and approver authorization.
- `enabled`: `true`, `false`, or `"auto"` (default). In auto mode, exec approvals activate when approvers can be resolved from `approvers` or `commands.ownerAllowFrom`.
@@ -398,9 +387,8 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
allowFrom: ["users/1234567890"],
groupPolicy: "allowlist",
groups: {
"spaces/AAAA": { allow: true, requireMention: true },
"spaces/AAAA": { enabled: true, requireMention: true },
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20,
},
@@ -423,17 +411,12 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
enabled: true,
botToken: "xoxb-...",
appToken: "xapp-...",
socketMode: {
clientPingTimeout: 15000,
serverPingTimeout: 30000,
pingPongLoggingEnabled: false,
},
dmPolicy: "pairing",
allowFrom: ["U123", "U456", "*"],
dm: { enabled: true, groupEnabled: false, groupChannels: ["G123"] },
channels: {
C123: { enabled: true, requireMention: true, allowBots: false },
"#general": {
C456: {
enabled: true,
requireMention: true,
allowBots: false,
@@ -504,7 +487,6 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
notifications and reaction action tools are unavailable. See
[Enterprise Grid org-wide installs](/channels/slack#enterprise-grid-org-wide-installs)
for the least-privilege manifest, setup workflow, and complete restrictions.
- `socketMode` passes Slack SDK Socket Mode transport tuning through to the public Bolt receiver API. Use it only when investigating ping/pong timeout or stale websocket behavior. `clientPingTimeout` defaults to `15000`; `serverPingTimeout` and `pingPongLoggingEnabled` are passed only when configured.
- `botToken`, `appToken`, `signingSecret`, and `userToken` accept plaintext
strings or SecretRef objects.
- Slack account snapshots expose per-credential source/status fields such as
+40 -2
View File
@@ -579,8 +579,8 @@ Configuring a custom/local provider `baseUrl` is also the narrow network trust d
| `requiresAssistantAfterToolResult` | Requires an assistant message after tool results. |
| `requiresThinkingAsText` | Replays reasoning as text rather than structured content. |
| `requiresReasoningContentOnAssistantMessages` | Preserves DeepSeek-style `reasoning_content` during replay. |
| `toolSchemaProfile` | Selects a provider-defined tool-schema normalization profile. |
| `unsupportedToolSchemaKeywords` | Removes named JSON Schema keywords rejected by the endpoint. |
| `toolSchemaProfile` | Selects a tool-schema normalization profile. Custom model entries recognize `llamacpp` and `gemini`. The `llamacpp` profile removes `pattern` and `maxLength` values at or above 2000; built-in `llama-cpp`, `ollama`, and `lmstudio` providers apply the same cleaner automatically. Custom `llama-server` models must select it explicitly. See the llama.cpp example below. |
| `unsupportedToolSchemaKeywords` | Removes named JSON Schema keywords rejected by the endpoint before tool schemas are sent. Use this for endpoint-specific gaps beyond a profile's targeted transformations. |
| `toolCallArgumentsEncoding` | Selects the endpoint's tool-call argument encoding. |
| `requiresOpenAiAnthropicToolPayload` | Converts OpenAI-shaped tool calls to Anthropic-family payloads. |
@@ -655,6 +655,44 @@ Interactive custom-provider onboarding infers image input for known vision-model
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
</Accordion>
<Accordion title="Local models (llama.cpp / llama-server)">
Point a **custom** `openai-completions` provider at a remote `llama-server` (or another OpenAI-compatible llama.cpp endpoint). The built-in `llama-cpp`, `ollama`, and `lmstudio` providers apply the llama.cpp schema cleaner automatically; a custom endpoint does not. Set `compat.toolSchemaProfile: "llamacpp"` on each model whose llama-server chat template compiles tool arguments into GBNF. The profile removes `pattern` and `maxLength` values at or above 2000, covering the `cron` tool's `trigger.script` limit of 65536. It is a targeted mitigation, not complete compatibility for every JSON Schema constraint or `minLength`.
```json5
{
agents: {
defaults: {
model: { primary: "my-llamacpp/qwen35" },
},
},
models: {
mode: "merge",
providers: {
"my-llamacpp": {
baseUrl: "http://127.0.0.1:8080/v1",
apiKey: "llamacpp-no-key",
api: "openai-completions",
models: [
{
id: "qwen35",
name: "Qwen3.5 (llama-server)",
contextWindow: 8192,
maxTokens: 2048,
compat: {
supportsTools: true,
toolSchemaProfile: "llamacpp",
},
},
],
},
},
},
}
```
On older builds without `toolSchemaProfile`, the broader fallback is `compat.unsupportedToolSchemaKeywords: ["pattern", "patternProperties", "format", "propertyNames", "uniqueItems", "contains", "minContains", "maxContains", "minLength", "maxLength"]`. Unlike the profile, this removes every listed keyword unconditionally.
</Accordion>
<Accordion title="Local models (LM Studio)">
See [Local Models](/gateway/local-models). TL;DR: run a large local model via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
-1
View File
@@ -367,7 +367,6 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
// Cron jobs
cron: {
enabled: true,
store: "~/.openclaw/cron/jobs.json",
sessionRetention: "24h",
},
+7 -9
View File
@@ -32,7 +32,7 @@ Dedicated deep references:
## Channels
Per-channel config keys live in [Configuration - channels](/gateway/config-channels): `channels.*` for Slack, Discord, Telegram, WhatsApp, Matrix, iMessage, and other bundled channels (auth, access control, multi-account, mention gating).
Per-channel config keys live in [Configuration - channels](/gateway/config-channels): `channels.*` for Slack, Discord, Telegram, WhatsApp, Matrix, iMessage, and other channel plugins (auth, access control, multi-account, mention gating).
## Agent defaults, multi-agent, sessions, and messages
@@ -761,9 +761,7 @@ See [Multiple Gateways](/gateway/multiple-gateways).
{
gateway: {
reload: {
mode: "hybrid", // off | restart | hot | hybrid
debounceMs: 500,
deferralTimeoutMs: 300000,
mode: "hybrid", // off | hybrid
},
},
}
@@ -771,11 +769,11 @@ See [Multiple Gateways](/gateway/multiple-gateways).
- `mode`: controls how config edits are applied at runtime.
- `"off"`: ignore live edits; changes require an explicit restart.
- `"restart"`: always restart the gateway process on config change.
- `"hot"`: apply changes in-process without restarting.
- `"hybrid"` (default): try hot reload first; fall back to restart if required.
- `debounceMs`: debounce window in ms before config changes are applied (non-negative integer; default: `300`).
- `deferralTimeoutMs`: optional maximum time in ms to wait for in-flight operations before forcing a restart or channel hot reload. Omit it to use the default bounded wait (`300000`); set `0` to wait indefinitely and log periodic still-pending warnings.
- `"hybrid"` (default): apply hot-safe changes in-process, then restart when a change requires it.
The earlier `"restart"` and `"hot"` values are retired; [`openclaw doctor --fix`](/cli/doctor) maps both to `"hybrid"`.
Reload debounce and in-flight operation deferral are no longer configurable and run behind built-in defaults. [`openclaw doctor --fix`](/cli/doctor) removes the retired `debounceMs` and `deferralTimeoutMs` keys from older config files.
---
+7 -7
View File
@@ -535,21 +535,21 @@ for the checklist.
### Reload modes
| Mode | Behavior |
| ---------------------- | --------------------------------------------------------------------------------------- |
| **`hybrid`** (default) | Hot-applies safe changes instantly. Automatically restarts for critical ones. |
| **`hot`** | Hot-applies safe changes only. Logs a warning when a restart is needed - you handle it. |
| **`restart`** | Restarts the Gateway on any config change, safe or not. |
| **`off`** | Disables file watching. Changes take effect on the next manual restart. |
| Mode | Behavior |
| ---------------------- | ----------------------------------------------------------------------------- |
| **`hybrid`** (default) | Hot-applies safe changes instantly. Automatically restarts for critical ones. |
| **`off`** | Disables file watching. Changes take effect on the next manual restart. |
```json5
{
gateway: {
reload: { mode: "hybrid", debounceMs: 300 },
reload: { mode: "hybrid" },
},
}
```
The earlier `hot` and `restart` modes are retired; [`openclaw doctor --fix`](/cli/doctor) maps both to `hybrid`. Reload debounce is no longer configurable and runs behind a built-in default.
### What hot-applies vs what needs a restart
Most fields hot-apply without downtime; some hot-applied sections restart just that
+2 -2
View File
@@ -352,7 +352,7 @@ That stages grounded durable candidates into the short-term dreaming store while
</Accordion>
<Accordion title="2b. OpenCode provider overrides">
If you have added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually, it overrides the built-in OpenCode catalog from `openclaw/plugin-sdk/llm`. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs.
If you have added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually while the matching official external plugin is installed and enabled, it overrides that plugin-provided catalog. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. Without the matching plugin, the entry remains a valid standalone custom provider.
</Accordion>
<Accordion title="2c. Browser migration and Chrome MCP readiness">
If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model (`browser.profiles.*.driver: "extension"``"existing-session"`; `browser.relayBindHost` removed).
@@ -422,7 +422,7 @@ That stages grounded durable candidates into the short-term dreaming store while
- payload `provider` delivery aliases → explicit `delivery.channel`
- legacy `notify: true` webhook fallback jobs → explicit webhook delivery from the retired raw `cron.webhook` value when valid; announce jobs keep their chat delivery and get `delivery.completionDestination`. Doctor then removes the old config key. Without a usable legacy webhook, the inert top-level `notify` marker is removed for no-target jobs (existing delivery, including announce, is preserved) since runtime delivery never reads it.
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Raw malformed rows are copied to `jobs-quarantine.json` next to the active store before removal from `jobs.json`; doctor reports quarantined rows so you can review or repair them manually.
The Gateway also sanitizes malformed cron rows at load time so valid jobs keep running. Malformed rows are quarantined in the shared SQLite state database in the same transaction that removes them from active scheduling; doctor reports those records and imports any `jobs-quarantine.json` sidecars left by older releases.
Gateway startup normalizes the runtime projection and ignores the top-level `notify` marker, but leaves persisted cron state for doctor repair. Doctor removes inert markers for jobs with no migration target (`delivery.mode` none/absent, an unusable legacy webhook target, or existing announce/chat delivery), leaving existing delivery untouched, so repeated `doctor --fix` runs no longer re-warn about the same job.
+1 -1
View File
@@ -39,7 +39,7 @@ health commands above for live connectivity checks.
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
- These per-channel overrides apply to the built-in channels that expose them today: Discord, Google Chat, iMessage, IRC, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
- These per-channel overrides apply to the channels that expose them today: Discord, Google Chat, iMessage, IRC, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
- A crashing channel is recovered by its own auto-restart backoff first (`auto-restart attempt N/10` in the logs). The health monitor stays out of the way until that ladder ends with `giving up after 10 restart attempts`, then takes over as the last restart owner.
## Inbound ingress health
+2 -2
View File
@@ -113,10 +113,10 @@ Gateway startup uses the same effective port and bind when it seeds local Contro
| `gateway.reload.mode` | Behavior |
| --------------------- | ------------------------------------------ |
| `off` | No config reload |
| `hot` | Apply only hot-safe changes |
| `restart` | Restart on reload-required changes |
| `hybrid` (default) | Hot-apply when safe, restart when required |
The earlier `hot` and `restart` modes are retired; [`openclaw doctor --fix`](/cli/doctor) maps both to `hybrid`.
## Operator command set
```bash
+33 -4
View File
@@ -153,7 +153,8 @@ Gateway responds with `hello-ok`:
"policy": {
"maxPayload": 26214400,
"maxBufferedBytes": 52428800,
"tickIntervalMs": 15000
"tickIntervalMs": 15000,
"attachments": { "maxBytes": 20971520, "maxImageBytes": 6291456 }
}
}
}
@@ -162,7 +163,32 @@ Gateway responds with `hello-ok`:
`server`, `features`, `snapshot`, `policy`, and `auth` are all required by
`HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth`
reports the negotiated role/scopes even when no device token is issued (shape
above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
above). `policy.attachments` is optional (older gateways omit it) and advertises
the decoded-size ceilings chat attachments face on `chat.send`, `sessions.send`,
and session-creation initial turns:
| Field | Meaning |
| --------------- | --------------------------------------------------------------------------------------------------- |
| `maxBytes` | Largest decoded size accepted for a single attachment (`agents.defaults.mediaMaxMb`, default 20 MB) |
| `maxImageBytes` | Largest decoded size accepted for a single image: `min(maxBytes, 6 MB agent-hydration cap)` |
Validating before send:
1. Check each file's decoded size against `maxImageBytes` for images and
`maxBytes` for everything else.
2. Serialize the whole request and check its encoded size against
`policy.maxPayload`. `policy.attachments` is a per-attachment ceiling, never a
promise the frame fits: attachments travel as base64, so a 20 MB file is about
26.7 MB on the wire and exceeds the default 25 MiB frame limit on its own.
3. Treat the server as authoritative for everything else. Accepted MIME types and
per-message handling are deliberately not advertised because they depend on
the entrypoint, the resolved model, and payload sniffing. The gateway can
return a typed rejection, while text-only model runs can omit additional
images after their offload cap and still complete the request.
4. Re-read the values on every reconnect. They are a connection-time snapshot, so
a live `mediaMaxMb` edit reaches existing connections only after they reconnect.
`pluginSurfaceUrls` is optional and maps plugin surface names (e.g.
`canvas`) to scoped hosted URLs; it may expire, so nodes call
`node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry.
The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh`
@@ -1034,10 +1060,13 @@ third-party clients.
| Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` |
| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` |
| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` |
| Chat attachment ceiling | `agents.defaults.mediaMaxMb`, default 20 MB decoded | `src/gateway/chat-attachment-policy.ts` |
| Chat attachment image ceiling | `min(attachment ceiling, 6 MB)` | `src/gateway/chat-attachment-policy.ts`, `packages/media-core/src/constants.ts` |
The server advertises the effective `policy.tickIntervalMs`,
`policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients
should honor those values rather than the pre-handshake defaults.
`policy.maxPayload`, `policy.maxBufferedBytes`, and `policy.attachments` in
`hello-ok`; clients should honor those values rather than the pre-handshake
defaults or hardcoded attachment sizes.
The reference client lets finite requests own their configured deadline when
every pending request has one. An `expectFinal` request without a finite
+2
View File
@@ -250,6 +250,8 @@ unavailable instead of triggering a network request.
When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.homedir()`) for internal OpenClaw path defaults. This includes the default state directory, config path, agent directories, credentials, installer onboarding workspace, and the default dev checkout used by `openclaw update --channel dev`.
`OPENCLAW_HOME` does not grant ownership of the OS account's native Gateway service. Gateway service-management commands treat a relocated home as isolated state; use the OS account home and a named profile when a separate native service identity is required.
**Precedence:** `OPENCLAW_HOME` > `$HOME` > `USERPROFILE` > Termux `PREFIX` home fallback on Android > `os.homedir()`
**Example** (macOS LaunchDaemon):
+1 -1
View File
@@ -592,7 +592,7 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
</Accordion>
<Accordion title="Do I have to restart after changing config?">
The Gateway watches the config and supports hot-reload: `gateway.reload.mode: "hybrid"` (default) hot-applies safe changes and restarts for critical ones. `hot`, `restart`, and `off` are also supported. Most `tools.*`, `agents.*` policy, `session.*`, and `messages.*` changes apply immediately with no reload action at all; `gateway.*` binding/port changes require a restart.
The Gateway watches the config and supports hot-reload: `gateway.reload.mode: "hybrid"` (default) hot-applies safe changes and restarts for critical ones. `off` disables config reload; the earlier `hot` and `restart` modes are retired. Most `tools.*`, `agents.*` policy, `session.*`, and `messages.*` changes apply immediately with no reload action at all; `gateway.*` binding/port changes require a restart.
</Accordion>
<Accordion title="How do I enable web search (and web fetch)?">
+1 -1
View File
@@ -612,7 +612,7 @@ If you have keys enabled, you can also test via:
More providers you can include in the live matrix (if you have creds/config):
- Built-in: `anthropic`, `cerebras`, `github-copilot`, `google`, `google-antigravity`, `google-gemini-cli`, `google-vertex`, `groq`, `mistral`, `openai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `zai`
- First-party provider plugins: `anthropic`, `cerebras`, `github-copilot`, `google`, `google-antigravity`, `google-gemini-cli`, `google-vertex`, `groq`, `mistral`, `openai`, `openrouter`, `opencode`, `opencode-go`, `xai`, `zai`
- Via `models.providers` (custom endpoints): `minimax` (cloud/API), plus any OpenAI/Anthropic-compatible proxy (LM Studio, vLLM, LiteLLM, etc.)
<Tip>
+1 -1
View File
@@ -171,7 +171,7 @@ read_when:
"groupPolicy": "allowlist",
"guilds": {
"YOUR_GUILD_ID": {
"channels": { "general": { "allow": true } },
"channels": { "general": { "enabled": true } },
"requireMention": false
}
}
+3 -3
View File
@@ -709,11 +709,11 @@ Notes:
- The exec path prepares a canonical `systemRunPlan` before approval. Once an approval is granted, the gateway forwards that stored plan, not any later caller-edited command/cwd/session fields.
- `system.notify` respects notification permission state on the macOS app; supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- Unrecognized node `platform` / `deviceFamily` metadata uses a conservative default allowlist that excludes `system.run` and `system.which`. If you intentionally need those commands for an unknown platform, add them explicitly via `gateway.nodes.commands.allow`.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `--env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- A `system.run` request supports `cwd`, an `env` map, `timeoutMs`, and `needsScreenRecording` — these are fields of the request payload carried on the exec path (see above), not `nodes invoke` CLI flags.
- For shell wrappers (`bash|sh|zsh ... -c/-lc`), request-scoped `env` values are reduced to an explicit allowlist (`TERM`, `LANG`, `LC_*`, `COLORTERM`, `NO_COLOR`, `FORCE_COLOR`).
- For allow-always decisions in allowlist mode, known dispatch wrappers (`env`, `flock`, `nice`, `nohup`, `stdbuf`, `timeout`) persist inner executable paths instead of wrapper paths. If unwrapping is not safe, no allowlist entry is persisted automatically.
- On Windows node hosts in allowlist mode, shell-wrapper runs via `cmd.exe /c` require approval (allowlist entry alone does not auto-allow the wrapper form).
- Node hosts ignore `PATH` overrides in `--env` and strip a large, maintained set of interpreter/shell startup variables (for example `NODE_OPTIONS`, `PYTHONPATH`, `BASH_ENV`, `DYLD_*`, `LD_*`) before running a command. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `--env`.
- Node hosts ignore `PATH` overrides in the `env` object and strip a large, maintained set of interpreter/shell startup variables (for example `NODE_OPTIONS`, `PYTHONPATH`, `BASH_ENV`, `DYLD_*`, `LD_*`) before running a command. If you need extra PATH entries, configure the node host service environment (or install tools in standard locations) instead of passing `PATH` via `env`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals). Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by the local SQLite exec approvals row; on macOS specifically, see the exec-host routing env vars under [Headless node host](#headless-node-host-cross-platform) below.
+19 -6
View File
@@ -418,13 +418,17 @@ rejects that combination.
## Auth and environment isolation
In the default per-agent home, auth is selected in this order:
In the default per-agent home, managed stdio launches use Codex's ephemeral
credential store. OpenClaw supplies auth in this order:
1. An explicit OpenClaw Codex auth profile for the agent.
2. The app-server's existing account in that agent's Codex home.
3. For local stdio app-server launches only, `CODEX_API_KEY`, then
`OPENAI_API_KEY`, when no app-server account is present and OpenAI auth is
still required.
1. An explicit or ordered OpenClaw auth profile for the agent.
2. For an API-key route only, a prepared key or local stdio fallback from
`CODEX_API_KEY`, then `OPENAI_API_KEY`.
The managed app-server does not read an existing `codex-home/auth.json` in
this mode. Import that file explicitly as described below. Set
`appServer.homeScope: "user"` only when the app-server should instead own and
use the operator's native Codex account.
When OpenClaw sees a ChatGPT subscription-style Codex auth profile (OAuth or
token credential type), it removes `CODEX_API_KEY` and `OPENAI_API_KEY` from
@@ -485,6 +489,15 @@ openclaw migrate codex --dry-run
openclaw migrate apply codex --yes
```
Credentials need the sensitive migration path because the default agent scope
does not consume a copied or mounted `codex-home/auth.json` directly. Replace
`<agent-id>` with the configured agent that owns this Codex home:
```bash
openclaw migrate plan codex --from <codex-home> --agent <agent-id> --include-secrets --item auth:openai
openclaw migrate apply codex --from <codex-home> --agent <agent-id> --include-secrets --item auth:openai --yes
```
If a deployment needs additional environment isolation, add those variables
to `appServer.clearEnv`:

Some files were not shown because too many files have changed in this diff Show More