mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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 you’re 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2307,
|
||||
"channel": 3664,
|
||||
"plugin": 4055
|
||||
"core": 2309,
|
||||
"channel": 3692,
|
||||
"plugin": 4057
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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) | ✅ | ✅ |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)?">
|
||||
|
||||
@@ -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
@@ -171,7 +171,7 @@ read_when:
|
||||
"groupPolicy": "allowlist",
|
||||
"guilds": {
|
||||
"YOUR_GUILD_ID": {
|
||||
"channels": { "general": { "allow": true } },
|
||||
"channels": { "general": { "enabled": true } },
|
||||
"requireMention": false
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user