diff --git a/.agents/skills/openclaw-parallels-smoke/SKILL.md b/.agents/skills/openclaw-parallels-smoke/SKILL.md index 25a15d0d0d75..d4a453b225be 100644 --- a/.agents/skills/openclaw-parallels-smoke/SKILL.md +++ b/.agents/skills/openclaw-parallels-smoke/SKILL.md @@ -9,7 +9,17 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo ## Global rules -- Use the snapshot most closely matching the requested fresh baseline. +- Inventory existing VMs and snapshots before provisioning anything. When a preconfigured pristine + snapshot matches the requested baseline, switch to it and reuse its user, tools, and base setup. + Do not create a new VM, reinstall macOS, or rebuild the guest baseline for a "fresh" run. +- "Fresh" means restoring the closest existing pristine snapshot, not creating another snapshot. + Do not create ad-hoc snapshots unless the user explicitly asks or no suitable baseline exists; + restore the original snapshot and leave the guest stopped after an ad-hoc run. +- Inspect the snapshot state before restoring it. A pristine `poweron` snapshot can contain the + preconfigured logged-in session; switch to it normally so Parallels resumes that session. Do not + pass `--skip-resume` at test entry unless the run intentionally needs to discard the saved session + and boot from the login window. `--skip-resume` is acceptable for final cleanup that must leave the + restored source guest stopped. - Gateway verification in smoke runs should use `openclaw gateway status --deep --require-rpc` unless the stable version being checked does not support it yet. - Stable `2026.3.12` pre-upgrade diagnostics may require a plain `gateway status --deep` fallback. - Treat `precheck=latest-ref-fail` on that stable pre-upgrade lane as baseline, not automatically a regression. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1484eb96f36..6c0cf5341fc4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1948,7 +1948,7 @@ jobs: run: | set -euo pipefail for attempt in 1 2 3; do - if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then + if swift test --package-path apps/macos --parallel --enable-code-coverage; then exit 0 fi echo "swift test failed (attempt $attempt/3). Retrying…" diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index fdb5270982b9..6bb6f5dcff96 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -2099,7 +2099,7 @@ jobs: profiles: full - suite_id: native-live-src-gateway-profiles-deepseek label: Native live gateway profiles DeepSeek - command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles + command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek OPENCLAW_LIVE_GATEWAY_MODELS=deepseek/deepseek-v4-flash,deepseek/deepseek-v4-pro node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles timeout_minutes: 30 profile_env_only: false advisory: true diff --git a/.github/workflows/package-acceptance.yml b/.github/workflows/package-acceptance.yml index 9123a04b028b..5408802131d1 100644 --- a/.github/workflows/package-acceptance.yml +++ b/.github/workflows/package-acceptance.yml @@ -657,14 +657,15 @@ jobs: steps: - name: Verify package acceptance results env: + ADVISORY: ${{ inputs.advisory }} DOCKER_RESULT: ${{ needs.docker_acceptance.result }} PACKAGE_INTEGRITY_RESULT: ${{ needs.package_integrity.result }} PACKAGE_TELEGRAM_RESULT: ${{ needs.package_telegram.result }} RESOLVE_RESULT: ${{ needs.resolve_package.result }} + TELEGRAM_ENABLED: ${{ needs.resolve_package.outputs.telegram_enabled }} shell: bash run: | set -euo pipefail - advisory="${{ inputs.advisory }}" failed=0 for item in \ "resolve_package=${RESOLVE_RESULT}" \ @@ -674,8 +675,17 @@ jobs: do name="${item%%=*}" result="${item#*=}" + result_failed=false if [[ "$result" != "success" && "$result" != "skipped" ]]; then - if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then + result_failed=true + fi + if [[ "$name" == "package_telegram" && + "$TELEGRAM_ENABLED" == "true" && + "$result" != "success" ]]; then + result_failed=true + fi + if [[ "$result_failed" == "true" ]]; then + if [[ "$ADVISORY" == "true" && "$name" != "resolve_package" ]]; then echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller." continue fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dfe15d9c5d3..62678c6edf05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,12 @@ Docs: https://docs.openclaw.ai ### Fixes +- **OpenAI-compatible streamed tool calls:** execute complete native tool calls from streams that end with SSE `data: [DONE]` but omit `finish_reason`, while keeping transport EOF and visible-text cases fail-closed. (#98124, #97994) Thanks @SunnyShu0925. +- **Doctor state isolation:** prevent automated update and Gateway watch repair from importing and archiving default-home exec or plugin-binding approvals when `OPENCLAW_STATE_DIR` points elsewhere, keep implicit CLI preflight notice-only, and reserve cross-state imports for direct operator doctor runs. (#103247, #103317) +- **Doctor clean-state guidance:** stop suggesting `openclaw doctor --fix` after a clean run with no config changes while preserving targeted repair hints. (#103233) - **OpenCode Zen model catalog:** refresh the provider-owned static seed for Claude Sonnet 5, Grok 4.5, Hy3 Free, Kimi K2.7 Code, and MiniMax M3 with verified routing, pricing, limits, and input capabilities, remove retired free-tier rows, and expose the same catalog through unauthenticated model listing. (#103184) - **Managed browser launch:** surface asynchronous Chrome bootstrap and runtime spawn failures as browser errors while keeping Gateway alive, and retain process error handling through later lifecycle failures. +- **Browser node-proxy downloads:** transfer every action-produced download to the Gateway media store, align a 10 MiB per-file and 16 MiB aggregate transport budget, and rewrite plural download paths to Gateway-local files without traversing page-controlled result data. - **Gateway startup migrations:** release the shared migration lease before exiting when the selected config changes during startup, allowing immediate retries instead of blocking readiness until the five-minute lease expires. (#103145) - **Apple timeout recovery:** return promptly from shared operation deadlines and caller cancellation even when platform work ignores cancellation, while isolating late Gateway handshakes and cleaning up location and permission waiters. (#103066) Thanks @NianJiuZst. - **Claude CLI warm sessions:** preserve managed stdio continuity when Claude writes no native transcript, fall back to bounded OpenClaw history only when the exact live child disappears or changes, and keep stateless runs from persisting CLI bindings. (#96841) Thanks @bradreaves. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index d7f573be35fa..9dd46b1b4ee7 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -19,7 +19,7 @@ }, { "kind": "conditional-branch", - "line": 217, + "line": 278, "path": "apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt", "source": "Off", "surface": "android", @@ -27,7 +27,7 @@ }, { "kind": "conditional-branch", - "line": 218, + "line": 279, "path": "apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt", "source": "Default", "surface": "android", @@ -155,7 +155,7 @@ }, { "kind": "ui-state-text", - "line": 155, + "line": 180, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Searching…", "surface": "android", @@ -163,7 +163,7 @@ }, { "kind": "ui-state-text", - "line": 255, + "line": 283, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Mic off", "surface": "android", @@ -171,7 +171,7 @@ }, { "kind": "ui-state-text", - "line": 269, + "line": 297, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt", "source": "Off", "surface": "android", @@ -211,7 +211,7 @@ }, { "kind": "ui-state-text", - "line": 639, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Offline", "surface": "android", @@ -219,7 +219,39 @@ }, { "kind": "conditional-branch", - "line": 2925, + "line": 1511, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Cron job started.", + "surface": "android", + "id": "native.android.fef5049bc20826c7" + }, + { + "kind": "conditional-branch", + "line": 1511, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Cron run queued.", + "surface": "android", + "id": "native.android.c39fb6f80dbf6820" + }, + { + "kind": "conditional-branch", + "line": 1555, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Cron job disabled.", + "surface": "android", + "id": "native.android.b878b2913410055a" + }, + { + "kind": "conditional-branch", + "line": 1555, + "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", + "source": "Cron job enabled.", + "surface": "android", + "id": "native.android.a45ec59239fbfff0" + }, + { + "kind": "conditional-branch", + "line": 3147, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: no secure gateway endpoint was detected. Enable gateway TLS or Tailscale Serve, or use a trusted private LAN address with Unencrypted selected.", "surface": "android", @@ -227,7 +259,7 @@ }, { "kind": "conditional-branch", - "line": 2927, + "line": 3149, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: secure endpoint reached, but TLS fingerprint verification timed out. Check Tailscale Serve or gateway TLS and retry.", "surface": "android", @@ -235,7 +267,7 @@ }, { "kind": "conditional-branch", - "line": 2929, + "line": 3151, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "source": "Failed: couldn't reach the secure gateway endpoint for this host.", "surface": "android", @@ -769,6 +801,494 @@ "surface": "android", "id": "native.android.73e6ab1a168fd381" }, + { + "kind": "ui-call", + "line": 87, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Delete", + "surface": "android", + "id": "native.android.c4a902430c22030c" + }, + { + "kind": "ui-call", + "line": 92, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Cancel", + "surface": "android", + "id": "native.android.6e1aaf9469428b28" + }, + { + "kind": "ui-call", + "line": 95, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Delete cron job?", + "surface": "android", + "id": "native.android.ec06722578a6027e" + }, + { + "kind": "ui-call", + "line": 96, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "This permanently removes the scheduled job from the gateway.", + "surface": "android", + "id": "native.android.362073d4e1e4624d" + }, + { + "kind": "ui-named-argument", + "line": 174, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Admin access required", + "surface": "android", + "id": "native.android.212889821e40127e" + }, + { + "kind": "ui-named-argument", + "line": 177, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Cron changes require operator.admin. Setup codes intentionally do not grant it. ", + "surface": "android", + "id": "native.android.954671d2e72ae79c" + }, + { + "kind": "conditional-branch", + "line": 217, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Disable", + "surface": "android", + "id": "native.android.f5ed7396dc317625" + }, + { + "kind": "conditional-branch", + "line": 217, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Enable", + "surface": "android", + "id": "native.android.1e3d5d4e62b4a7b0" + }, + { + "kind": "ui-named-argument", + "line": 225, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Delete Job", + "surface": "android", + "id": "native.android.1fed5d5912a9679c" + }, + { + "kind": "ui-named-argument", + "line": 258, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Edit Job", + "surface": "android", + "id": "native.android.da9ecf342cbd7035" + }, + { + "kind": "ui-named-argument", + "line": 261, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Enabled", + "surface": "android", + "id": "native.android.006f60922ae98063" + }, + { + "kind": "ui-named-argument", + "line": 262, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Allow the scheduler to run this job.", + "surface": "android", + "id": "native.android.8b967e5cba1edfd8" + }, + { + "kind": "ui-named-argument", + "line": 269, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Delete after run", + "surface": "android", + "id": "native.android.f5fe0e769a9df4f8" + }, + { + "kind": "ui-named-argument", + "line": 270, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Remove this job after a successful one-shot run.", + "surface": "android", + "id": "native.android.9ed8d09744263540" + }, + { + "kind": "ui-named-argument", + "line": 279, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Job name", + "surface": "android", + "id": "native.android.8faa8ae2ea76fe52" + }, + { + "kind": "ui-named-argument", + "line": 280, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Name", + "surface": "android", + "id": "native.android.e3a12cf55b2caea3" + }, + { + "kind": "ui-named-argument", + "line": 286, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Optional description", + "surface": "android", + "id": "native.android.6c87607a5cebdb8e" + }, + { + "kind": "ui-named-argument", + "line": 287, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Description", + "surface": "android", + "id": "native.android.0889eb39d1b58159" + }, + { + "kind": "ui-named-argument", + "line": 299, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "main, isolated, current, or session:", + "surface": "android", + "id": "native.android.853d7f21386e00ce" + }, + { + "kind": "ui-named-argument", + "line": 300, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Session target", + "surface": "android", + "id": "native.android.65d7bb7d9f9b082f" + }, + { + "kind": "conditional-branch", + "line": 317, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Save Changes", + "surface": "android", + "id": "native.android.d84417059750df22" + }, + { + "kind": "conditional-branch", + "line": 317, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Working", + "surface": "android", + "id": "native.android.2e09de2153828824" + }, + { + "kind": "ui-named-argument", + "line": 333, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Revert Changes", + "surface": "android", + "id": "native.android.e4c8c46ac5a926f7" + }, + { + "kind": "ui-named-argument", + "line": 382, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Schedule · ${cronScheduleKindLabel(schedule)}", + "surface": "android", + "id": "native.android.6709a2ee21539fac" + }, + { + "kind": "ui-named-argument", + "line": 391, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "ISO time, e.g. 2026-07-09T09:30:00Z", + "surface": "android", + "id": "native.android.c5c8713fcc8b695b" + }, + { + "kind": "ui-named-argument", + "line": 392, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Run at", + "surface": "android", + "id": "native.android.af7a2e3e4e43b695" + }, + { + "kind": "ui-named-argument", + "line": 399, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Milliseconds", + "surface": "android", + "id": "native.android.67f1f087d42411e7" + }, + { + "kind": "ui-named-argument", + "line": 400, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Interval", + "surface": "android", + "id": "native.android.4a97c2ca6b3a2ea6" + }, + { + "kind": "ui-named-argument", + "line": 406, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Epoch milliseconds (optional)", + "surface": "android", + "id": "native.android.af67797053368f7a" + }, + { + "kind": "ui-named-argument", + "line": 407, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Anchor", + "surface": "android", + "id": "native.android.f92ca93c74dd9ec6" + }, + { + "kind": "ui-named-argument", + "line": 415, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Cron expression, e.g. 0 9 * * *", + "surface": "android", + "id": "native.android.ec1404d32b37a228" + }, + { + "kind": "ui-named-argument", + "line": 416, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Expression", + "surface": "android", + "id": "native.android.7224806b900ea27b" + }, + { + "kind": "ui-named-argument", + "line": 423, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "e.g. America/New_York", + "surface": "android", + "id": "native.android.3fd296fbbcbd0e80" + }, + { + "kind": "ui-named-argument", + "line": 424, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Timezone", + "surface": "android", + "id": "native.android.6a15bddd2f00a26b" + }, + { + "kind": "ui-named-argument", + "line": 431, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "0 = exact", + "surface": "android", + "id": "native.android.0f3a49d995ed9910" + }, + { + "kind": "ui-named-argument", + "line": 432, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Stagger ms", + "surface": "android", + "id": "native.android.9c4cd709af2884fd" + }, + { + "kind": "ui-named-argument", + "line": 442, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Command to watch", + "surface": "android", + "id": "native.android.36ef6b38d936f70f" + }, + { + "kind": "ui-named-argument", + "line": 443, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Command", + "surface": "android", + "id": "native.android.09bb59bcf0c7e009" + }, + { + "kind": "ui-named-argument", + "line": 450, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Working directory", + "surface": "android", + "id": "native.android.cad18710a0fdaa21" + }, + { + "kind": "ui-named-argument", + "line": 465, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Payload · ${cronPayloadKindLabel(payload)}", + "surface": "android", + "id": "native.android.06fb3142ca1b9d7a" + }, + { + "kind": "ui-named-argument", + "line": 474, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "System event text", + "surface": "android", + "id": "native.android.9719574f53e9ee15" + }, + { + "kind": "ui-named-argument", + "line": 475, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Event text", + "surface": "android", + "id": "native.android.268f6823b43b3177" + }, + { + "kind": "ui-named-argument", + "line": 483, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Agent message", + "surface": "android", + "id": "native.android.406fffc637df0bc0" + }, + { + "kind": "ui-named-argument", + "line": 484, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Message", + "surface": "android", + "id": "native.android.657a9cba38e35a9c" + }, + { + "kind": "ui-named-argument", + "line": 493, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Model", + "surface": "android", + "id": "native.android.282315578422bf23" + }, + { + "kind": "ui-named-argument", + "line": 500, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Optional override", + "surface": "android", + "id": "native.android.aca463f1ba7edde5" + }, + { + "kind": "ui-named-argument", + "line": 501, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Thinking", + "surface": "android", + "id": "native.android.7f45799d74ee7587" + }, + { + "kind": "ui-named-argument", + "line": 512, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Command argv JSON array", + "surface": "android", + "id": "native.android.0a20280e51a2c61b" + }, + { + "kind": "ui-named-argument", + "line": 513, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Arguments", + "surface": "android", + "id": "native.android.04c3a1825c19886e" + }, + { + "kind": "ui-named-argument", + "line": 524, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Optional path", + "surface": "android", + "id": "native.android.e45a470156b0f564" + }, + { + "kind": "ui-named-argument", + "line": 535, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "The gateway can change this path but cannot clear an existing path.", + "surface": "android", + "id": "native.android.00a27842963455a7" + }, + { + "kind": "ui-named-argument", + "line": 565, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Recent Runs", + "surface": "android", + "id": "native.android.b0de849068a82211" + }, + { + "kind": "conditional-branch", + "line": 571, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Loading", + "surface": "android", + "id": "native.android.9b6b1f5c976e23fa" + }, + { + "kind": "conditional-branch", + "line": 571, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Reload", + "surface": "android", + "id": "native.android.30ea2a568e832013" + }, + { + "kind": "conditional-branch", + "line": 585, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Loading recent runs…", + "surface": "android", + "id": "native.android.677d53d3c85c3d7e" + }, + { + "kind": "conditional-branch", + "line": 585, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "No recent runs yet.", + "surface": "android", + "id": "native.android.9daf37294c74454c" + }, + { + "kind": "conditional-branch", + "line": 607, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "One time", + "surface": "android", + "id": "native.android.322daf975eb5e204" + }, + { + "kind": "conditional-branch", + "line": 609, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Cron", + "surface": "android", + "id": "native.android.d8dfc026668cdc81" + }, + { + "kind": "conditional-branch", + "line": 610, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "On command exit", + "surface": "android", + "id": "native.android.da05d4ae32ae6a75" + }, + { + "kind": "conditional-branch", + "line": 615, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "System event", + "surface": "android", + "id": "native.android.22f6ed1cadece34c" + }, + { + "kind": "conditional-branch", + "line": 616, + "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt", + "source": "Agent turn", + "surface": "android", + "id": "native.android.24e541dd61519341" + }, { "kind": "ui-named-argument", "line": 52, @@ -2587,7 +3107,7 @@ }, { "kind": "ui-named-argument", - "line": 230, + "line": 236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits and quota health.", "surface": "android", @@ -2595,7 +3115,7 @@ }, { "kind": "ui-named-argument", - "line": 230, + "line": 236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Usage", "surface": "android", @@ -2603,7 +3123,7 @@ }, { "kind": "ui-named-argument", - "line": 250, + "line": 256, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load usage.", "surface": "android", @@ -2611,7 +3131,7 @@ }, { "kind": "ui-named-argument", - "line": 255, + "line": 261, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No usage data yet.", "surface": "android", @@ -2619,7 +3139,7 @@ }, { "kind": "ui-named-argument", - "line": 256, + "line": 262, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Provider limits will appear here when your gateway reports them.", "surface": "android", @@ -2627,7 +3147,7 @@ }, { "kind": "ui-named-argument", - "line": 291, + "line": 297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron Jobs", "surface": "android", @@ -2635,7 +3155,7 @@ }, { "kind": "ui-named-argument", - "line": 291, + "line": 297, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scheduled OpenClaw work from your gateway.", "surface": "android", @@ -2643,15 +3163,15 @@ }, { "kind": "ui-named-argument", - "line": 302, + "line": 308, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Android shows scheduled work status. Create and edit schedules from the desktop app.", + "source": "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", "surface": "android", - "id": "native.android.b043fde2bb211935" + "id": "native.android.3cd22006987fde12" }, { "kind": "ui-named-argument", - "line": 312, + "line": 318, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load cron jobs.", "surface": "android", @@ -2659,7 +3179,7 @@ }, { "kind": "ui-named-argument", - "line": 317, + "line": 323, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No scheduled jobs.", "surface": "android", @@ -2667,15 +3187,15 @@ }, { "kind": "ui-named-argument", - "line": 318, + "line": 324, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", - "source": "Create recurring OpenClaw work from the desktop app.", + "source": "Scheduled work created on the gateway will appear here.", "surface": "android", - "id": "native.android.fe85be29a00c5e58" + "id": "native.android.bc88f537cd309fcb" }, { "kind": "ui-named-argument", - "line": 353, + "line": 425, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Inspect scheduled gateway work.", "surface": "android", @@ -2683,7 +3203,7 @@ }, { "kind": "ui-named-argument", - "line": 367, + "line": 446, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to inspect cron jobs.", "surface": "android", @@ -2691,7 +3211,7 @@ }, { "kind": "conditional-branch", - "line": 375, + "line": 454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cron job not loaded.", "surface": "android", @@ -2699,7 +3219,7 @@ }, { "kind": "conditional-branch", - "line": 375, + "line": 454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Loading cron job…", "surface": "android", @@ -2707,7 +3227,7 @@ }, { "kind": "ui-named-argument", - "line": 397, + "line": 505, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agents", "surface": "android", @@ -2715,7 +3235,7 @@ }, { "kind": "ui-named-argument", - "line": 397, + "line": 505, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose and inspect the assistants available on this gateway.", "surface": "android", @@ -2723,7 +3243,7 @@ }, { "kind": "ui-named-argument", - "line": 408, + "line": 516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load agents.", "surface": "android", @@ -2731,7 +3251,7 @@ }, { "kind": "ui-named-argument", - "line": 412, + "line": 520, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No agents loaded yet.", "surface": "android", @@ -2739,7 +3259,7 @@ }, { "kind": "ui-named-argument", - "line": 438, + "line": 546, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Approvals", "surface": "android", @@ -2747,7 +3267,7 @@ }, { "kind": "ui-named-argument", - "line": 438, + "line": 546, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review actions that need your attention.", "surface": "android", @@ -2755,7 +3275,7 @@ }, { "kind": "conditional-branch", - "line": 449, + "line": 557, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refresh", "surface": "android", @@ -2763,7 +3283,7 @@ }, { "kind": "conditional-branch", - "line": 449, + "line": 557, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Refreshing", "surface": "android", @@ -2771,7 +3291,7 @@ }, { "kind": "ui-named-argument", - "line": 462, + "line": 570, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway disconnected.", "surface": "android", @@ -2779,7 +3299,7 @@ }, { "kind": "ui-named-argument", - "line": 463, + "line": 571, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connect the gateway to load approval requests in the app.", "surface": "android", @@ -2787,7 +3307,7 @@ }, { "kind": "ui-named-argument", - "line": 469, + "line": 577, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No gateway approvals.", "surface": "android", @@ -2795,7 +3315,7 @@ }, { "kind": "ui-named-argument", - "line": 470, + "line": 578, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Exec approval requests will appear here while this phone is connected.", "surface": "android", @@ -2803,7 +3323,7 @@ }, { "kind": "ui-named-argument", - "line": 477, + "line": 585, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Session activity", "surface": "android", @@ -2811,7 +3331,7 @@ }, { "kind": "ui-named-argument", - "line": 478, + "line": 586, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Chat tool calls waiting in the active session remain visible here.", "surface": "android", @@ -2819,7 +3339,7 @@ }, { "kind": "ui-named-argument", - "line": 492, + "line": 600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "How this phone appears to OpenClaw.", "surface": "android", @@ -2827,7 +3347,7 @@ }, { "kind": "ui-named-argument", - "line": 492, + "line": 600, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Profile", "surface": "android", @@ -2835,7 +3355,7 @@ }, { "kind": "ui-named-argument", - "line": 495, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Device name", "surface": "android", @@ -2843,7 +3363,7 @@ }, { "kind": "ui-named-argument", - "line": 496, + "line": 604, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save Profile", "surface": "android", @@ -2851,7 +3371,7 @@ }, { "kind": "ui-named-argument", - "line": 515, + "line": 623, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Configure voice, transport, and playback.", "surface": "android", @@ -2859,7 +3379,7 @@ }, { "kind": "ui-named-argument", - "line": 515, + "line": 623, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Talk Provider Setup", "surface": "android", @@ -2867,7 +3387,7 @@ }, { "kind": "ui-named-argument", - "line": 518, + "line": 626, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Audio Test", "surface": "android", @@ -2875,7 +3395,7 @@ }, { "kind": "ui-named-argument", - "line": 519, + "line": 627, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check that OpenClaw can speak clearly on this phone.", "surface": "android", @@ -2883,7 +3403,7 @@ }, { "kind": "conditional-branch", - "line": 522, + "line": 630, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enable speaker", "surface": "android", @@ -2891,7 +3411,7 @@ }, { "kind": "conditional-branch", - "line": 522, + "line": 630, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Mute speaker", "surface": "android", @@ -2899,7 +3419,7 @@ }, { "kind": "conditional-branch", - "line": 523, + "line": 631, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Assistant speech muted", "surface": "android", @@ -2907,7 +3427,7 @@ }, { "kind": "conditional-branch", - "line": 523, + "line": 631, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replies play aloud", "surface": "android", @@ -2915,7 +3435,7 @@ }, { "kind": "conditional-branch", - "line": 525, + "line": 633, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Muted", "surface": "android", @@ -2923,7 +3443,7 @@ }, { "kind": "conditional-branch", - "line": 525, + "line": 633, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "On", "surface": "android", @@ -2931,7 +3451,7 @@ }, { "kind": "ui-named-argument", - "line": 529, + "line": 637, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Done", "surface": "android", @@ -2939,7 +3459,7 @@ }, { "kind": "ui-named-argument", - "line": 539, + "line": 647, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Realtime Talk", "surface": "android", @@ -2947,7 +3467,7 @@ }, { "kind": "ui-named-argument", - "line": 540, + "line": 648, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Dictation", "surface": "android", @@ -2955,7 +3475,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 772, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowlist", "surface": "android", @@ -2963,7 +3483,7 @@ }, { "kind": "conditional-branch", - "line": 664, + "line": 772, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Blocklist", "surface": "android", @@ -2971,7 +3491,7 @@ }, { "kind": "ui-named-argument", - "line": 697, + "line": 805, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what reaches OpenClaw.", "surface": "android", @@ -2979,7 +3499,7 @@ }, { "kind": "ui-named-argument", - "line": 697, + "line": 805, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Notifications", "surface": "android", @@ -2987,7 +3507,7 @@ }, { "kind": "conditional-branch", - "line": 701, + "line": 809, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Alerts stay on this phone.", "surface": "android", @@ -2995,7 +3515,7 @@ }, { "kind": "conditional-branch", - "line": 701, + "line": 809, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can receive selected alerts.", "surface": "android", @@ -3003,7 +3523,7 @@ }, { "kind": "conditional-branch", - "line": 713, + "line": 821, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Granted", "surface": "android", @@ -3011,7 +3531,7 @@ }, { "kind": "conditional-branch", - "line": 713, + "line": 821, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup", "surface": "android", @@ -3019,7 +3539,7 @@ }, { "kind": "conditional-branch", - "line": 718, + "line": 826, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check Access", "surface": "android", @@ -3027,7 +3547,7 @@ }, { "kind": "conditional-branch", - "line": 718, + "line": 826, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open System Access", "surface": "android", @@ -3035,7 +3555,7 @@ }, { "kind": "ui-named-argument", - "line": 728, + "line": 836, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forwarding Mode", "surface": "android", @@ -3043,7 +3563,7 @@ }, { "kind": "ui-named-argument", - "line": 777, + "line": 885, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App Filter", "surface": "android", @@ -3051,7 +3571,7 @@ }, { "kind": "conditional-branch", - "line": 784, + "line": 892, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Close App Picker", "surface": "android", @@ -3059,7 +3579,7 @@ }, { "kind": "conditional-branch", - "line": 784, + "line": 892, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open App Picker", "surface": "android", @@ -3067,7 +3587,7 @@ }, { "kind": "ui-named-argument", - "line": 789, + "line": 897, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Search apps", "surface": "android", @@ -3075,7 +3595,7 @@ }, { "kind": "ui-named-argument", - "line": 792, + "line": 900, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Show System Apps", "surface": "android", @@ -3083,7 +3603,7 @@ }, { "kind": "ui-named-argument", - "line": 793, + "line": 901, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Include Android and background packages.", "surface": "android", @@ -3091,7 +3611,7 @@ }, { "kind": "ui-named-argument", - "line": 800, + "line": 908, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No matching apps.", "surface": "android", @@ -3099,7 +3619,7 @@ }, { "kind": "ui-named-argument", - "line": 811, + "line": 919, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Showing ${visibleApps.size} of ${apps.size}. Refine search for more.", "surface": "android", @@ -3107,7 +3627,7 @@ }, { "kind": "ui-named-argument", - "line": 1050, + "line": 1158, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Choose what this phone can share.", "surface": "android", @@ -3115,7 +3635,7 @@ }, { "kind": "ui-named-argument", - "line": 1050, + "line": 1158, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Phone Capabilities", "surface": "android", @@ -3123,7 +3643,7 @@ }, { "kind": "conditional-branch", - "line": 1059, + "line": 1167, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow photo library access.", "surface": "android", @@ -3131,7 +3651,7 @@ }, { "kind": "conditional-branch", - "line": 1059, + "line": 1167, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected or full photo access granted.", "surface": "android", @@ -3139,7 +3659,7 @@ }, { "kind": "conditional-branch", - "line": 1069, + "line": 1177, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App list stays on this phone.", "surface": "android", @@ -3147,7 +3667,7 @@ }, { "kind": "conditional-branch", - "line": 1069, + "line": 1177, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw can list launcher-visible apps.", "surface": "android", @@ -3155,7 +3675,7 @@ }, { "kind": "ui-named-argument", - "line": 1080, + "line": 1188, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Location", "surface": "android", @@ -3163,7 +3683,7 @@ }, { "kind": "ui-named-argument", - "line": 1088, + "line": 1196, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always allows requested location checks while OpenClaw is in the background; Android shows this in the persistent node notification.", "surface": "android", @@ -3171,7 +3691,7 @@ }, { "kind": "ui-call", - "line": 1113, + "line": 1221, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow background location?", "surface": "android", @@ -3179,7 +3699,7 @@ }, { "kind": "ui-call", - "line": 1115, + "line": 1223, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw only checks location when your paired Gateway requests it. ", "surface": "android", @@ -3187,7 +3707,7 @@ }, { "kind": "ui-call", - "line": 1128, + "line": 1236, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open Settings", "surface": "android", @@ -3195,7 +3715,7 @@ }, { "kind": "ui-call", - "line": 1133, + "line": 1241, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not Now", "surface": "android", @@ -3203,7 +3723,7 @@ }, { "kind": "ui-call", - "line": 1181, + "line": 1289, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace gateway setup?", "surface": "android", @@ -3211,7 +3731,7 @@ }, { "kind": "ui-call", - "line": 1196, + "line": 1304, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Replace setup", "surface": "android", @@ -3219,7 +3739,7 @@ }, { "kind": "ui-call", - "line": 1212, + "line": 1320, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget gateway?", "surface": "android", @@ -3227,7 +3747,7 @@ }, { "kind": "ui-call", - "line": 1225, + "line": 1333, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Cancel", "surface": "android", @@ -3235,7 +3755,7 @@ }, { "kind": "ui-named-argument", - "line": 1231, + "line": 1339, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection between this phone and OpenClaw.", "surface": "android", @@ -3243,7 +3763,7 @@ }, { "kind": "conditional-branch", - "line": 1235, + "line": 1343, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connected", "surface": "android", @@ -3251,7 +3771,7 @@ }, { "kind": "conditional-branch", - "line": 1235, + "line": 1343, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Offline", "surface": "android", @@ -3259,7 +3779,7 @@ }, { "kind": "conditional-branch", - "line": 1236, + "line": 1344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Not paired", "surface": "android", @@ -3267,7 +3787,7 @@ }, { "kind": "conditional-branch", - "line": 1236, + "line": 1344, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Online", "surface": "android", @@ -3275,7 +3795,7 @@ }, { "kind": "ui-named-argument", - "line": 1246, + "line": 1354, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Reconnect", "surface": "android", @@ -3283,7 +3803,7 @@ }, { "kind": "ui-named-argument", - "line": 1247, + "line": 1355, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Disconnect", "surface": "android", @@ -3291,7 +3811,7 @@ }, { "kind": "ui-named-argument", - "line": 1251, + "line": 1359, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateways", "surface": "android", @@ -3299,7 +3819,7 @@ }, { "kind": "ui-named-argument", - "line": 1253, + "line": 1361, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No paired gateways.", "surface": "android", @@ -3307,7 +3827,7 @@ }, { "kind": "ui-call", - "line": 1273, + "line": 1381, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Forget", "surface": "android", @@ -3315,7 +3835,7 @@ }, { "kind": "ui-named-argument", - "line": 1295, + "line": 1403, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway setup", "surface": "android", @@ -3323,7 +3843,7 @@ }, { "kind": "ui-named-argument", - "line": 1297, + "line": 1405, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Scan or paste a setup code to add another gateway.", "surface": "android", @@ -3331,7 +3851,7 @@ }, { "kind": "ui-named-argument", - "line": 1304, + "line": 1412, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Add gateway", "surface": "android", @@ -3339,7 +3859,7 @@ }, { "kind": "ui-named-argument", - "line": 1305, + "line": 1413, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup Code", "surface": "android", @@ -3347,7 +3867,7 @@ }, { "kind": "ui-named-argument", - "line": 1309, + "line": 1417, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Android can scan or paste an existing setup code, but this gateway does not expose setup-code generation to the app yet. Generate the QR/code on the gateway host with openclaw qr, then scan it here or paste the setup code below.", "surface": "android", @@ -3355,7 +3875,7 @@ }, { "kind": "ui-named-argument", - "line": 1318, + "line": 1426, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection Setup", "surface": "android", @@ -3363,7 +3883,7 @@ }, { "kind": "ui-named-argument", - "line": 1319, + "line": 1427, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Setup code", "surface": "android", @@ -3371,7 +3891,7 @@ }, { "kind": "ui-named-argument", - "line": 1321, + "line": 1429, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Host", "surface": "android", @@ -3379,7 +3899,7 @@ }, { "kind": "ui-named-argument", - "line": 1322, + "line": 1430, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Port", "surface": "android", @@ -3387,7 +3907,7 @@ }, { "kind": "ui-named-argument", - "line": 1324, + "line": 1432, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Connection security", "surface": "android", @@ -3395,7 +3915,7 @@ }, { "kind": "conditional-branch", - "line": 1328, + "line": 1436, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Secure (TLS)", "surface": "android", @@ -3403,7 +3923,7 @@ }, { "kind": "conditional-branch", - "line": 1328, + "line": 1436, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Unencrypted", "surface": "android", @@ -3411,7 +3931,7 @@ }, { "kind": "ui-named-argument", - "line": 1345, + "line": 1453, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Token", "surface": "android", @@ -3419,7 +3939,7 @@ }, { "kind": "ui-named-argument", - "line": 1346, + "line": 1454, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Bootstrap", "surface": "android", @@ -3427,7 +3947,7 @@ }, { "kind": "ui-named-argument", - "line": 1348, + "line": 1456, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Password", "surface": "android", @@ -3435,7 +3955,7 @@ }, { "kind": "ui-named-argument", - "line": 1353, + "line": 1461, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Save & Connect", "surface": "android", @@ -3443,7 +3963,7 @@ }, { "kind": "ui-named-argument", - "line": 1370, + "line": 1478, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enter a valid setup code or gateway address.", "surface": "android", @@ -3451,7 +3971,7 @@ }, { "kind": "ui-named-argument", - "line": 1396, + "line": 1504, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Appearance", "surface": "android", @@ -3459,7 +3979,7 @@ }, { "kind": "ui-named-argument", - "line": 1396, + "line": 1504, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme and translated Android text.", "surface": "android", @@ -3467,7 +3987,7 @@ }, { "kind": "ui-named-argument", - "line": 1408, + "line": 1516, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Theme", "surface": "android", @@ -3475,7 +3995,7 @@ }, { "kind": "ui-named-argument", - "line": 1418, + "line": 1526, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "App language", "surface": "android", @@ -3483,7 +4003,7 @@ }, { "kind": "ui-named-argument", - "line": 1420, + "line": 1528, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Changes Android text that OpenClaw has translated. Screens with English-only copy stay unchanged.", "surface": "android", @@ -3491,7 +4011,7 @@ }, { "kind": "ui-named-argument", - "line": 1457, + "line": 1565, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Selected", "surface": "android", @@ -3499,7 +4019,7 @@ }, { "kind": "conditional-branch", - "line": 1503, + "line": 1611, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Ready", "surface": "android", @@ -3507,7 +4027,7 @@ }, { "kind": "ui-named-argument", - "line": 1532, + "line": 1640, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "About", "surface": "android", @@ -3515,7 +4035,7 @@ }, { "kind": "ui-named-argument", - "line": 1532, + "line": 1640, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw for Android.", "surface": "android", @@ -3523,7 +4043,7 @@ }, { "kind": "ui-named-argument", - "line": 1545, + "line": 1653, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Gateway", "surface": "android", @@ -3531,7 +4051,7 @@ }, { "kind": "ui-named-argument", - "line": 1547, + "line": 1655, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Runtime", "surface": "android", @@ -3539,7 +4059,7 @@ }, { "kind": "ui-named-argument", - "line": 1550, + "line": 1658, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Update", "surface": "android", @@ -3547,7 +4067,7 @@ }, { "kind": "ui-named-argument", - "line": 1561, + "line": 1669, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "android", @@ -3555,7 +4075,7 @@ }, { "kind": "ui-named-argument", - "line": 1578, + "line": 1686, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw logo", "surface": "android", @@ -3563,7 +4083,7 @@ }, { "kind": "ui-named-argument", - "line": 1580, + "line": 1688, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw", "surface": "android", @@ -3571,7 +4091,7 @@ }, { "kind": "ui-named-argument", - "line": 1581, + "line": 1689, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Personal AI on your devices", "surface": "android", @@ -3579,7 +4099,7 @@ }, { "kind": "ui-named-argument", - "line": 1640, + "line": 1748, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Licenses", "surface": "android", @@ -3587,7 +4107,7 @@ }, { "kind": "conditional-branch", - "line": 1641, + "line": 1749, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "android", @@ -3595,7 +4115,7 @@ }, { "kind": "ui-named-argument", - "line": 1650, + "line": 1758, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No license notices are packaged in this build.", "surface": "android", @@ -3603,7 +4123,7 @@ }, { "kind": "ui-named-argument", - "line": 1676, + "line": 1784, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Open ${license.title}", "surface": "android", @@ -3611,7 +4131,7 @@ }, { "kind": "conditional-branch", - "line": 1711, + "line": 1819, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Check", "surface": "android", @@ -3619,7 +4139,7 @@ }, { "kind": "ui-named-argument", - "line": 1744, + "line": 1852, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Back", "surface": "android", @@ -3627,7 +4147,7 @@ }, { "kind": "conditional-branch", - "line": 1818, + "line": 1926, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Sending", "surface": "android", @@ -3635,7 +4155,7 @@ }, { "kind": "conditional-branch", - "line": 1827, + "line": 1935, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allow Once", "surface": "android", @@ -3643,7 +4163,7 @@ }, { "kind": "conditional-branch", - "line": 1827, + "line": 1935, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Allowing", "surface": "android", @@ -3651,7 +4171,7 @@ }, { "kind": "conditional-branch", - "line": 1835, + "line": 1943, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Always", "surface": "android", @@ -3659,7 +4179,7 @@ }, { "kind": "conditional-branch", - "line": 1835, + "line": 1943, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Saving", "surface": "android", @@ -3667,7 +4187,7 @@ }, { "kind": "conditional-branch", - "line": 1843, + "line": 1951, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Deny", "surface": "android", @@ -3675,7 +4195,7 @@ }, { "kind": "conditional-branch", - "line": 1843, + "line": 1951, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Denying", "surface": "android", @@ -3683,7 +4203,7 @@ }, { "kind": "conditional-branch", - "line": 1868, + "line": 1976, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Review", "surface": "android", @@ -3691,7 +4211,7 @@ }, { "kind": "conditional-branch", - "line": 1896, + "line": 2004, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Issue", "surface": "android", @@ -3699,7 +4219,7 @@ }, { "kind": "conditional-branch", - "line": 1926, + "line": 2059, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Enabled", "surface": "android", @@ -3707,7 +4227,7 @@ }, { "kind": "conditional-branch", - "line": 1940, + "line": 2073, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No", "surface": "android", @@ -3715,7 +4235,7 @@ }, { "kind": "conditional-branch", - "line": 1940, + "line": 2073, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Yes", "surface": "android", @@ -3723,7 +4243,7 @@ }, { "kind": "ui-named-argument", - "line": 1958, + "line": 2091, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Last Error", "surface": "android", @@ -3731,7 +4251,7 @@ }, { "kind": "ui-named-argument", - "line": 1961, + "line": 2094, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Delivery Error", "surface": "android", @@ -3739,7 +4259,7 @@ }, { "kind": "ui-named-argument", - "line": 2000, + "line": 2133, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Tap to copy", "surface": "android", @@ -3747,7 +4267,7 @@ }, { "kind": "ui-toast", - "line": 2015, + "line": 2148, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "$title copied", "surface": "android", @@ -3755,7 +4275,7 @@ }, { "kind": "conditional-branch", - "line": 2053, + "line": 2186, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default assistant", "surface": "android", @@ -3763,7 +4283,7 @@ }, { "kind": "conditional-branch", - "line": 2055, + "line": 2188, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Default", "surface": "android", @@ -3771,7 +4291,7 @@ }, { "kind": "conditional-branch", - "line": 2111, + "line": 2244, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Needs attention", "surface": "android", @@ -3779,7 +4299,7 @@ }, { "kind": "conditional-branch", - "line": 2114, + "line": 2247, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting ${minutes}m", "surface": "android", @@ -3787,7 +4307,7 @@ }, { "kind": "conditional-branch", - "line": 2114, + "line": 2247, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Waiting for review", "surface": "android", @@ -3795,7 +4315,7 @@ }, { "kind": "conditional-branch", - "line": 2142, + "line": 2275, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "${job.scheduleLabel} · ${formatCronWake(job.nextRunAtMs)} · ${job.promptPreview}", "surface": "android", @@ -3803,7 +4323,7 @@ }, { "kind": "conditional-branch", - "line": 2149, + "line": 2282, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "No limits reported", "surface": "android", @@ -3811,7 +4331,7 @@ }, { "kind": "conditional-branch", - "line": 2180, + "line": 2313, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Off", "surface": "android", @@ -3819,7 +4339,7 @@ }, { "kind": "conditional-branch", - "line": 2201, + "line": 2334, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "System Event Text", "surface": "android", @@ -3827,7 +4347,7 @@ }, { "kind": "conditional-branch", - "line": 2202, + "line": 2335, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Agent Prompt", "surface": "android", @@ -3835,7 +4355,7 @@ }, { "kind": "conditional-branch", - "line": 2203, + "line": 2336, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Command", "surface": "android", @@ -3843,7 +4363,7 @@ }, { "kind": "conditional-branch", - "line": 2204, + "line": 2337, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt", "source": "Payload Text", "surface": "android", @@ -5651,7 +6171,7 @@ }, { "kind": "ui-named-argument", - "line": 544, + "line": 559, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Local command center", "surface": "android", @@ -5659,7 +6179,7 @@ }, { "kind": "ui-named-argument", - "line": 545, + "line": 560, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "OC", "surface": "android", @@ -5667,7 +6187,7 @@ }, { "kind": "ui-named-argument", - "line": 547, + "line": 562, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Search", "surface": "android", @@ -5675,7 +6195,7 @@ }, { "kind": "ui-named-argument", - "line": 557, + "line": 572, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "OpenClaw", "surface": "android", @@ -5683,7 +6203,7 @@ }, { "kind": "ui-named-argument", - "line": 558, + "line": 573, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Design system prototype", "surface": "android", @@ -5691,7 +6211,7 @@ }, { "kind": "ui-named-argument", - "line": 560, + "line": 575, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Connected", "surface": "android", @@ -5699,7 +6219,7 @@ }, { "kind": "ui-named-argument", - "line": 571, + "line": 586, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Sessions", "surface": "android", @@ -5707,7 +6227,7 @@ }, { "kind": "ui-named-argument", - "line": 573, + "line": 588, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Testing testing 1 2 3", "surface": "android", @@ -5715,7 +6235,7 @@ }, { "kind": "ui-named-argument", - "line": 574, + "line": 589, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "14 messages · Android", "surface": "android", @@ -5723,7 +6243,7 @@ }, { "kind": "ui-named-argument", - "line": 578, + "line": 593, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Provider setup", "surface": "android", @@ -5731,7 +6251,7 @@ }, { "kind": "ui-named-argument", - "line": 579, + "line": 594, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "OpenClaw gateway", "surface": "android", @@ -5739,7 +6259,7 @@ }, { "kind": "ui-named-argument", - "line": 584, + "line": 599, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Ask OpenClaw anything", "surface": "android", @@ -5747,7 +6267,7 @@ }, { "kind": "ui-named-argument", - "line": 587, + "line": 602, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Start Chat", "surface": "android", @@ -5755,7 +6275,7 @@ }, { "kind": "ui-named-argument", - "line": 588, + "line": 603, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Voice", "surface": "android", @@ -5763,7 +6283,7 @@ }, { "kind": "ui-named-argument", - "line": 592, + "line": 607, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Realtime", "surface": "android", @@ -5771,7 +6291,7 @@ }, { "kind": "ui-named-argument", - "line": 593, + "line": 608, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Dictation", "surface": "android", @@ -5779,7 +6299,7 @@ }, { "kind": "ui-named-argument", - "line": 594, + "line": 609, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Screen", "surface": "android", @@ -5787,7 +6307,7 @@ }, { "kind": "ui-named-argument", - "line": 598, + "line": 613, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "Nothing needs your attention", "surface": "android", @@ -5795,7 +6315,7 @@ }, { "kind": "ui-named-argument", - "line": 599, + "line": 614, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt", "source": "OpenClaw will surface approvals, failed jobs, and channel issues here.", "surface": "android", @@ -9323,7 +9843,7 @@ }, { "kind": "ui-modifier", - "line": 145, + "line": 147, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Settings", "surface": "apple", @@ -9331,7 +9851,7 @@ }, { "kind": "ui-modifier", - "line": 249, + "line": 252, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Scan QR Code", "surface": "apple", @@ -9339,7 +9859,7 @@ }, { "kind": "ui-modifier", - "line": 271, + "line": 274, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset Onboarding?", "surface": "apple", @@ -9347,7 +9867,7 @@ }, { "kind": "ui-call", - "line": 275, + "line": 278, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Reset", "surface": "apple", @@ -9355,7 +9875,7 @@ }, { "kind": "ui-call", - "line": 283, + "line": 286, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This disconnects, clears saved gateway credentials, and reopens onboarding.", "surface": "apple", @@ -9363,7 +9883,7 @@ }, { "kind": "ui-modifier", - "line": 286, + "line": 289, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "QR Scanner Unavailable", "surface": "apple", @@ -9371,15 +9891,47 @@ }, { "kind": "ui-call", - "line": 293, + "line": 299, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "OK", "surface": "apple", "id": "native.apple.541078d5af9de662" }, + { + "kind": "ui-modifier", + "line": 306, + "path": "apps/ios/Sources/Design/SettingsProTab.swift", + "source": "Access Level", + "surface": "apple", + "id": "native.apple.32230ecb2fbc2378" + }, { "kind": "ui-call", - "line": 310, + "line": 314, + "path": "apps/ios/Sources/Design/SettingsProTab.swift", + "source": "While Using the App", + "surface": "apple", + "id": "native.apple.7ed27029e9c313cc" + }, + { + "kind": "ui-call", + "line": 320, + "path": "apps/ios/Sources/Design/SettingsProTab.swift", + "source": "Always", + "surface": "apple", + "id": "native.apple.b975064b240c9fd6" + }, + { + "kind": "ui-call", + "line": 328, + "path": "apps/ios/Sources/Design/SettingsProTab.swift", + "source": "Choose when OpenClaw may share this iPhone's location with gateway tools.", + "surface": "apple", + "id": "native.apple.756ff21ca904b2d8" + }, + { + "kind": "ui-call", + "line": 345, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Forget Gateway", "surface": "apple", @@ -9387,23 +9939,23 @@ }, { "kind": "ui-call", - "line": 316, + "line": 351, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Cancel", "surface": "apple", "id": "native.apple.0cb64be43c0b3d97" }, { - "kind": "ui-call", - "line": 320, + "kind": "ui-call-concatenated", + "line": 355, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "This removes saved credentials, device access, TLS trust, and cached chats for this gateway.", "surface": "apple", - "id": "native.apple.313a2b8cac5e10ad" + "id": "native.apple.53bb7808a1ae62bb" }, { "kind": "ui-call", - "line": 371, + "line": 408, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Enable OpenClaw Hosted Push Relay?", "surface": "apple", @@ -9411,7 +9963,7 @@ }, { "kind": "ui-call", - "line": 385, + "line": 422, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Continue", "surface": "apple", @@ -9419,7 +9971,7 @@ }, { "kind": "ui-call", - "line": 393, + "line": 430, "path": "apps/ios/Sources/Design/SettingsProTab.swift", "source": "Not Now", "surface": "apple", @@ -9523,7 +10075,7 @@ }, { "kind": "conditional-branch", - "line": 830, + "line": 914, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup queued for the watch. Open OpenClaw before the code expires.", "surface": "apple", @@ -9531,7 +10083,7 @@ }, { "kind": "conditional-branch", - "line": 830, + "line": 914, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Setup sent. Open OpenClaw on the watch to connect.", "surface": "apple", @@ -9539,7 +10091,7 @@ }, { "kind": "conditional-branch", - "line": 986, + "line": 1070, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Configured", "surface": "apple", @@ -9547,7 +10099,7 @@ }, { "kind": "conditional-branch", - "line": 986, + "line": 1070, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Not configured", "surface": "apple", @@ -9555,7 +10107,7 @@ }, { "kind": "conditional-branch", - "line": 1051, + "line": 1135, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Connect to the gateway.", "surface": "apple", @@ -9563,7 +10115,7 @@ }, { "kind": "conditional-branch", - "line": 1051, + "line": 1135, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Gateway requests will appear here.", "surface": "apple", @@ -9571,7 +10123,7 @@ }, { "kind": "conditional-branch", - "line": 1093, + "line": 1177, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "High", "surface": "apple", @@ -9579,7 +10131,7 @@ }, { "kind": "conditional-branch", - "line": 1093, + "line": 1177, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Resolving", "surface": "apple", @@ -9587,7 +10139,7 @@ }, { "kind": "conditional-branch", - "line": 1098, + "line": 1182, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "One-time approval", "surface": "apple", @@ -9595,7 +10147,7 @@ }, { "kind": "conditional-branch", - "line": 1098, + "line": 1182, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Permission can be saved", "surface": "apple", @@ -9603,7 +10155,7 @@ }, { "kind": "conditional-branch", - "line": 1100, + "line": 1184, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Medium", "surface": "apple", @@ -9611,7 +10163,7 @@ }, { "kind": "conditional-branch", - "line": 1100, + "line": 1184, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "Review", "surface": "apple", @@ -9619,68 +10171,12 @@ }, { "kind": "conditional-branch", - "line": 1121, + "line": 1205, "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", "source": "\\(diagnosticsIssueCount)", "surface": "apple", "id": "native.apple.8ae57748b0b13d62" }, - { - "kind": "conditional-branch", - "line": 1132, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location off", - "surface": "apple", - "id": "native.apple.acbedcae9f022c29" - }, - { - "kind": "conditional-branch", - "line": 1134, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location While Using", - "surface": "apple", - "id": "native.apple.7d31e35d20b78c2d" - }, - { - "kind": "conditional-branch", - "line": 1136, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location While Using, effective Off", - "surface": "apple", - "id": "native.apple.b94b8d4e935c81fb" - }, - { - "kind": "conditional-branch", - "line": 1138, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location While Using, effective Always", - "surface": "apple", - "id": "native.apple.6533895b2a1f2b2d" - }, - { - "kind": "conditional-branch", - "line": 1140, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location Always", - "surface": "apple", - "id": "native.apple.c3c9d2baa0607380" - }, - { - "kind": "conditional-branch", - "line": 1142, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location Always, effective While Using", - "surface": "apple", - "id": "native.apple.10fe695bb1cd3c62" - }, - { - "kind": "conditional-branch", - "line": 1144, - "path": "apps/ios/Sources/Design/SettingsProTabActions.swift", - "source": "Location Always, effective Off", - "surface": "apple", - "id": "native.apple.306454b6d067e1ee" - }, { "kind": "ui-modifier", "line": 34, @@ -9745,6 +10241,14 @@ "surface": "apple", "id": "native.apple.1573249126ddd84f" }, + { + "kind": "ui-named-argument", + "line": 174, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Privacy", + "surface": "apple", + "id": "native.apple.464377858fd51ef3" + }, { "kind": "ui-named-argument", "line": 184, @@ -10035,23 +10539,7 @@ }, { "kind": "ui-named-argument", - "line": 557, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Privacy", - "surface": "apple", - "id": "native.apple.464377858fd51ef3" - }, - { - "kind": "ui-named-argument", - "line": 558, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Control what device context OpenClaw can expose to the gateway.", - "surface": "apple", - "id": "native.apple.6d2f325ea39a5ee5" - }, - { - "kind": "ui-named-argument", - "line": 563, + "line": 556, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Camera Access", "surface": "apple", @@ -10059,7 +10547,7 @@ }, { "kind": "ui-named-argument", - "line": 569, + "line": 562, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Background Listening", "surface": "apple", @@ -10067,7 +10555,7 @@ }, { "kind": "ui-call", - "line": 593, + "line": 586, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Notifications", "surface": "apple", @@ -10075,7 +10563,7 @@ }, { "kind": "ui-modifier", - "line": 598, + "line": 591, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Turns OpenClaw notification delivery on or off", "surface": "apple", @@ -10083,7 +10571,7 @@ }, { "kind": "ui-named-argument", - "line": 618, + "line": 611, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reconnect", "surface": "apple", @@ -10091,7 +10579,7 @@ }, { "kind": "ui-named-argument", - "line": 628, + "line": 621, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Diagnose", "surface": "apple", @@ -10099,7 +10587,7 @@ }, { "kind": "ui-call", - "line": 644, + "line": 637, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "License files are not available in this build.", "surface": "apple", @@ -10107,7 +10595,7 @@ }, { "kind": "ui-call", - "line": 661, + "line": 654, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw appreciates its partners in the open-source community.", "surface": "apple", @@ -10115,7 +10603,7 @@ }, { "kind": "ui-call", - "line": 701, + "line": 694, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw", "surface": "apple", @@ -10123,7 +10611,7 @@ }, { "kind": "ui-call", - "line": 703, + "line": 696, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Personal AI on your devices", "surface": "apple", @@ -10131,7 +10619,7 @@ }, { "kind": "ui-call", - "line": 717, + "line": 710, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "OpenClaw app version", "surface": "apple", @@ -10139,7 +10627,7 @@ }, { "kind": "ui-call", - "line": 719, + "line": 712, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "iOS", "surface": "apple", @@ -10147,7 +10635,7 @@ }, { "kind": "ui-named-argument", - "line": 724, + "line": 717, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Website", "surface": "apple", @@ -10155,7 +10643,7 @@ }, { "kind": "ui-named-argument", - "line": 729, + "line": 722, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Docs", "surface": "apple", @@ -10163,7 +10651,7 @@ }, { "kind": "ui-named-argument", - "line": 734, + "line": 727, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "GitHub", "surface": "apple", @@ -10171,7 +10659,7 @@ }, { "kind": "ui-named-argument", - "line": 739, + "line": 732, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discord", "surface": "apple", @@ -10179,7 +10667,7 @@ }, { "kind": "ui-call", - "line": 744, + "line": 737, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "© 2026 OpenClaw Foundation — MIT License.", "surface": "apple", @@ -10187,7 +10675,7 @@ }, { "kind": "ui-call", - "line": 750, + "line": 743, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Title", "surface": "apple", @@ -10195,47 +10683,39 @@ }, { "kind": "ui-call", - "line": 789, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Controls whether location can be shared with gateway tools.", - "surface": "apple", - "id": "native.apple.0f4e7486619df175" - }, - { - "kind": "ui-call", - "line": 801, + "line": 778, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Location", "surface": "apple", "id": "native.apple.0535a52a62812307" }, { - "kind": "ui-call", - "line": 802, + "kind": "ui-modifier", + "line": 796, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Off", + "source": "Location Sharing", "surface": "apple", - "id": "native.apple.fe44fd4b5147e65e" + "id": "native.apple.72d124648efb5a97" }, { "kind": "ui-call", - "line": 805, + "line": 807, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "While Using", + "source": "Access Level", "surface": "apple", - "id": "native.apple.3e77661ca2afbe76" + "id": "native.apple.aadb749ebce75251" + }, + { + "kind": "ui-modifier", + "line": 829, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Chooses While Using the App or Always", + "surface": "apple", + "id": "native.apple.d8c7ed70277b3ff1" }, { "kind": "ui-call", - "line": 808, - "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", - "source": "Always", - "surface": "apple", - "id": "native.apple.9277893c07d00dd4" - }, - { - "kind": "ui-call", - "line": 831, + "line": 849, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Agent", "surface": "apple", @@ -10243,7 +10723,7 @@ }, { "kind": "ui-call", - "line": 832, + "line": 850, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default", "surface": "apple", @@ -10251,7 +10731,7 @@ }, { "kind": "ui-call", - "line": 842, + "line": 860, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Used for new Chat and Talk sessions.", "surface": "apple", @@ -10259,7 +10739,7 @@ }, { "kind": "ui-call", - "line": 849, + "line": 867, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paste setup code", "surface": "apple", @@ -10267,7 +10747,7 @@ }, { "kind": "ui-named-argument", - "line": 855, + "line": 873, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Scan QR", "surface": "apple", @@ -10275,7 +10755,7 @@ }, { "kind": "ui-named-argument", - "line": 865, + "line": 883, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect", "surface": "apple", @@ -10283,7 +10763,7 @@ }, { "kind": "ui-call", - "line": 874, + "line": 892, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Setup Code", "surface": "apple", @@ -10291,7 +10771,7 @@ }, { "kind": "ui-call", - "line": 887, + "line": 905, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovered Gateways", "surface": "apple", @@ -10299,7 +10779,7 @@ }, { "kind": "ui-call", - "line": 889, + "line": 907, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "No gateways found yet. Use manual setup if Bonjour is blocked.", "surface": "apple", @@ -10307,7 +10787,7 @@ }, { "kind": "ui-call", - "line": 903, + "line": 921, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Pair a gateway to make it available here.", "surface": "apple", @@ -10315,7 +10795,7 @@ }, { "kind": "ui-call", - "line": 912, + "line": 930, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Paired Gateways", "surface": "apple", @@ -10323,7 +10803,7 @@ }, { "kind": "ui-call", - "line": 915, + "line": 933, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Switch gateways without pairing again.", "surface": "apple", @@ -10331,7 +10811,7 @@ }, { "kind": "ui-modifier", - "line": 943, + "line": 961, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Gateway", "surface": "apple", @@ -10339,7 +10819,7 @@ }, { "kind": "ui-call", - "line": 955, + "line": 973, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget", "surface": "apple", @@ -10347,7 +10827,7 @@ }, { "kind": "ui-call", - "line": 967, + "line": 985, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Forget Gateway", "surface": "apple", @@ -10355,7 +10835,7 @@ }, { "kind": "ui-call", - "line": 1004, + "line": 1038, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Manual Gateway", "surface": "apple", @@ -10363,7 +10843,7 @@ }, { "kind": "ui-call", - "line": 1005, + "line": 1039, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Use Manual Gateway", "surface": "apple", @@ -10371,7 +10851,7 @@ }, { "kind": "ui-call", - "line": 1006, + "line": 1040, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Host", "surface": "apple", @@ -10379,7 +10859,7 @@ }, { "kind": "ui-call", - "line": 1010, + "line": 1044, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Port", "surface": "apple", @@ -10387,7 +10867,7 @@ }, { "kind": "ui-call", - "line": 1013, + "line": 1047, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connection security", "surface": "apple", @@ -10395,7 +10875,7 @@ }, { "kind": "ui-call", - "line": 1014, + "line": 1048, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Unencrypted", "surface": "apple", @@ -10403,7 +10883,7 @@ }, { "kind": "ui-call", - "line": 1017, + "line": 1051, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Secure (TLS)", "surface": "apple", @@ -10411,7 +10891,7 @@ }, { "kind": "ui-named-argument", - "line": 1029, + "line": 1063, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Connect Manual", "surface": "apple", @@ -10419,7 +10899,7 @@ }, { "kind": "ui-call", - "line": 1059, + "line": 1093, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Auto-connect on launch", "surface": "apple", @@ -10427,7 +10907,7 @@ }, { "kind": "ui-call", - "line": 1060, + "line": 1094, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -10435,7 +10915,7 @@ }, { "kind": "ui-call", - "line": 1061, + "line": 1095, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Password", "surface": "apple", @@ -10443,7 +10923,7 @@ }, { "kind": "ui-call", - "line": 1066, + "line": 1100, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Custom Headers", "surface": "apple", @@ -10451,7 +10931,7 @@ }, { "kind": "ui-call", - "line": 1073, + "line": 1107, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Reset Onboarding", "surface": "apple", @@ -10459,7 +10939,7 @@ }, { "kind": "ui-call", - "line": 1100, + "line": 1134, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Wake", "surface": "apple", @@ -10467,7 +10947,7 @@ }, { "kind": "ui-call", - "line": 1103, + "line": 1137, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Talk Mode", "surface": "apple", @@ -10475,7 +10955,7 @@ }, { "kind": "ui-call", - "line": 1111, + "line": 1145, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speech Language", "surface": "apple", @@ -10483,7 +10963,7 @@ }, { "kind": "ui-call", - "line": 1118, + "line": 1152, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Speakerphone", "surface": "apple", @@ -10491,7 +10971,7 @@ }, { "kind": "ui-call", - "line": 1122, + "line": 1156, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Wake Words", "surface": "apple", @@ -10499,7 +10979,7 @@ }, { "kind": "ui-call", - "line": 1143, + "line": 1177, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice", "surface": "apple", @@ -10507,7 +10987,7 @@ }, { "kind": "ui-call", - "line": 1144, + "line": 1178, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Provider", "surface": "apple", @@ -10515,7 +10995,7 @@ }, { "kind": "ui-call", - "line": 1151, + "line": 1185, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Realtime Voice", "surface": "apple", @@ -10523,7 +11003,7 @@ }, { "kind": "ui-call", - "line": 1152, + "line": 1186, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Gateway Default", "surface": "apple", @@ -10531,7 +11011,7 @@ }, { "kind": "ui-call", - "line": 1159, + "line": 1193, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Voice Mode", "surface": "apple", @@ -10539,7 +11019,7 @@ }, { "kind": "ui-call", - "line": 1160, + "line": 1194, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Active Voice", "surface": "apple", @@ -10547,7 +11027,7 @@ }, { "kind": "ui-call", - "line": 1162, + "line": 1196, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Last Voice Issue", "surface": "apple", @@ -10555,7 +11035,7 @@ }, { "kind": "ui-call", - "line": 1164, + "line": 1198, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Transport", "surface": "apple", @@ -10563,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 1165, + "line": 1199, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "API Key", "surface": "apple", @@ -10571,7 +11051,7 @@ }, { "kind": "ui-call", - "line": 1172, + "line": 1206, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Show Talk Control", "surface": "apple", @@ -10579,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 1173, + "line": 1207, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Default Share Instruction", "surface": "apple", @@ -10587,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 1180, + "line": 1214, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Run Share Self-Test", "surface": "apple", @@ -10595,7 +11075,7 @@ }, { "kind": "ui-call", - "line": 1197, + "line": 1231, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Debug Logs", "surface": "apple", @@ -10603,7 +11083,7 @@ }, { "kind": "ui-call", - "line": 1200, + "line": 1234, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Debug Screen Status", "surface": "apple", @@ -10611,7 +11091,7 @@ }, { "kind": "ui-call", - "line": 1204, + "line": 1238, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Discovery Logs", "surface": "apple", @@ -10619,7 +11099,7 @@ }, { "kind": "ui-call", - "line": 1210, + "line": 1244, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device", "surface": "apple", @@ -10627,7 +11107,7 @@ }, { "kind": "ui-call", - "line": 1211, + "line": 1245, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Device Name", "surface": "apple", @@ -10635,7 +11115,7 @@ }, { "kind": "ui-call", - "line": 1213, + "line": 1247, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "Instance ID", "surface": "apple", @@ -10643,7 +11123,15 @@ }, { "kind": "conditional-branch", - "line": 1236, + "line": 1270, + "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", + "source": "Off", + "surface": "apple", + "id": "native.apple.40692c0b7ad6a05d" + }, + { + "kind": "conditional-branch", + "line": 1270, "path": "apps/ios/Sources/Design/SettingsProTabSections.swift", "source": "On", "surface": "apple", @@ -11361,6 +11849,46 @@ "surface": "apple", "id": "native.apple.09312ef7ca727f4b" }, + { + "kind": "ui-localized-call", + "line": 82, + "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", + "source": "Connect", + "surface": "apple", + "id": "native.apple.3cb1f1788ffb16e2" + }, + { + "kind": "ui-localized-call", + "line": 84, + "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", + "source": "TLS required", + "surface": "apple", + "id": "native.apple.b6ea004a3ef09501" + }, + { + "kind": "ui-localized-call", + "line": 93, + "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", + "source": "Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. Use Unencrypted only with a trusted private-LAN address.", + "surface": "apple", + "id": "native.apple.16e06dae717734de" + }, + { + "kind": "ui-localized-call", + "line": 1302, + "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", + "source": "Can't reach gateway at \\(host):\\(port). Verify Tailscale Serve is enabled and publishes this Gateway.", + "surface": "apple", + "id": "native.apple.ccfde3d74305d096" + }, + { + "kind": "ui-localized-call", + "line": 1305, + "path": "apps/ios/Sources/Gateway/GatewayConnectionController.swift", + "source": "Can't reach gateway at \\(host):\\(port). Check Tailscale or LAN.", + "surface": "apple", + "id": "native.apple.fe8f13e52e05ff77" + }, { "kind": "ui-call", "line": 11, @@ -11515,7 +12043,7 @@ }, { "kind": "ui-call", - "line": 62, + "line": 70, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Connecting…", "surface": "apple", @@ -11523,7 +12051,7 @@ }, { "kind": "ui-call", - "line": 66, + "line": 74, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Connect to this Gateway", "surface": "apple", @@ -11531,7 +12059,15 @@ }, { "kind": "ui-call", - "line": 82, + "line": 100, + "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", + "source": "Use Manual Setup", + "surface": "apple", + "id": "native.apple.91a0ab004066bacb" + }, + { + "kind": "ui-call", + "line": 114, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Not now", "surface": "apple", @@ -11539,7 +12075,7 @@ }, { "kind": "ui-call", - "line": 89, + "line": 121, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Don't show this again", "surface": "apple", @@ -11547,7 +12083,7 @@ }, { "kind": "ui-modifier", - "line": 99, + "line": 131, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Quick Setup", "surface": "apple", @@ -11555,7 +12091,7 @@ }, { "kind": "ui-call", - "line": 107, + "line": 139, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Close", "surface": "apple", @@ -11563,7 +12099,7 @@ }, { "kind": "ui-call", - "line": 196, + "line": 233, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Connect a nearby Gateway", "surface": "apple", @@ -11571,7 +12107,7 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 283, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Local", "surface": "apple", @@ -11579,7 +12115,7 @@ }, { "kind": "conditional-branch", - "line": 246, + "line": 283, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Secure", "surface": "apple", @@ -11587,7 +12123,7 @@ }, { "kind": "ui-named-argument", - "line": 260, + "line": 297, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Discovery", "surface": "apple", @@ -11595,7 +12131,7 @@ }, { "kind": "ui-named-argument", - "line": 261, + "line": 298, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Gateway", "surface": "apple", @@ -11603,7 +12139,7 @@ }, { "kind": "ui-named-argument", - "line": 262, + "line": 299, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Node", "surface": "apple", @@ -11611,7 +12147,7 @@ }, { "kind": "ui-named-argument", - "line": 263, + "line": 300, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Operator", "surface": "apple", @@ -11619,7 +12155,7 @@ }, { "kind": "ui-call", - "line": 369, + "line": 406, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Looking for a Gateway", "surface": "apple", @@ -11627,7 +12163,7 @@ }, { "kind": "ui-call", - "line": 371, + "line": 408, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Keep your iPhone on the same LAN or tailnet, then start the Gateway on your host machine.", "surface": "apple", @@ -11635,7 +12171,7 @@ }, { "kind": "ui-named-argument", - "line": 378, + "line": 415, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Run openclaw gateway --port 18789.", "surface": "apple", @@ -11643,7 +12179,7 @@ }, { "kind": "ui-named-argument", - "line": 379, + "line": 416, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Check that Bonjour discovery is enabled.", "surface": "apple", @@ -11651,7 +12187,7 @@ }, { "kind": "ui-named-argument", - "line": 380, + "line": 417, "path": "apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift", "source": "Open Settings if you need a manual host.", "surface": "apple", @@ -11985,6 +12521,70 @@ "surface": "apple", "id": "native.apple.633f011b81108a8c" }, + { + "kind": "ui-localized-call", + "line": 31, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "Location Services are off in iOS Settings.", + "surface": "apple", + "id": "native.apple.e5673992d3aeac64" + }, + { + "kind": "ui-localized-call", + "line": 36, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "iOS permission is required to share location.", + "surface": "apple", + "id": "native.apple.270f9edf88f41fbf" + }, + { + "kind": "ui-localized-call", + "line": 38, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "Location permission is denied in iOS Settings.", + "surface": "apple", + "id": "native.apple.8cb96b0bb1921610" + }, + { + "kind": "ui-localized-call", + "line": 40, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "Location permission is restricted on this device.", + "surface": "apple", + "id": "native.apple.d7da989dd737723c" + }, + { + "kind": "ui-localized-call", + "line": 42, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "iOS currently allows location only while using the app.", + "surface": "apple", + "id": "native.apple.cc1df565f1194db4" + }, + { + "kind": "ui-localized-call", + "line": 46, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "OpenClaw cannot determine the current iOS location permission.", + "surface": "apple", + "id": "native.apple.f6b19113d5d8e7db" + }, + { + "kind": "ui-localized-call", + "line": 82, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "While Using the App", + "surface": "apple", + "id": "native.apple.2892e5b02c6789f0" + }, + { + "kind": "ui-localized-call", + "line": 84, + "path": "apps/ios/Sources/Location/LocationSettingsPresentation.swift", + "source": "Always", + "surface": "apple", + "id": "native.apple.f689e63e18ca692d" + }, { "kind": "conditional-branch", "line": 2942, @@ -12507,15 +13107,7 @@ }, { "kind": "ui-call", - "line": 742, - "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", - "source": "Resolving…", - "surface": "apple", - "id": "native.apple.17b900bb8b2fcced" - }, - { - "kind": "ui-call", - "line": 758, + "line": 770, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Restart Discovery", "surface": "apple", @@ -12523,7 +13115,7 @@ }, { "kind": "ui-call", - "line": 764, + "line": 776, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovered Gateways", "surface": "apple", @@ -12531,7 +13123,7 @@ }, { "kind": "ui-named-argument", - "line": 768, + "line": 780, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Manual Fallback", "surface": "apple", @@ -12539,7 +13131,7 @@ }, { "kind": "ui-named-argument", - "line": 773, + "line": 785, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Domain Settings", "surface": "apple", @@ -12547,7 +13139,7 @@ }, { "kind": "ui-call", - "line": 784, + "line": 796, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Developer Local", "surface": "apple", @@ -12555,7 +13147,7 @@ }, { "kind": "ui-call", - "line": 787, + "line": 799, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Default host is localhost. Use your Mac LAN IP if simulator networking requires it.", "surface": "apple", @@ -12563,7 +13155,7 @@ }, { "kind": "ui-call", - "line": 815, + "line": 827, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway rejected credentials. Scan a fresh setup code or update token/password.", "surface": "apple", @@ -12571,7 +13163,7 @@ }, { "kind": "ui-call", - "line": 819, + "line": 831, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "OpenClaw is checking gateway and node access.", "surface": "apple", @@ -12579,7 +13171,7 @@ }, { "kind": "ui-call", - "line": 833, + "line": 845, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Resume After Approval", "surface": "apple", @@ -12587,7 +13179,7 @@ }, { "kind": "ui-call", - "line": 839, + "line": 851, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Pairing Approval", "surface": "apple", @@ -12595,7 +13187,7 @@ }, { "kind": "ui-call-concatenated", - "line": 849, + "line": 861, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Approve this device on the gateway.\n1) `\\(commandLine)`\n2) `/pair approve` in your OpenClaw chat\n\\(requestLine)\nOpenClaw will also retry automatically when you return to this app.", "surface": "apple", @@ -12603,7 +13195,7 @@ }, { "kind": "ui-call", - "line": 863, + "line": 875, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Scan Setup Code Again", "surface": "apple", @@ -12611,7 +13203,7 @@ }, { "kind": "ui-call", - "line": 876, + "line": 888, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Retry Connection", "surface": "apple", @@ -12619,7 +13211,7 @@ }, { "kind": "ui-call", - "line": 908, + "line": 920, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Enter setup code", "surface": "apple", @@ -12627,7 +13219,7 @@ }, { "kind": "ui-call", - "line": 924, + "line": 936, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Apply", "surface": "apple", @@ -12635,7 +13227,7 @@ }, { "kind": "ui-call", - "line": 942, + "line": 954, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Setup Code", "surface": "apple", @@ -12643,7 +13235,7 @@ }, { "kind": "ui-call", - "line": 945, + "line": 957, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Use this if you have a setup code instead of scanning.", "surface": "apple", @@ -12651,7 +13243,7 @@ }, { "kind": "ui-call", - "line": 957, + "line": 969, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Host", "surface": "apple", @@ -12659,7 +13251,7 @@ }, { "kind": "ui-call", - "line": 958, + "line": 970, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Port", "surface": "apple", @@ -12667,7 +13259,7 @@ }, { "kind": "ui-call", - "line": 961, + "line": 973, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Discovery Domain (optional)", "surface": "apple", @@ -12675,7 +13267,7 @@ }, { "kind": "ui-call", - "line": 966, + "line": 978, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Auth Token", "surface": "apple", @@ -12683,7 +13275,7 @@ }, { "kind": "ui-call", - "line": 970, + "line": 982, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Gateway Password", "surface": "apple", @@ -12691,7 +13283,7 @@ }, { "kind": "ui-call", - "line": 999, + "line": 1011, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connection security", "surface": "apple", @@ -12699,7 +13291,7 @@ }, { "kind": "ui-call", - "line": 1000, + "line": 1012, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Unencrypted", "surface": "apple", @@ -12707,7 +13299,7 @@ }, { "kind": "ui-call", - "line": 1003, + "line": 1015, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Secure (TLS)", "surface": "apple", @@ -12715,7 +13307,7 @@ }, { "kind": "ui-call", - "line": 1074, + "line": 1086, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connecting…", "surface": "apple", @@ -12723,7 +13315,7 @@ }, { "kind": "ui-call", - "line": 1078, + "line": 1090, "path": "apps/ios/Sources/Onboarding/OnboardingWizardView.swift", "source": "Connect", "surface": "apple", @@ -12891,7 +13483,7 @@ }, { "kind": "conditional-branch", - "line": 1017, + "line": 1020, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway needs attention", "surface": "apple", @@ -12899,7 +13491,7 @@ }, { "kind": "conditional-branch", - "line": 1017, + "line": 1020, "path": "apps/ios/Sources/RootTabs.swift", "source": "OpenClaw iOS", "surface": "apple", @@ -12907,7 +13499,7 @@ }, { "kind": "conditional-branch", - "line": 1053, + "line": 1056, "path": "apps/ios/Sources/RootTabs.swift", "source": "Available", "surface": "apple", @@ -12915,7 +13507,7 @@ }, { "kind": "conditional-branch", - "line": 1053, + "line": 1056, "path": "apps/ios/Sources/RootTabs.swift", "source": "Gateway default", "surface": "apple", @@ -14219,7 +14811,7 @@ }, { "kind": "conditional-branch", - "line": 642, + "line": 654, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host)", "surface": "apple", @@ -14227,7 +14819,7 @@ }, { "kind": "conditional-branch", - "line": 642, + "line": 654, "path": "apps/macos/Sources/OpenClaw/AppState.swift", "source": "\\(user)@\\(host):\\(port)", "surface": "apple", @@ -14715,7 +15307,7 @@ }, { "kind": "ui-named-argument", - "line": 17, + "line": 22, "path": "apps/macos/Sources/OpenClaw/CrestodianSettings.swift", "source": "Crestodian", "surface": "apple", @@ -14723,15 +15315,15 @@ }, { "kind": "ui-named-argument", - "line": 18, + "line": 23, "path": "apps/macos/Sources/OpenClaw/CrestodianSettings.swift", - "source": "Your setup helper. It can check status, fix config, switch models, ", + "source": "Your AI-powered setup helper. It can check status, fix config, ", "surface": "apple", - "id": "native.apple.69557562fe99d402" + "id": "native.apple.2a00563ee9d35dd3" }, { "kind": "ui-call", - "line": 21, + "line": 26, "path": "apps/macos/Sources/OpenClaw/CrestodianSettings.swift", "source": "Chat", "surface": "apple", @@ -14739,7 +15331,7 @@ }, { "kind": "ui-call", - "line": 27, + "line": 32, "path": "apps/macos/Sources/OpenClaw/CrestodianSettings.swift", "source": "Tip: try “status”, “doctor”, “set default model …”, or “connect telegram”.", "surface": "apple", @@ -17867,7 +18459,7 @@ }, { "kind": "conditional-branch", - "line": 219, + "line": 225, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -17875,7 +18467,7 @@ }, { "kind": "conditional-branch", - "line": 219, + "line": 225, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", @@ -17883,7 +18475,7 @@ }, { "kind": "ui-call", - "line": 416, + "line": 528, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Looking for AI you already use…", "surface": "apple", @@ -17891,7 +18483,7 @@ }, { "kind": "ui-call", - "line": 418, + "line": 530, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Checking for Claude Code, Codex, Gemini, and saved API keys.", "surface": "apple", @@ -17899,7 +18491,7 @@ }, { "kind": "ui-named-argument", - "line": 448, + "line": 560, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Couldn’t check this Mac for AI accounts", "surface": "apple", @@ -17907,7 +18499,7 @@ }, { "kind": "ui-named-argument", - "line": 459, + "line": 572, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Couldn’t load the full provider list", "surface": "apple", @@ -17915,7 +18507,7 @@ }, { "kind": "ui-named-argument", - "line": 462, + "line": 575, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Try again", "surface": "apple", @@ -17923,7 +18515,7 @@ }, { "kind": "ui-named-argument", - "line": 470, + "line": 583, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "None of the found options worked", "surface": "apple", @@ -17931,7 +18523,7 @@ }, { "kind": "ui-named-argument", - "line": 471, + "line": 584, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "The details are listed on each option above. You can fix the login and retry, or connect with an API key or token below.", "surface": "apple", @@ -17939,7 +18531,7 @@ }, { "kind": "ui-call", - "line": 488, + "line": 602, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Need help? Chat with Crestodian", "surface": "apple", @@ -17947,7 +18539,7 @@ }, { "kind": "ui-call", - "line": 501, + "line": 616, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Your AI is ready", "surface": "apple", @@ -17955,7 +18547,7 @@ }, { "kind": "ui-call", - "line": 518, + "line": 633, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "No AI accounts found on this Mac", "surface": "apple", @@ -17963,7 +18555,7 @@ }, { "kind": "ui-call-concatenated", - "line": 520, + "line": 635, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "That’s fine — you can connect one with an API key or token. If you use Claude Code, Codex, or the Gemini CLI on this Mac, sign in there first and hit “Check again”.", "surface": "apple", @@ -17971,7 +18563,7 @@ }, { "kind": "ui-call", - "line": 552, + "line": 668, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Recommended", "surface": "apple", @@ -17979,7 +18571,7 @@ }, { "kind": "ui-named-argument", - "line": 635, + "line": 765, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "No key-based providers are available", "surface": "apple", @@ -17987,7 +18579,7 @@ }, { "kind": "ui-named-argument", - "line": 636, + "line": 766, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Enable or install a text-inference provider plugin on this Gateway, then check again.", "surface": "apple", @@ -17995,7 +18587,7 @@ }, { "kind": "ui-named-argument", - "line": 638, + "line": 768, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Check again", "surface": "apple", @@ -18003,7 +18595,7 @@ }, { "kind": "ui-call", - "line": 650, + "line": 780, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect with an API key or token instead…", "surface": "apple", @@ -18011,7 +18603,7 @@ }, { "kind": "ui-call", - "line": 661, + "line": 791, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect with an API key or token", "surface": "apple", @@ -18019,7 +18611,7 @@ }, { "kind": "ui-call", - "line": 664, + "line": 794, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Provider", "surface": "apple", @@ -18027,7 +18619,7 @@ }, { "kind": "ui-call", - "line": 672, + "line": 802, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "API key or token", "surface": "apple", @@ -18035,7 +18627,7 @@ }, { "kind": "ui-call", - "line": 684, + "line": 814, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Connect", "surface": "apple", @@ -18043,7 +18635,7 @@ }, { "kind": "ui-named-argument", - "line": 697, + "line": 827, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "That key didn’t work", "surface": "apple", @@ -18051,7 +18643,7 @@ }, { "kind": "ui-call", - "line": 722, + "line": 853, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Crestodian — setup helper", "surface": "apple", @@ -18059,7 +18651,7 @@ }, { "kind": "ui-call", - "line": 725, + "line": 856, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Done", "surface": "apple", @@ -18067,15 +18659,55 @@ }, { "kind": "ui-call", - "line": 766, + "line": 917, "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", "source": "Open help…", "surface": "apple", "id": "native.apple.c3460290224a3ed3" }, + { + "kind": "conditional-branch", + "line": 956, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", + "source": "Hide details", + "surface": "apple", + "id": "native.apple.cd74ba4ea185d89e" + }, + { + "kind": "conditional-branch", + "line": 956, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", + "source": "Show details", + "surface": "apple", + "id": "native.apple.3a8d0f37b25ab92d" + }, { "kind": "ui-call", - "line": 126, + "line": 976, + "path": "apps/macos/Sources/OpenClaw/OnboardingAISetup.swift", + "source": "Copy error", + "surface": "apple", + "id": "native.apple.bdf53a219bdfbe0d" + }, + { + "kind": "conditional-branch", + "line": 198, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "Crestodian was interrupted. Restart to try again.", + "surface": "apple", + "id": "native.apple.722995710f90cfc6" + }, + { + "kind": "conditional-branch", + "line": 198, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "The Gateway connection changed. Restart Crestodian to reconnect.", + "surface": "apple", + "id": "native.apple.6596e00d4333081e" + }, + { + "kind": "ui-call", + "line": 223, "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", "source": "Crestodian is working…", "surface": "apple", @@ -18083,7 +18715,7 @@ }, { "kind": "ui-call", - "line": 151, + "line": 248, "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", "source": "Restart", "surface": "apple", @@ -18091,7 +18723,7 @@ }, { "kind": "ui-call", - "line": 162, + "line": 259, "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", "source": "Enter secret…", "surface": "apple", @@ -18099,7 +18731,7 @@ }, { "kind": "ui-call", - "line": 164, + "line": 261, "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", "source": "Reply to Crestodian… (yes sets everything up)", "surface": "apple", @@ -18107,7 +18739,7 @@ }, { "kind": "ui-call", - "line": 9, + "line": 10, "path": "apps/macos/Sources/OpenClaw/OnboardingView+AISetupPage.swift", "source": "Connect your AI", "surface": "apple", @@ -18115,7 +18747,7 @@ }, { "kind": "ui-call", - "line": 105, + "line": 156, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift", "source": "Back", "surface": "apple", @@ -18259,7 +18891,7 @@ }, { "kind": "conditional-branch", - "line": 173, + "line": 179, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Existing gateway detected", "surface": "apple", @@ -18267,7 +18899,7 @@ }, { "kind": "conditional-branch", - "line": 173, + "line": 179, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Port \\(probe.port) already in use", "surface": "apple", @@ -18275,7 +18907,7 @@ }, { "kind": "conditional-branch", - "line": 175, + "line": 181, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " (\\(probe.command) pid \\(probe.pid))", "surface": "apple", @@ -18283,7 +18915,7 @@ }, { "kind": "conditional-branch", - "line": 183, + "line": 189, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "1 gateway found on your network — click to choose it.", "surface": "apple", @@ -18291,7 +18923,7 @@ }, { "kind": "conditional-branch", - "line": 183, + "line": 189, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "\\(count) gateways found on your network — click to choose one.", "surface": "apple", @@ -18299,7 +18931,7 @@ }, { "kind": "ui-call", - "line": 198, + "line": 204, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No gateways found on your network yet.", "surface": "apple", @@ -18307,7 +18939,7 @@ }, { "kind": "ui-call", - "line": 201, + "line": 207, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Look again", "surface": "apple", @@ -18315,7 +18947,7 @@ }, { "kind": "ui-modifier", - "line": 206, + "line": 212, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Retry discovery (Bonjour + Tailscale DNS-SD).", "surface": "apple", @@ -18323,7 +18955,7 @@ }, { "kind": "conditional-branch", - "line": 234, + "line": 240, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Advanced…", "surface": "apple", @@ -18331,7 +18963,7 @@ }, { "kind": "conditional-branch", - "line": 234, + "line": 240, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Hide Advanced", "surface": "apple", @@ -18339,7 +18971,7 @@ }, { "kind": "ui-call", - "line": 254, + "line": 260, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Transport", "surface": "apple", @@ -18347,7 +18979,7 @@ }, { "kind": "ui-call", - "line": 255, + "line": 261, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH tunnel", "surface": "apple", @@ -18355,7 +18987,7 @@ }, { "kind": "ui-call", - "line": 256, + "line": 262, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Direct (ws/wss)", "surface": "apple", @@ -18363,7 +18995,7 @@ }, { "kind": "ui-call", - "line": 263, + "line": 269, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway URL", "surface": "apple", @@ -18371,7 +19003,7 @@ }, { "kind": "ui-call", - "line": 266, + "line": 272, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "wss://gateway.example.ts.net", "surface": "apple", @@ -18379,7 +19011,7 @@ }, { "kind": "ui-call", - "line": 273, + "line": 279, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "SSH target", "surface": "apple", @@ -18387,7 +19019,7 @@ }, { "kind": "ui-call", - "line": 293, + "line": 299, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Identity file", "surface": "apple", @@ -18395,7 +19027,7 @@ }, { "kind": "ui-call", - "line": 296, + "line": 302, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Users/you/.ssh/id_ed25519", "surface": "apple", @@ -18403,7 +19035,7 @@ }, { "kind": "ui-call", - "line": 301, + "line": 307, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Project root", "surface": "apple", @@ -18411,7 +19043,7 @@ }, { "kind": "ui-call", - "line": 304, + "line": 310, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/home/you/Projects/openclaw", "surface": "apple", @@ -18419,7 +19051,7 @@ }, { "kind": "ui-call", - "line": 309, + "line": 315, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "CLI path", "surface": "apple", @@ -18427,7 +19059,7 @@ }, { "kind": "ui-call", - "line": 312, + "line": 318, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "/Applications/OpenClaw.app/.../openclaw", "surface": "apple", @@ -18435,7 +19067,7 @@ }, { "kind": "conditional-branch", - "line": 322, + "line": 328, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: keep Tailscale enabled so your gateway stays reachable.", "surface": "apple", @@ -18443,7 +19075,7 @@ }, { "kind": "conditional-branch", - "line": 322, + "line": 328, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.", "surface": "apple", @@ -18451,7 +19083,7 @@ }, { "kind": "ui-call", - "line": 381, + "line": 387, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote connection", "surface": "apple", @@ -18459,7 +19091,7 @@ }, { "kind": "ui-call", - "line": 383, + "line": 389, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checks the real remote websocket and auth handshake.", "surface": "apple", @@ -18467,7 +19099,7 @@ }, { "kind": "ui-call", - "line": 396, + "line": 402, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Check connection", "surface": "apple", @@ -18475,7 +19107,7 @@ }, { "kind": "ui-call", - "line": 426, + "line": 432, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway token", "surface": "apple", @@ -18483,7 +19115,7 @@ }, { "kind": "ui-call", - "line": 429, + "line": 435, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "remote gateway auth token (gateway.remote.token)", "surface": "apple", @@ -18491,7 +19123,7 @@ }, { "kind": "ui-call", - "line": 433, + "line": 439, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Used when the remote gateway requires token auth.", "surface": "apple", @@ -18499,7 +19131,7 @@ }, { "kind": "ui-call-concatenated", - "line": 437, + "line": 443, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The current gateway.remote.token value is not plain text. OpenClaw for macOS cannot use it directly; enter a plaintext token here to replace it.", "surface": "apple", @@ -18507,7 +19139,7 @@ }, { "kind": "ui-call", - "line": 454, + "line": 460, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checking remote gateway…", "surface": "apple", @@ -18515,7 +19147,7 @@ }, { "kind": "conditional-branch", - "line": 584, + "line": 591, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": " · ssh \\(parsed.port)", "surface": "apple", @@ -18523,7 +19155,7 @@ }, { "kind": "ui-call", - "line": 654, + "line": 661, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Grant permissions", "surface": "apple", @@ -18531,7 +19163,7 @@ }, { "kind": "ui-call-concatenated", - "line": 661, + "line": 668, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac. Status updates automatically.", "surface": "apple", @@ -18539,7 +19171,7 @@ }, { "kind": "ui-call", - "line": 688, + "line": 695, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Getting things ready", "surface": "apple", @@ -18547,7 +19179,7 @@ }, { "kind": "ui-call-concatenated", - "line": 690, + "line": 697, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw is setting up its background service on this Mac. This usually takes under a minute — no Terminal, no administrator password.", "surface": "apple", @@ -18555,7 +19187,7 @@ }, { "kind": "ui-named-argument", - "line": 701, + "line": 708, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Install OpenClaw", "surface": "apple", @@ -18563,7 +19195,7 @@ }, { "kind": "ui-named-argument", - "line": 708, + "line": 715, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Start the background service", "surface": "apple", @@ -18571,7 +19203,7 @@ }, { "kind": "ui-named-argument", - "line": 709, + "line": 716, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Runs quietly and starts again after a restart.", "surface": "apple", @@ -18579,7 +19211,7 @@ }, { "kind": "ui-named-argument", - "line": 712, + "line": 719, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Ready for the next step", "surface": "apple", @@ -18587,7 +19219,7 @@ }, { "kind": "ui-named-argument", - "line": 713, + "line": 720, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Once the service answers, you’ll connect your AI.", "surface": "apple", @@ -18595,7 +19227,7 @@ }, { "kind": "ui-named-argument", - "line": 718, + "line": 725, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "The Gateway didn’t start", "surface": "apple", @@ -18603,7 +19235,7 @@ }, { "kind": "ui-named-argument", - "line": 721, + "line": 728, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try again", "surface": "apple", @@ -18611,7 +19243,7 @@ }, { "kind": "ui-call", - "line": 808, + "line": 815, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Agent workspace", "surface": "apple", @@ -18619,7 +19251,7 @@ }, { "kind": "ui-call-concatenated", - "line": 810, + "line": 817, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` and write files there without mixing into your other projects.", "surface": "apple", @@ -18627,7 +19259,7 @@ }, { "kind": "ui-call", - "line": 821, + "line": 828, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway detected", "surface": "apple", @@ -18635,7 +19267,7 @@ }, { "kind": "ui-call-concatenated", - "line": 823, + "line": 830, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create the workspace on the remote host (SSH in first). The macOS app can’t write files on your gateway over SSH yet.", "surface": "apple", @@ -18643,7 +19275,7 @@ }, { "kind": "conditional-branch", - "line": 829, + "line": 836, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copied", "surface": "apple", @@ -18651,7 +19283,7 @@ }, { "kind": "conditional-branch", - "line": 829, + "line": 836, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copy setup command", "surface": "apple", @@ -18659,7 +19291,7 @@ }, { "kind": "ui-call", - "line": 835, + "line": 842, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Workspace folder", "surface": "apple", @@ -18667,7 +19299,7 @@ }, { "kind": "ui-call", - "line": 849, + "line": 856, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create workspace", "surface": "apple", @@ -18675,7 +19307,7 @@ }, { "kind": "ui-call", - "line": 855, + "line": 862, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open folder", "surface": "apple", @@ -18683,7 +19315,7 @@ }, { "kind": "ui-call", - "line": 862, + "line": 869, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Save in config", "surface": "apple", @@ -18691,7 +19323,7 @@ }, { "kind": "ui-call-concatenated", - "line": 883, + "line": 890, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. For backup, make the workspace a private git repo so your agent’s “memory” is versioned.", "surface": "apple", @@ -18699,7 +19331,7 @@ }, { "kind": "ui-call", - "line": 898, + "line": 905, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Meet your agent", "surface": "apple", @@ -18707,7 +19339,7 @@ }, { "kind": "ui-call-concatenated", - "line": 900, + "line": 907, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Your agent introduces itself, picks a name with you, and helps you connect WhatsApp, Telegram, or another channel — just chat.", "surface": "apple", @@ -18715,7 +19347,7 @@ }, { "kind": "ui-call", - "line": 921, + "line": 928, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "You’re all set!", "surface": "apple", @@ -18723,7 +19355,7 @@ }, { "kind": "ui-call", - "line": 924, + "line": 931, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Finish opens the chat — say hi to your new agent.", "surface": "apple", @@ -18731,7 +19363,7 @@ }, { "kind": "ui-named-argument", - "line": 932, + "line": 939, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Configure later", "surface": "apple", @@ -18739,7 +19371,7 @@ }, { "kind": "ui-named-argument", - "line": 933, + "line": 940, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Pick Local or Remote in Settings → General whenever you’re ready.", "surface": "apple", @@ -18747,7 +19379,7 @@ }, { "kind": "ui-named-argument", - "line": 940, + "line": 947, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway checklist", "surface": "apple", @@ -18755,7 +19387,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 941, + "line": 948, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.", "surface": "apple", @@ -18763,7 +19395,7 @@ }, { "kind": "ui-named-argument", - "line": 950, + "line": 957, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel", "surface": "apple", @@ -18771,7 +19403,7 @@ }, { "kind": "ui-named-argument", - "line": 951, + "line": 958, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Click the OpenClaw menu bar icon for quick chat and status.", "surface": "apple", @@ -18779,7 +19411,7 @@ }, { "kind": "ui-named-argument", - "line": 954, + "line": 961, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Connect Discord, Slack, Telegram, WhatsApp, …", "surface": "apple", @@ -18787,7 +19419,7 @@ }, { "kind": "ui-named-argument", - "line": 955, + "line": 962, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels to link channels and monitor status.", "surface": "apple", @@ -18795,7 +19427,7 @@ }, { "kind": "ui-named-argument", - "line": 957, + "line": 964, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels", "surface": "apple", @@ -18803,7 +19435,7 @@ }, { "kind": "ui-named-argument", - "line": 962, + "line": 969, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try Voice Wake", "surface": "apple", @@ -18811,7 +19443,7 @@ }, { "kind": "ui-named-argument", - "line": 963, + "line": 970, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", "surface": "apple", @@ -18819,7 +19451,7 @@ }, { "kind": "ui-named-argument", - "line": 966, + "line": 973, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Use the panel + Canvas", "surface": "apple", @@ -18827,7 +19459,7 @@ }, { "kind": "ui-named-argument", - "line": 967, + "line": 974, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel for quick chat; the agent can show previews ", "surface": "apple", @@ -18835,7 +19467,7 @@ }, { "kind": "ui-named-argument", - "line": 971, + "line": 978, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your agent more powers", "surface": "apple", @@ -18843,7 +19475,7 @@ }, { "kind": "ui-named-argument", - "line": 972, + "line": 979, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", "surface": "apple", @@ -18851,7 +19483,7 @@ }, { "kind": "ui-named-argument", - "line": 974, + "line": 981, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Skills", "surface": "apple", @@ -18859,7 +19491,7 @@ }, { "kind": "ui-call", - "line": 979, + "line": 986, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -18867,7 +19499,7 @@ }, { "kind": "ui-call", - "line": 1008, + "line": 1015, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -18875,7 +19507,7 @@ }, { "kind": "ui-call", - "line": 1015, + "line": 1022, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -18883,7 +19515,7 @@ }, { "kind": "ui-call", - "line": 1024, + "line": 1031, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -18891,7 +19523,7 @@ }, { "kind": "ui-call-concatenated", - "line": 1027, + "line": 1034, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).", "surface": "apple", @@ -18899,7 +19531,7 @@ }, { "kind": "ui-call", - "line": 1033, + "line": 1040, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -18907,7 +19539,7 @@ }, { "kind": "ui-call", - "line": 1039, + "line": 1046, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", @@ -19779,7 +20411,7 @@ }, { "kind": "ui-call", - "line": 132, + "line": 179, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Managed by Nix", "surface": "apple", @@ -19787,7 +20419,7 @@ }, { "kind": "ui-call", - "line": 138, + "line": 185, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Config: \\(configPath)", "surface": "apple", @@ -19795,7 +20427,7 @@ }, { "kind": "ui-call", - "line": 139, + "line": 186, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "State: \\(stateDir)", "surface": "apple", @@ -19803,7 +20435,7 @@ }, { "kind": "conditional-branch", - "line": 271, + "line": 474, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "General", "surface": "apple", @@ -19811,7 +20443,7 @@ }, { "kind": "conditional-branch", - "line": 272, + "line": 475, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Connection", "surface": "apple", @@ -19819,7 +20451,7 @@ }, { "kind": "conditional-branch", - "line": 273, + "line": 476, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Permissions", "surface": "apple", @@ -19827,7 +20459,7 @@ }, { "kind": "conditional-branch", - "line": 274, + "line": 477, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Voice & Talk", "surface": "apple", @@ -19835,7 +20467,7 @@ }, { "kind": "conditional-branch", - "line": 275, + "line": 478, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Crestodian", "surface": "apple", @@ -19843,7 +20475,7 @@ }, { "kind": "conditional-branch", - "line": 276, + "line": 479, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Channels", "surface": "apple", @@ -19851,7 +20483,7 @@ }, { "kind": "conditional-branch", - "line": 277, + "line": 480, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Skills", "surface": "apple", @@ -19859,7 +20491,7 @@ }, { "kind": "conditional-branch", - "line": 278, + "line": 481, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Cron Jobs", "surface": "apple", @@ -19867,7 +20499,7 @@ }, { "kind": "conditional-branch", - "line": 279, + "line": 482, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Exec Approvals", "surface": "apple", @@ -19875,7 +20507,7 @@ }, { "kind": "conditional-branch", - "line": 280, + "line": 483, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Sessions", "surface": "apple", @@ -19883,7 +20515,7 @@ }, { "kind": "conditional-branch", - "line": 281, + "line": 484, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Instances", "surface": "apple", @@ -19891,7 +20523,7 @@ }, { "kind": "conditional-branch", - "line": 282, + "line": 485, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Config", "surface": "apple", @@ -19899,7 +20531,7 @@ }, { "kind": "conditional-branch", - "line": 283, + "line": 486, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "Debug", "surface": "apple", @@ -19907,7 +20539,7 @@ }, { "kind": "conditional-branch", - "line": 284, + "line": 487, "path": "apps/macos/Sources/OpenClaw/SettingsRootView.swift", "source": "About", "surface": "apple", diff --git a/apps/android/README.md b/apps/android/README.md index 067bf2d11228..a3c832391967 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -20,6 +20,7 @@ OpenClaw Android is the officially released Google Play app. It connects to an O - [x] Screen tab full functionality - [x] Skill Workshop settings can filter proposals, inspect proposal content, and apply/reject/quarantine drafts through Gateway RPCs - [x] Per-app language selection for translated resources follows Android system settings and persistence +- [x] Cron job settings support details, run history, run now, edits, enable/disable, and deletion with admin-scoped Gateway access ## Open in Android Studio diff --git a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt index 7b664d9f67c4..a6f9ca4378b8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/AndroidScreenshotFixture.kt @@ -8,6 +8,8 @@ import kotlinx.serialization.json.buildJsonObject internal object AndroidScreenshotFixture { const val mainSessionKey = "agent:main:node-screenshot" const val primarySessionTitle = "Android release planning" + const val cronJobId = "android-release-digest" + const val cronJobName = "Android release digest" val agents = listOf( @@ -93,9 +95,97 @@ internal object AndroidScreenshotFixture { "chat.history" -> chatHistory() "sessions.list" -> sessionList() "chat.metadata" -> chatMetadata() + "cron.list" -> cronList() + "cron.get" -> cronJob().toString() + "cron.runs" -> cronRuns() else -> error("Screenshot fixture does not implement gateway method $method with params $paramsJson") } + private fun cronList(): String = + buildJsonObject { + put( + "jobs", + buildJsonArray { + add(cronJob()) + }, + ) + }.toString() + + private fun cronJob() = + buildJsonObject { + put("id", JsonPrimitive(cronJobId)) + put("name", JsonPrimitive(cronJobName)) + put("enabled", JsonPrimitive(true)) + put("createdAtMs", JsonPrimitive(1_783_468_800_000)) + put("updatedAtMs", JsonPrimitive(1_783_555_200_000)) + put("configRevision", JsonPrimitive("sha256:screenshot-fixture")) + put( + "schedule", + buildJsonObject { + put("kind", JsonPrimitive("every")) + put("everyMs", JsonPrimitive(86_400_000)) + put("anchorMs", JsonPrimitive(1_783_468_800_000)) + }, + ) + put("sessionTarget", JsonPrimitive("isolated")) + put("wakeMode", JsonPrimitive("now")) + put( + "payload", + buildJsonObject { + put("kind", JsonPrimitive("agentTurn")) + put("message", JsonPrimitive("Summarize Android release readiness.")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + put( + "state", + buildJsonObject { + put("nextRunAtMs", JsonPrimitive(1_783_641_600_000)) + put("lastRunAtMs", JsonPrimitive(1_783_555_200_000)) + put("lastStatus", JsonPrimitive("ok")) + put("lastDurationMs", JsonPrimitive(1_842)) + put("consecutiveErrors", JsonPrimitive(0)) + put("consecutiveSkipped", JsonPrimitive(0)) + put("lastDeliveryStatus", JsonPrimitive("delivered")) + }, + ) + } + + private fun cronRuns(): String = + buildJsonObject { + put( + "entries", + buildJsonArray { + add( + buildJsonObject { + put("ts", JsonPrimitive(1_783_555_200_000)) + put("jobId", JsonPrimitive(cronJobId)) + put("runId", JsonPrimitive("android-release-digest-run-2")) + put("action", JsonPrimitive("finished")) + put("status", JsonPrimitive("ok")) + put("summary", JsonPrimitive("Release checklist ready")) + put("durationMs", JsonPrimitive(1_842)) + put("deliveryStatus", JsonPrimitive("delivered")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + add( + buildJsonObject { + put("ts", JsonPrimitive(1_783_468_800_000)) + put("jobId", JsonPrimitive(cronJobId)) + put("runId", JsonPrimitive("android-release-digest-run-1")) + put("action", JsonPrimitive("finished")) + put("status", JsonPrimitive("error")) + put("error", JsonPrimitive("Play publish blocked")) + put("durationMs", JsonPrimitive(927)) + put("deliveryStatus", JsonPrimitive("not-requested")) + put("model", JsonPrimitive("openai/gpt-5.2")) + }, + ) + }, + ) + }.toString() + private fun chatHistory(): String = buildJsonObject { put("sessionId", JsonPrimitive("screenshot-session")) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt b/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt index 78725c7d262f..b16aa4edcc9f 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/CronJobDetail.kt @@ -15,22 +15,37 @@ data class GatewayCronJobDetail( val description: String, val enabled: Boolean, val deleteAfterRun: Boolean, + val scheduleKind: String, val scheduleLabel: String, val scheduleDetail: String, + val scheduleAt: String?, + val scheduleEveryMs: Long?, + val scheduleAnchorMs: Long?, + val scheduleCronExpr: String?, + val scheduleTimezone: String?, + val scheduleStaggerMs: Long?, + val scheduleCommand: String?, + val scheduleCwd: String?, val sessionTarget: String, val wakeMode: String, val payloadKind: String, val payloadText: String?, val payloadLabel: String, + val payloadModel: String?, + val payloadThinking: String?, + val payloadCommandArgv: List?, + val payloadCommandCwd: String?, val deliveryLabel: String, val failureAlertLabel: String, val createdAtMs: Long, val updatedAtMs: Long, + val configRevision: String?, val nextRunAtMs: Long?, val runningAtMs: Long?, val lastRunAtMs: Long?, val lastRunStatus: String?, val lastError: String?, + val lastDiagnosticSummary: String?, val lastDurationMs: Long?, val consecutiveErrors: Long?, val consecutiveSkipped: Long?, @@ -75,6 +90,18 @@ internal class CronJobDetailRequestGuard { } } + fun beginIfCurrent( + rawId: String, + onBegin: (CronJobDetailRequest) -> Unit, + ): CronJobDetailRequest? { + val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return null + return synchronized(lock) { + if (selectedId != id) return@synchronized null + generation += 1 + CronJobDetailRequest(id = id, generation = generation).also(onBegin) + } + } + fun cancel(onCancel: () -> Unit = {}) { synchronized(lock) { generation += 1 @@ -83,6 +110,20 @@ internal class CronJobDetailRequestGuard { } } + fun cancelIfCurrent( + rawId: String, + onCancel: () -> Unit, + ): Boolean { + val id = rawId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (selectedId != id) return@synchronized false + generation += 1 + selectedId = null + onCancel() + true + } + } + fun publishIfCurrent( request: CronJobDetailRequest, publish: () -> Unit, @@ -110,6 +151,9 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail? val sessionTarget = value.string("sessionTarget") ?: return null val wakeMode = value.string("wakeMode") ?: return null val payloadKind = payload.string("kind") ?: return null + val scheduleKind = schedule.string("kind") ?: return null + if (scheduleKind !in setOf("at", "every", "cron", "on-exit")) return null + if (payloadKind !in setOf("systemEvent", "agentTurn", "command")) return null val state = value["state"].asObjectOrNull() ?: return null return GatewayCronJobDetail( @@ -118,22 +162,39 @@ internal fun parseGatewayCronJobDetail(job: JsonObject?): GatewayCronJobDetail? description = value.string("description").orEmpty(), enabled = value.boolean("enabled"), deleteAfterRun = value.boolean("deleteAfterRun"), + scheduleKind = scheduleKind, scheduleLabel = cronScheduleLabel(schedule), scheduleDetail = cronScheduleDetail(schedule), + scheduleAt = schedule.string("at"), + scheduleEveryMs = schedule.long("everyMs"), + scheduleAnchorMs = schedule.long("anchorMs"), + scheduleCronExpr = schedule.string("expr"), + scheduleTimezone = schedule.string("tz"), + scheduleStaggerMs = schedule.long("staggerMs"), + scheduleCommand = schedule.string("command"), + scheduleCwd = schedule.string("cwd"), sessionTarget = sessionTarget, wakeMode = wakeMode, payloadKind = payloadKind, payloadText = cronPayloadText(payload), payloadLabel = cronPayloadLabel(payload), + payloadModel = payload.string("model"), + payloadThinking = payload.string("thinking"), + payloadCommandArgv = + (payload["argv"] as? JsonArray) + ?.mapNotNull { it.asStringOrNull() }, + payloadCommandCwd = payload.string("cwd"), deliveryLabel = cronDeliveryLabel(value["delivery"].asObjectOrNull()), failureAlertLabel = cronFailureAlertLabel(value["failureAlert"]), createdAtMs = createdAtMs, updatedAtMs = updatedAtMs, + configRevision = value.string("configRevision"), nextRunAtMs = state.long("nextRunAtMs"), runningAtMs = state.long("runningAtMs"), lastRunAtMs = state.long("lastRunAtMs"), lastRunStatus = cronJobLastRunStatus(state), lastError = state.string("lastError"), + lastDiagnosticSummary = state.string("lastDiagnosticSummary"), lastDurationMs = state.long("lastDurationMs"), consecutiveErrors = state.long("consecutiveErrors"), consecutiveSkipped = state.long("consecutiveSkipped"), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/CronJobManagement.kt b/apps/android/app/src/main/java/ai/openclaw/app/CronJobManagement.kt new file mode 100644 index 000000000000..27471ccb2b94 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/CronJobManagement.kt @@ -0,0 +1,611 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewaySession +import ai.openclaw.app.node.asObjectOrNull +import ai.openclaw.app.node.asStringOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonObject + +data class GatewayCronRunSummary( + val ts: Long, + val runId: String?, + val status: String?, + val summary: String?, + val error: String?, + val durationMs: Long?, + val deliveryStatus: String?, + val sessionKey: String?, + val model: String?, +) + +sealed interface GatewayCronRunHistoryState { + data object Idle : GatewayCronRunHistoryState + + data class Loading( + val id: String, + ) : GatewayCronRunHistoryState + + data class Loaded( + val id: String, + val runs: List, + ) : GatewayCronRunHistoryState + + data class Error( + val id: String, + val message: String, + ) : GatewayCronRunHistoryState +} + +enum class GatewayCronAction { + Run, + Enable, + Disable, + Save, + Delete, +} + +enum class GatewayCronNoticeKind { + Success, + Warning, + Error, +} + +sealed interface GatewayCronActionState { + data object Idle : GatewayCronActionState + + data class Running( + val id: String, + val action: GatewayCronAction, + ) : GatewayCronActionState + + data class Notice( + val id: String, + val message: String, + val kind: GatewayCronNoticeKind, + val deleted: Boolean = false, + ) : GatewayCronActionState +} + +/** Owns one queued manual run id per job so a stale tracker cannot clear a newer run. */ +internal class PendingCronRunRegistry { + private val lock = Any() + private val runIdsByJob = linkedMapOf() + + fun contains(rawJobId: String): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { runIdsByJob.containsKey(jobId) } + } + + fun begin( + rawJobId: String, + rawRunId: String, + publish: (Set) -> Unit, + ): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (runIdsByJob.containsKey(jobId)) return@synchronized false + runIdsByJob[jobId] = runId + publish(runIdsByJob.keys.toSet()) + true + } + } + + fun finish( + rawJobId: String, + rawRunId: String, + publish: (Set) -> Unit, + ): Boolean { + val jobId = rawJobId.trim().takeIf { it.isNotEmpty() } ?: return false + val runId = rawRunId.trim().takeIf { it.isNotEmpty() } ?: return false + return synchronized(lock) { + if (runIdsByJob[jobId] != runId) return@synchronized false + runIdsByJob.remove(jobId) + publish(runIdsByJob.keys.toSet()) + true + } + } + + fun clear(publish: (Set) -> Unit) { + synchronized(lock) { + runIdsByJob.clear() + publish(emptySet()) + } + } +} + +sealed interface GatewayCronScheduleEdit { + data class At( + val at: String, + ) : GatewayCronScheduleEdit + + data class Every( + val everyMs: String, + val anchorMs: String, + ) : GatewayCronScheduleEdit + + data class Cron( + val expression: String, + val timezone: String, + val staggerMs: String, + ) : GatewayCronScheduleEdit + + data class OnExit( + val command: String, + val cwd: String, + ) : GatewayCronScheduleEdit +} + +sealed interface GatewayCronPayloadEdit { + data class SystemEvent( + val text: String, + ) : GatewayCronPayloadEdit + + data class AgentTurn( + val message: String, + val model: String, + val thinking: String, + ) : GatewayCronPayloadEdit + + data class Command( + val argvJson: String, + val cwd: String, + ) : GatewayCronPayloadEdit +} + +data class GatewayCronJobEdit( + val name: String, + val description: String, + val enabled: Boolean, + val deleteAfterRun: Boolean, + val schedule: GatewayCronScheduleEdit, + val sessionTarget: String, + val wakeMode: String, + val payload: GatewayCronPayloadEdit, +) { + fun withSchedule(value: GatewayCronScheduleEdit): GatewayCronJobEdit = + copy( + schedule = value, + deleteAfterRun = deleteAfterRun && value is GatewayCronScheduleEdit.At, + ) +} + +internal data class CronEditorDraftState( + val baseline: GatewayCronJobEdit, + val edit: GatewayCronJobEdit, + val savePending: Boolean = false, + val saveSucceeded: Boolean = false, + val hasIncomingConflict: Boolean = false, +) { + val isDirty: Boolean + get() = edit != baseline + + val requiresResolution: Boolean + get() = isDirty || hasIncomingConflict + + fun withEdit(value: GatewayCronJobEdit): CronEditorDraftState = copy(edit = value) + + fun saveStarted(): CronEditorDraftState = copy(savePending = true, saveSucceeded = false) + + fun saveAborted(): CronEditorDraftState = copy(savePending = false, saveSucceeded = false) + + fun observeSaveNotice(kind: GatewayCronNoticeKind): CronEditorDraftState { + if (!savePending) return this + return if (kind == GatewayCronNoticeKind.Success) { + copy(saveSucceeded = true) + } else { + copy(savePending = false, saveSucceeded = false) + } + } + + fun observeJob(job: GatewayCronJobDetail): CronEditorDraftState { + val incoming = job.toCronJobEdit() + if (incoming == edit) { + return CronEditorDraftState( + baseline = incoming, + edit = incoming, + ) + } + if (incoming == baseline) { + return copy(hasIncomingConflict = false) + } + val canAdopt = !isDirty || saveSucceeded + if (!canAdopt) { + return copy(hasIncomingConflict = true) + } + return CronEditorDraftState( + baseline = incoming, + edit = incoming, + ) + } + + companion object { + fun from(job: GatewayCronJobDetail): CronEditorDraftState { + val edit = job.toCronJobEdit() + return CronEditorDraftState( + baseline = edit, + edit = edit, + ) + } + } +} + +internal fun CronEditorDraftState.reconcileRestoredAction( + isConnected: Boolean, + jobId: String, + actionState: GatewayCronActionState, +): CronEditorDraftState { + if (!savePending) return this + // Activity recreation retains the runtime action; process death does not. + // Preserve pending only when the restored runtime still owns this Save. + val retainedSaveState = + when (actionState) { + is GatewayCronActionState.Running -> + actionState.id == jobId && actionState.action == GatewayCronAction.Save + is GatewayCronActionState.Notice -> actionState.id == jobId + GatewayCronActionState.Idle -> false + } + return if (isConnected && retainedSaveState) this else saveAborted() +} + +internal enum class GatewayCronRunSkipReason( + val message: String, +) { + NotDue("Cron job is not due yet."), + AlreadyRunning("Cron job is already running."), + RestartRecoveryPending("Gateway restart recovery is still in progress."), + InvalidSpec("Cron job has an invalid configuration."), + Stopped("Cron scheduler is stopped."), +} + +internal sealed interface GatewayCronRunOutcome { + data class Started( + val runId: String?, + ) : GatewayCronRunOutcome + + data class Skipped( + val reason: GatewayCronRunSkipReason, + ) : GatewayCronRunOutcome + + data object Rejected : GatewayCronRunOutcome +} + +internal fun cronRunShouldRefresh(outcome: GatewayCronRunOutcome): Boolean = + when (outcome) { + is GatewayCronRunOutcome.Started -> true + is GatewayCronRunOutcome.Skipped -> outcome.reason == GatewayCronRunSkipReason.InvalidSpec + GatewayCronRunOutcome.Rejected -> false + } + +internal fun cronRunCompletionNotice( + jobId: String, + status: String?, +): GatewayCronActionState.Notice { + val (message, kind) = + when (status) { + "ok" -> "Cron run finished." to GatewayCronNoticeKind.Success + "skipped" -> "Cron run skipped." to GatewayCronNoticeKind.Warning + "error" -> "Cron run failed." to GatewayCronNoticeKind.Error + else -> "Cron run finished with an unknown status." to GatewayCronNoticeKind.Warning + } + return GatewayCronActionState.Notice(id = jobId, message = message, kind = kind) +} + +internal fun isCronJobRevisionConflict(error: GatewaySession.ErrorShape): Boolean = error.details?.code == "CRON_JOB_CHANGED" + +internal fun GatewayCronJobDetail.toCronJobEdit(): GatewayCronJobEdit = + GatewayCronJobEdit( + name = name, + description = description, + enabled = enabled, + // Gateway deletion only runs after a successful one-shot schedule. + deleteAfterRun = deleteAfterRun && scheduleKind == "at", + schedule = + when (scheduleKind) { + "at" -> GatewayCronScheduleEdit.At(at = scheduleAt.orEmpty()) + "every" -> + GatewayCronScheduleEdit.Every( + everyMs = scheduleEveryMs?.toString().orEmpty(), + anchorMs = scheduleAnchorMs?.toString().orEmpty(), + ) + "cron" -> + GatewayCronScheduleEdit.Cron( + expression = scheduleCronExpr.orEmpty(), + timezone = scheduleTimezone.orEmpty(), + staggerMs = scheduleStaggerMs?.toString().orEmpty(), + ) + "on-exit" -> + GatewayCronScheduleEdit.OnExit( + command = scheduleCommand.orEmpty(), + cwd = scheduleCwd.orEmpty(), + ) + else -> error("Unsupported cron schedule kind: $scheduleKind") + }, + sessionTarget = sessionTarget, + wakeMode = wakeMode, + payload = + when (payloadKind) { + "systemEvent" -> GatewayCronPayloadEdit.SystemEvent(text = payloadText.orEmpty()) + "agentTurn" -> + GatewayCronPayloadEdit.AgentTurn( + message = payloadText.orEmpty(), + model = payloadModel.orEmpty(), + thinking = payloadThinking.orEmpty(), + ) + "command" -> + GatewayCronPayloadEdit.Command( + argvJson = JsonArray(payloadCommandArgv.orEmpty().map(::JsonPrimitive)).toString(), + cwd = payloadCommandCwd.orEmpty(), + ) + else -> error("Unsupported cron payload kind: $payloadKind") + }, + ) + +internal fun buildCronUpdateParams( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, +): String { + val name = edit.name.trim() + require(name.isNotEmpty()) { "Cron job name is required." } + val description = edit.description.trim() + val sessionTarget = edit.sessionTarget.trim() + require( + sessionTarget == "main" || + sessionTarget == "isolated" || + sessionTarget == "current" || + (sessionTarget.startsWith("session:") && sessionTarget.removePrefix("session:").isNotBlank()), + ) { "Session target must be main, isolated, current, or session:." } + val wakeMode = edit.wakeMode.trim() + require(wakeMode == "now" || wakeMode == "next-heartbeat") { + "Wake mode must be now or next-heartbeat." + } + + val schedulePatch = buildCronSchedulePatch(original = original, edit = edit.schedule) + val payloadPatch = buildCronPayloadPatch(original = original, edit = edit.payload) + val patch = + buildJsonObject { + if (name != original.name) put("name", JsonPrimitive(name)) + if (description != original.description) put("description", JsonPrimitive(description)) + if (edit.enabled != original.enabled) put("enabled", JsonPrimitive(edit.enabled)) + if (edit.deleteAfterRun != original.deleteAfterRun) { + put("deleteAfterRun", JsonPrimitive(edit.deleteAfterRun)) + } + schedulePatch?.let { put("schedule", it) } + if (sessionTarget != original.sessionTarget) { + put("sessionTarget", JsonPrimitive(sessionTarget)) + } + if (wakeMode != original.wakeMode) put("wakeMode", JsonPrimitive(wakeMode)) + payloadPatch?.let { put("payload", it) } + } + require(patch.isNotEmpty()) { "No cron changes to save." } + val configRevision = + requireNotNull(original.configRevision) { + "Update the gateway before saving cron changes from Android." + } + return buildJsonObject { + put("id", JsonPrimitive(original.id)) + put("expectedConfigRevision", JsonPrimitive(configRevision)) + put("patch", patch) + }.toString() +} + +internal fun parseGatewayCronRunOutcome(root: JsonObject?): GatewayCronRunOutcome? { + val value = root ?: return null + val ok = value.optionalBoolean("ok") ?: return null + if (!ok) return GatewayCronRunOutcome.Rejected + if (value.optionalBoolean("ran") == true) { + return GatewayCronRunOutcome.Started(runId = value.string("runId")) + } + if (value.optionalBoolean("enqueued") == true) { + val runId = value.string("runId") ?: return null + return GatewayCronRunOutcome.Started(runId = runId) + } + if (value.optionalBoolean("ran") != false) return null + val reason = + when (value.string("reason")) { + "not-due" -> GatewayCronRunSkipReason.NotDue + "already-running" -> GatewayCronRunSkipReason.AlreadyRunning + "restart-recovery-pending" -> GatewayCronRunSkipReason.RestartRecoveryPending + "invalid-spec" -> GatewayCronRunSkipReason.InvalidSpec + "stopped" -> GatewayCronRunSkipReason.Stopped + else -> return null + } + return GatewayCronRunOutcome.Skipped(reason) +} + +internal fun parseGatewayCronRunHistory(entries: JsonArray?): List = + entries + ?.mapNotNull { item -> + val value = item.asObjectOrNull() ?: return@mapNotNull null + val ts = value.long("ts") ?: return@mapNotNull null + GatewayCronRunSummary( + ts = ts, + runId = value.string("runId"), + status = value.string("status"), + summary = value.string("summary"), + error = value.string("error"), + durationMs = value.long("durationMs"), + deliveryStatus = value.string("deliveryStatus"), + sessionKey = value.string("sessionKey"), + model = value.string("model"), + ) + }.orEmpty() + +private fun buildCronSchedulePatch( + original: GatewayCronJobDetail, + edit: GatewayCronScheduleEdit, +): JsonObject? = + when (edit) { + is GatewayCronScheduleEdit.At -> { + require(original.scheduleKind == "at") { "Changing schedule type is not supported here." } + val at = edit.at.trim() + require(at.isNotEmpty()) { "One-time cron jobs need an ISO time." } + if (at == original.scheduleAt) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("at")) + put("at", JsonPrimitive(at)) + } + } + } + is GatewayCronScheduleEdit.Every -> { + require(original.scheduleKind == "every") { "Changing schedule type is not supported here." } + val everyMs = edit.everyMs.trim().toLongOrNull() + require(everyMs != null && everyMs > 0L) { "Interval must be a positive number of milliseconds." } + val anchorMs = parseOptionalNonNegativeLong(edit.anchorMs, "Anchor") + if (everyMs == original.scheduleEveryMs && anchorMs == original.scheduleAnchorMs) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("every")) + put("everyMs", JsonPrimitive(everyMs)) + anchorMs?.let { put("anchorMs", JsonPrimitive(it)) } + } + } + } + is GatewayCronScheduleEdit.Cron -> { + require(original.scheduleKind == "cron") { "Changing schedule type is not supported here." } + val expression = edit.expression.trim() + require(expression.isNotEmpty()) { "Cron expression is required." } + val timezone = edit.timezone.trim().ifEmpty { null } + val requestedStaggerMs = parseOptionalNonNegativeLong(edit.staggerMs, "Stagger") + val staggerMs = + requestedStaggerMs ?: if (original.scheduleStaggerMs != null) 0L else null + if ( + expression == original.scheduleCronExpr && + timezone == original.scheduleTimezone && + staggerMs == original.scheduleStaggerMs + ) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("cron")) + put("expr", JsonPrimitive(expression)) + timezone?.let { put("tz", JsonPrimitive(it)) } + staggerMs?.let { put("staggerMs", JsonPrimitive(it)) } + } + } + } + is GatewayCronScheduleEdit.OnExit -> { + require(original.scheduleKind == "on-exit") { "Changing schedule type is not supported here." } + val command = edit.command.trim() + require(command.isNotEmpty()) { "On-exit cron jobs need a command." } + val cwd = edit.cwd.trim().ifEmpty { null } + if (command == original.scheduleCommand && cwd == original.scheduleCwd) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("on-exit")) + put("command", JsonPrimitive(command)) + cwd?.let { put("cwd", JsonPrimitive(it)) } + } + } + } + } + +private fun buildCronPayloadPatch( + original: GatewayCronJobDetail, + edit: GatewayCronPayloadEdit, +): JsonObject? = + when (edit) { + is GatewayCronPayloadEdit.SystemEvent -> { + require(original.payloadKind == "systemEvent") { "Changing payload type is not supported here." } + val text = edit.text.trim() + require(text.isNotEmpty()) { "System event text is required." } + if (text == original.payloadText) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("systemEvent")) + put("text", JsonPrimitive(text)) + } + } + } + is GatewayCronPayloadEdit.AgentTurn -> { + require(original.payloadKind == "agentTurn") { "Changing payload type is not supported here." } + val message = edit.message.trim() + require(message.isNotEmpty()) { "Agent message is required." } + val model = edit.model.trim().ifEmpty { null } + val thinking = edit.thinking.trim().ifEmpty { null } + if ( + message == original.payloadText && + model == original.payloadModel && + thinking == original.payloadThinking + ) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("agentTurn")) + if (message != original.payloadText) put("message", JsonPrimitive(message)) + if (model != original.payloadModel) put("model", model?.let(::JsonPrimitive) ?: JsonNull) + if (thinking != original.payloadThinking) { + put("thinking", thinking?.let(::JsonPrimitive) ?: JsonNull) + } + } + } + } + is GatewayCronPayloadEdit.Command -> { + require(original.payloadKind == "command") { "Changing payload type is not supported here." } + val argv = parseCommandArgv(edit.argvJson) + val cwd = edit.cwd.trim().ifEmpty { null } + if (cwd == null && original.payloadCommandCwd != null) { + error("The gateway does not support clearing a command working directory.") + } + if (argv == original.payloadCommandArgv && cwd == original.payloadCommandCwd) { + null + } else { + buildJsonObject { + put("kind", JsonPrimitive("command")) + if (argv != original.payloadCommandArgv) { + put("argv", JsonArray(argv.map(::JsonPrimitive))) + } + if (cwd != original.payloadCommandCwd) put("cwd", JsonPrimitive(requireNotNull(cwd))) + } + } + } + } + +private fun parseCommandArgv(raw: String): List { + val value = + runCatching { Json.parseToJsonElement(raw) }.getOrNull() as? JsonArray + ?: error("Command argv must be a JSON array.") + val argv = + value.map { item -> + val primitive = item as? JsonPrimitive + primitive?.takeIf { it.isString }?.content?.takeIf { it.isNotEmpty() } + ?: error("Command argv entries must be non-empty strings.") + } + require(argv.isNotEmpty()) { "Command argv must contain at least one entry." } + return argv +} + +private fun parseOptionalNonNegativeLong( + raw: String, + label: String, +): Long? { + val value = raw.trim() + if (value.isEmpty()) return null + val parsed = value.toLongOrNull() + require(parsed != null && parsed >= 0L) { "$label must be a non-negative number of milliseconds." } + return parsed +} + +private fun JsonObject.string(key: String): String? = + this[key] + .asStringOrNull() + ?.trim() + ?.takeIf { it.isNotEmpty() } + +private fun JsonObject.long(key: String): Long? = + (this[key] as? JsonPrimitive) + ?.content + ?.trim() + ?.toLongOrNull() + +private fun JsonObject.optionalBoolean(key: String): Boolean? = (this[key] as? JsonPrimitive)?.booleanOrNull diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 32a5859085b3..6eeac1e336b5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -51,6 +51,27 @@ internal fun shouldStartRuntimeOnForeground( onboardingCompleted: Boolean, ): Boolean = foreground && onboardingCompleted +internal class CronEditorDraftMemory { + private var retained: Pair? = null + + fun get(jobId: String): CronEditorDraftState? = retained?.takeIf { it.first == jobId }?.second + + fun set( + jobId: String, + state: CronEditorDraftState?, + ) { + if (state == null) { + clear(jobId) + } else { + retained = jobId to state + } + } + + fun clear(jobId: String) { + if (retained?.first == jobId) retained = null + } +} + /** * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows. */ @@ -64,6 +85,10 @@ class MainViewModel( private val gatewayConfigOperationSeq = AtomicLong() private val gatewayConfigOperationMutex = Mutex() + // One bounded heap-only slot follows the ViewModel across Activity recreation. + // Detail disposal clears it; process death drops it with the ViewModel. + internal val cronEditorDraftMemory = CronEditorDraftMemory() + @Volatile private var permissionRequester: PermissionRequester? = null @Volatile private var foreground = false @@ -196,6 +221,9 @@ class MainViewModel( val cronRefreshing: StateFlow = runtimeState(initial = false) { it.cronRefreshing } val cronErrorText: StateFlow = runtimeState(initial = null) { it.cronErrorText } val cronJobDetailState: StateFlow = runtimeState(initial = GatewayCronJobDetailState.Idle) { it.cronJobDetailState } + val cronRunHistoryState: StateFlow = runtimeState(initial = GatewayCronRunHistoryState.Idle) { it.cronRunHistoryState } + val cronActionState: StateFlow = runtimeState(initial = GatewayCronActionState.Idle) { it.cronActionState } + val pendingCronRunJobIds: StateFlow> = runtimeState(initial = emptySet()) { it.pendingCronRunJobIds } val usageSummary: StateFlow = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary } val usageRefreshing: StateFlow = runtimeState(initial = false) { it.usageRefreshing } val usageErrorText: StateFlow = runtimeState(initial = null) { it.usageErrorText } @@ -736,10 +764,40 @@ class MainViewModel( ensureRuntime().loadCronJobDetail(id) } + fun refreshCronRunHistory(id: String) { + ensureRuntime().refreshCronRunHistory(id) + } + fun clearCronJobDetail() { ensureRuntime().clearCronJobDetail() } + fun dismissCronActionNotice(id: String) { + ensureRuntime().dismissCronActionNotice(id) + } + + fun runCronJob(id: String) { + ensureRuntime().runCronJob(id) + } + + fun setCronJobEnabled( + id: String, + enabled: Boolean, + ) { + ensureRuntime().setCronJobEnabled(id = id, enabled = enabled) + } + + fun updateCronJob( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, + ) { + ensureRuntime().updateCronJob(original = original, edit = edit) + } + + fun deleteCronJob(id: String) { + ensureRuntime().deleteCronJob(id) + } + fun refreshUsage() { ensureRuntime().refreshUsage() } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 2aba6998428d..a0559319d5ae 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -24,6 +24,7 @@ import ai.openclaw.app.gateway.GatewayDiscovery import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayRegistryEntry import ai.openclaw.app.gateway.GatewayRegistryEntryKind +import ai.openclaw.app.gateway.GatewayRequestRejected import ai.openclaw.app.gateway.GatewaySession import ai.openclaw.app.gateway.GatewayTlsProbeFailure import ai.openclaw.app.gateway.GatewayTlsProbeResult @@ -116,6 +117,7 @@ import java.util.concurrent.atomic.AtomicReference private const val MAX_PENDING_NOTIFICATION_EVENTS = 128 private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L +private const val CRON_RUN_TRACKING_POLL_MS = 2_000L private const val OperatorAdminScope = "operator.admin" private enum class SkillWorkshopGatewayAction( @@ -374,6 +376,13 @@ class NodeRuntime private constructor( val generation: Long, ) + private data class CronActionResult( + val message: String, + val kind: GatewayCronNoticeKind, + val refresh: Boolean, + val deleted: Boolean = false, + ) + constructor( context: Context, prefs: SecurePrefs = SecurePrefs(context.applicationContext), @@ -715,7 +724,17 @@ class NodeRuntime private constructor( val cronErrorText: StateFlow = _cronErrorText.asStateFlow() private val _cronJobDetailState = MutableStateFlow(GatewayCronJobDetailState.Idle) val cronJobDetailState: StateFlow = _cronJobDetailState.asStateFlow() + private val _cronRunHistoryState = MutableStateFlow(GatewayCronRunHistoryState.Idle) + val cronRunHistoryState: StateFlow = _cronRunHistoryState.asStateFlow() + private val _cronActionState = MutableStateFlow(GatewayCronActionState.Idle) + val cronActionState: StateFlow = _cronActionState.asStateFlow() + private val _pendingCronRunJobIds = MutableStateFlow>(emptySet()) + val pendingCronRunJobIds: StateFlow> = _pendingCronRunJobIds.asStateFlow() private val cronJobDetailRequestGuard = CronJobDetailRequestGuard() + private val cronRunHistoryRequestGuard = CronJobDetailRequestGuard() + private val cronRefreshGuard = LatestGatewayRefreshGuard() + private val cronActionMutex = Mutex() + private val pendingCronRunRegistry = PendingCronRunRegistry() private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) val usageSummary: StateFlow = _usageSummary.asStateFlow() private val _usageRefreshing = MutableStateFlow(false) @@ -756,7 +775,7 @@ class NodeRuntime private constructor( val nodesDevicesRefreshing: StateFlow = _nodesDevicesRefreshing.asStateFlow() private val _nodesDevicesErrorText = MutableStateFlow(null) val nodesDevicesErrorText: StateFlow = _nodesDevicesErrorText.asStateFlow() - private val nodeApprovalRefreshGuard = GatewayNodeApprovalRefreshGuard() + private val nodeApprovalRefreshGuard = LatestGatewayRefreshGuard() private val _execApprovals = MutableStateFlow>(emptyList()) val execApprovals: StateFlow> = _execApprovals.asStateFlow() private val _execApprovalsRefreshing = MutableStateFlow(false) @@ -848,7 +867,7 @@ class NodeRuntime private constructor( } }, onDisconnected = { message -> - clearOperatorGatewayState() + clearOperatorGatewayState(retirePendingCronRuns = false) chat.applyMainSessionKey(resolveMainSessionKey()) chat.onDisconnected(message) updateStatus { @@ -869,7 +888,7 @@ class NodeRuntime private constructor( customHeadersProvider = prefs::loadGatewayCustomHeaders, ) - private fun clearOperatorGatewayState() { + private fun clearOperatorGatewayState(retirePendingCronRuns: Boolean) { invalidateNodeCapabilityApprovalState() _serverName.value = null _remoteAddress.value = null @@ -885,11 +904,17 @@ class NodeRuntime private constructor( _modelCatalogRefreshing.value = false _modelCatalogErrorText.value = null _talkSetupReadiness.value = GatewayTalkSetupReadiness.unverified() + cronRefreshGuard.invalidate() _cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null) _cronJobs.value = emptyList() _cronRefreshing.value = false _cronErrorText.value = null cronJobDetailRequestGuard.cancel { _cronJobDetailState.value = GatewayCronJobDetailState.Idle } + cronRunHistoryRequestGuard.cancel { _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle } + _cronActionState.value = GatewayCronActionState.Idle + if (retirePendingCronRuns) { + pendingCronRunRegistry.clear { _pendingCronRunJobIds.value = it } + } _usageSummary.value = GatewayUsageSummary(updatedAtMs = null, providers = emptyList()) _usageRefreshing.value = false _usageErrorText.value = null @@ -1408,17 +1433,174 @@ class NodeRuntime private constructor( } fun loadCronJobDetail(id: String) { - val request = cronJobDetailRequestGuard.begin(id) ?: return - _cronJobDetailState.value = GatewayCronJobDetailState.Loading(request.id) - scope.launch { - loadCronJobDetailFromGateway(request) + val detailRequest = cronJobDetailRequestGuard.begin(id) ?: return + val historyRequest = cronRunHistoryRequestGuard.begin(detailRequest.id) ?: return + _cronJobDetailState.value = GatewayCronJobDetailState.Loading(detailRequest.id) + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(historyRequest.id) + if (mode == NodeRuntimeMode.ScreenshotFixture) { + applyScreenshotCronDetail(detailRequest = detailRequest, historyRequest = historyRequest) + return } + scope.launch { loadCronJobDetailFromGateway(detailRequest) } + scope.launch { loadCronRunHistoryFromGateway(historyRequest) } + } + + fun refreshCronRunHistory(id: String) { + val request = cronRunHistoryRequestGuard.begin(id) ?: return + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id) + if (mode == NodeRuntimeMode.ScreenshotFixture) { + publishScreenshotCronHistory(request) + return + } + scope.launch { loadCronRunHistoryFromGateway(request) } } fun clearCronJobDetail() { cronJobDetailRequestGuard.cancel { _cronJobDetailState.value = GatewayCronJobDetailState.Idle } + cronRunHistoryRequestGuard.cancel { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle + } + } + + fun dismissCronActionNotice(id: String) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + val notice = _cronActionState.value as? GatewayCronActionState.Notice + if (notice?.id == jobId) { + _cronActionState.value = GatewayCronActionState.Idle + } + } + + fun runCronJob(id: String) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + if (pendingCronRunRegistry.contains(jobId)) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = "This cron job already has a queued run.", + kind = GatewayCronNoticeKind.Warning, + ) + return + } + launchCronAction(id = jobId, action = GatewayCronAction.Run) { gatewayScope, actionJobId -> + val response = + requestGatewayData( + gatewayScope, + "cron.run", + buildJsonObject { + put("id", JsonPrimitive(actionJobId)) + put("mode", JsonPrimitive("force")) + }.toString(), + ) + when (val outcome = parseGatewayCronRunOutcome(json.parseToJsonElement(response).asObjectOrNull())) { + is GatewayCronRunOutcome.Started -> { + outcome.runId?.let { runId -> + var trackingStarted = false + publishGatewayData(gatewayScope) { + trackingStarted = + pendingCronRunRegistry.begin(actionJobId, runId) { + _pendingCronRunJobIds.value = it + } + } + if (trackingStarted) { + trackQueuedCronRun(gatewayScope = gatewayScope, jobId = actionJobId, runId = runId) + } + } + CronActionResult( + message = if (outcome.runId == null) "Cron job started." else "Cron run queued.", + kind = GatewayCronNoticeKind.Success, + refresh = cronRunShouldRefresh(outcome), + ) + } + is GatewayCronRunOutcome.Skipped -> + CronActionResult( + message = outcome.reason.message, + kind = GatewayCronNoticeKind.Warning, + refresh = cronRunShouldRefresh(outcome), + ) + GatewayCronRunOutcome.Rejected -> + CronActionResult( + message = "Gateway rejected the cron run.", + kind = GatewayCronNoticeKind.Error, + refresh = false, + ) + null -> error("Gateway returned an invalid cron run result.") + } + } + } + + fun setCronJobEnabled( + id: String, + enabled: Boolean, + ) { + launchCronAction( + id = id, + action = if (enabled) GatewayCronAction.Enable else GatewayCronAction.Disable, + ) { gatewayScope, jobId -> + requestGatewayData( + gatewayScope, + "cron.update", + buildJsonObject { + put("id", JsonPrimitive(jobId)) + put( + "patch", + buildJsonObject { + put("enabled", JsonPrimitive(enabled)) + }, + ) + }.toString(), + ) + CronActionResult( + message = if (enabled) "Cron job enabled." else "Cron job disabled.", + kind = GatewayCronNoticeKind.Success, + refresh = true, + ) + } + } + + fun updateCronJob( + original: GatewayCronJobDetail, + edit: GatewayCronJobEdit, + ) { + launchCronAction(id = original.id, action = GatewayCronAction.Save) { gatewayScope, _ -> + try { + requestGatewayData( + gatewayScope, + "cron.update", + buildCronUpdateParams(original = original, edit = edit), + ) + } catch (err: GatewayRequestRejected) { + if (!isCronJobRevisionConflict(err.gatewayError)) throw err + reloadCronJobIfSelected(original.id) + return@launchCronAction CronActionResult( + message = "This cron job changed on the gateway. Review the latest version before saving again.", + kind = GatewayCronNoticeKind.Warning, + refresh = false, + ) + } + CronActionResult( + message = "Cron job updated.", + kind = GatewayCronNoticeKind.Success, + refresh = true, + ) + } + } + + fun deleteCronJob(id: String) { + launchCronAction(id = id, action = GatewayCronAction.Delete) { gatewayScope, jobId -> + requestGatewayData( + gatewayScope, + "cron.remove", + buildJsonObject { put("id", JsonPrimitive(jobId)) }.toString(), + ) + CronActionResult( + message = "Cron job deleted.", + kind = GatewayCronNoticeKind.Success, + refresh = true, + deleted = true, + ) + } } fun refreshUsage() { @@ -1729,9 +1911,11 @@ class NodeRuntime private constructor( _cronStatus.value = GatewayCronStatus( enabled = true, - jobs = 2, + jobs = 1, nextWakeAtMs = 1_783_641_600_000, ) + _cronJobs.value = parseScreenshotCronJobs() + _operatorScopes.value = listOf(OperatorAdminScope) _nodesDevicesSummary.value = AndroidScreenshotFixture.nodes _channelsSummary.value = AndroidScreenshotFixture.channels _nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Approved @@ -1748,6 +1932,44 @@ class NodeRuntime private constructor( chat.refreshSessions(limit = 20) } + private fun parseScreenshotCronJobs(): List { + // Screenshot mode parses gateway-shaped fixtures so UI navigation covers the live data contract. + val list = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null)) + .asObjectOrNull() + return parseCronJobs(list?.get("jobs") as? JsonArray) + } + + private fun applyScreenshotCronDetail( + detailRequest: CronJobDetailRequest, + historyRequest: CronJobDetailRequest, + ) { + val detail = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.get", cronJobGetParams(detailRequest.id))) + .asObjectOrNull() + ?.let(::parseGatewayCronJobDetail) + ?.takeIf { it.id == detailRequest.id } + cronJobDetailRequestGuard.publishIfCurrent(detailRequest) { + _cronJobDetailState.value = + detail?.let(GatewayCronJobDetailState::Loaded) + ?: GatewayCronJobDetailState.Error(detailRequest.id, "Gateway returned an invalid cron job.") + } + publishScreenshotCronHistory(historyRequest) + } + + private fun publishScreenshotCronHistory(request: CronJobDetailRequest) { + val history = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", cronJobGetParams(request.id))) + .asObjectOrNull() + val runs = parseGatewayCronRunHistory(history?.get("entries") as? JsonArray) + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs) + } + } + init { if (mode == NodeRuntimeMode.Live) { if (prefs.voiceWakeMode.value != VoiceWakeMode.Off) { @@ -3126,7 +3348,7 @@ class NodeRuntime private constructor( connectAttemptSeq.incrementAndGet() synchronized(gatewayDataScopeLock) { gatewayDataGeneration += 1 - clearOperatorGatewayState() + clearOperatorGatewayState(retirePendingCronRuns = true) } chat.onGatewayScopeChanging(retireRunState) stopMessageSpeech() @@ -3474,6 +3696,15 @@ class NodeRuntime private constructor( } } + private inline fun publishCronRefresh( + gatewayScope: GatewayDataScope, + refreshGeneration: Long, + crossinline publish: () -> Unit, + ): Boolean = + publishGatewayData(gatewayScope) { + cronRefreshGuard.publishIfCurrent(refreshGeneration) { publish() } + } + private suspend fun refreshBrandingFromGateway() { val gatewayScope = captureGatewayDataScope() ?: return if (!gatewayConnectionDisplay.value.isConnected) return @@ -3611,15 +3842,18 @@ class NodeRuntime private constructor( } private suspend fun refreshCronFromGateway() { + val refreshGeneration = cronRefreshGuard.begin() val gatewayScope = captureGatewayDataScope() ?: return - publishGatewayData(gatewayScope) { + publishCronRefresh(gatewayScope, refreshGeneration) { _cronRefreshing.value = true _cronErrorText.value = null } if (!operatorConnected) { - _cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null) - _cronJobs.value = emptyList() - _cronRefreshing.value = false + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronStatus.value = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null) + _cronJobs.value = emptyList() + _cronRefreshing.value = false + } return } try { @@ -3635,14 +3869,18 @@ class NodeRuntime private constructor( val listRes = requestGatewayData(gatewayScope, "cron.list", """{"includeDisabled":true,"limit":20,"sortBy":"nextRunAtMs","sortDir":"asc"}""") val listRoot = json.parseToJsonElement(listRes).asObjectOrNull() val jobs = parseCronJobs(listRoot?.get("jobs") as? JsonArray) - publishGatewayData(gatewayScope) { + publishCronRefresh(gatewayScope, refreshGeneration) { _cronStatus.value = status _cronJobs.value = jobs } } catch (_: Throwable) { - publishGatewayData(gatewayScope) { _cronErrorText.value = "Could not load cron jobs." } + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronErrorText.value = "Could not load cron jobs." + } } finally { - publishGatewayData(gatewayScope) { _cronRefreshing.value = false } + publishCronRefresh(gatewayScope, refreshGeneration) { + _cronRefreshing.value = false + } } } @@ -3669,6 +3907,230 @@ class NodeRuntime private constructor( } } + private suspend fun loadCronRunHistoryFromGateway(request: CronJobDetailRequest) { + val gatewayScope = captureGatewayDataScope() ?: return + if (!operatorConnected) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = + GatewayCronRunHistoryState.Error( + id = request.id, + message = "Connect the gateway to inspect cron run history.", + ) + } + return + } + try { + val response = + requestGatewayData( + gatewayScope, + "cron.runs", + buildJsonObject { + put("id", JsonPrimitive(request.id)) + put("limit", JsonPrimitive(20)) + put("sortDir", JsonPrimitive("desc")) + }.toString(), + ) + val root = json.parseToJsonElement(response).asObjectOrNull() + val runs = parseGatewayCronRunHistory(root?.get("entries") as? JsonArray) + publishGatewayData(gatewayScope) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loaded(id = request.id, runs = runs) + } + } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + publishGatewayData(gatewayScope) { + cronRunHistoryRequestGuard.publishIfCurrent(request) { + _cronRunHistoryState.value = + GatewayCronRunHistoryState.Error( + id = request.id, + message = "Could not load cron run history.", + ) + } + } + } + } + + private fun launchCronAction( + id: String, + action: GatewayCronAction, + perform: suspend (GatewayDataScope, String) -> CronActionResult, + ) { + val jobId = id.trim().takeIf { it.isNotEmpty() } ?: return + if (!operatorAdminScopeAvailable.value) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = "Cron changes require operator.admin access.", + kind = GatewayCronNoticeKind.Error, + ) + return + } + if (!operatorConnected) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = "Connect the gateway to manage cron jobs.", + kind = GatewayCronNoticeKind.Error, + ) + return + } + if (_cronActionState.value is GatewayCronActionState.Running) return + // One mutating RPC at a time keeps button taps and programmatic calls from racing. + if (!cronActionMutex.tryLock()) { + if (_cronActionState.value !is GatewayCronActionState.Running) { + _cronActionState.value = + GatewayCronActionState.Notice( + id = jobId, + message = "Another cron action is still finishing.", + kind = GatewayCronNoticeKind.Warning, + ) + } + return + } + // Publish ownership before returning to Compose so Activity recreation can + // distinguish a retained Save from dead pending state after process death. + val actionScope = captureGatewayDataScope() + if (actionScope == null) { + cronActionMutex.unlock() + return + } + val started = + publishGatewayData(actionScope) { + _cronActionState.value = GatewayCronActionState.Running(id = jobId, action = action) + } + if (!started) { + cronActionMutex.unlock() + return + } + scope.launch { + var completionState: GatewayCronActionState.Notice? = null + try { + val result = perform(actionScope, jobId) + if (result.deleted) { + clearDeletedCronSelection(jobId) + } + if (result.refresh) { + refreshCronFromGateway() + if (!result.deleted) reloadCronJobIfSelected(jobId) + } + completionState = + GatewayCronActionState.Notice( + id = jobId, + message = result.message, + kind = result.kind, + deleted = result.deleted, + ) + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + val message = err.message?.trim()?.takeIf { it.isNotEmpty() } ?: "Cron action failed." + completionState = + GatewayCronActionState.Notice( + id = jobId, + message = message, + kind = GatewayCronNoticeKind.Error, + ) + } finally { + cronActionMutex.unlock() + val notice = completionState + if (notice != null) { + publishGatewayData(actionScope) { + _cronActionState.value = notice + } + } + } + } + } + + private fun reloadCronJobIfSelected(jobId: String) { + // Ownership checks and loading publication stay under each guard's lock; + // navigation that wins afterward invalidates these requests before publish. + val detailRequest = + cronJobDetailRequestGuard.beginIfCurrent(jobId) { request -> + _cronJobDetailState.value = GatewayCronJobDetailState.Loading(request.id) + } + val historyRequest = + cronRunHistoryRequestGuard.beginIfCurrent(jobId) { request -> + _cronRunHistoryState.value = GatewayCronRunHistoryState.Loading(request.id) + } + detailRequest?.let { scope.launch { loadCronJobDetailFromGateway(it) } } + historyRequest?.let { scope.launch { loadCronRunHistoryFromGateway(it) } } + } + + private fun clearDeletedCronSelection(jobId: String) { + // A completed delete can race navigation to another job. Clear only state + // still owned by the deleted id so the newer detail/history survives. + cronJobDetailRequestGuard.cancelIfCurrent(jobId) { + _cronJobDetailState.value = GatewayCronJobDetailState.Idle + } + cronRunHistoryRequestGuard.cancelIfCurrent(jobId) { + _cronRunHistoryState.value = GatewayCronRunHistoryState.Idle + } + } + + private fun trackQueuedCronRun( + gatewayScope: GatewayDataScope, + jobId: String, + runId: String, + ) { + // cron.run acknowledges before lane admission. Track its exact run-log id + // so only this job stays deduped until terminal evidence or scope retirement. + scope.launch { + var completedRun: GatewayCronRunSummary? = null + while (isGatewayDataScopeCurrent(gatewayScope) && completedRun == null) { + completedRun = + try { + val response = + requestGatewayData( + gatewayScope, + "cron.runs", + buildJsonObject { + put("id", JsonPrimitive(jobId)) + put("runId", JsonPrimitive(runId)) + put("limit", JsonPrimitive(1)) + put("sortDir", JsonPrimitive("desc")) + }.toString(), + ) + val root = json.parseToJsonElement(response).asObjectOrNull() + parseGatewayCronRunHistory(root?.get("entries") as? JsonArray) + .firstOrNull { it.runId == runId } + } catch (err: CancellationException) { + throw err + } catch (_: Throwable) { + if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch + null + } + if (completedRun == null) delay(CRON_RUN_TRACKING_POLL_MS) + } + if (!isGatewayDataScopeCurrent(gatewayScope)) return@launch + val terminalRun = completedRun ?: return@launch + + var pendingCleared = false + val scopeCurrent = + publishGatewayData(gatewayScope) { + pendingCleared = + pendingCronRunRegistry.finish(jobId, runId) { + _pendingCronRunJobIds.value = it + } + } + if (!scopeCurrent || !pendingCleared) return@launch + + refreshCronFromGateway() + reloadCronJobIfSelected(jobId) + publishGatewayData(gatewayScope) { + val currentAction = _cronActionState.value + val canPublish = + currentAction == GatewayCronActionState.Idle || + (currentAction is GatewayCronActionState.Notice && currentAction.id == jobId) + if (canPublish) { + _cronActionState.value = cronRunCompletionNotice(jobId, terminalRun.status) + } + } + } + } + private suspend fun refreshUsageFromGateway() { val gatewayScope = captureGatewayDataScope() ?: return publishGatewayData(gatewayScope) { @@ -5339,8 +5801,8 @@ internal fun GatewayNodeCapabilityApproval.withoutExactRequestId(): GatewayNodeC internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds(): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null) }) -/** Prevents older node.list responses from overwriting newer approval state. */ -internal class GatewayNodeApprovalRefreshGuard { +/** Prevents an older gateway response from publishing after a newer refresh begins. */ +internal class LatestGatewayRefreshGuard { private val lock = Any() private var generation = 0L @@ -5350,6 +5812,10 @@ internal class GatewayNodeApprovalRefreshGuard { generation } + fun invalidate() { + begin() + } + fun publishIfCurrent( refreshGeneration: Long, publish: () -> Unit, diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt new file mode 100644 index 000000000000..4510833e2695 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/CronJobManagementPanel.kt @@ -0,0 +1,642 @@ +package ai.openclaw.app.ui + +import ai.openclaw.app.CronEditorDraftState +import ai.openclaw.app.GatewayCronActionState +import ai.openclaw.app.GatewayCronJobDetail +import ai.openclaw.app.GatewayCronJobEdit +import ai.openclaw.app.GatewayCronNoticeKind +import ai.openclaw.app.GatewayCronPayloadEdit +import ai.openclaw.app.GatewayCronRunHistoryState +import ai.openclaw.app.GatewayCronRunSummary +import ai.openclaw.app.GatewayCronScheduleEdit +import ai.openclaw.app.ui.design.ClawDetailRow +import ai.openclaw.app.ui.design.ClawIconBadge +import ai.openclaw.app.ui.design.ClawListPanel +import ai.openclaw.app.ui.design.ClawPanel +import ai.openclaw.app.ui.design.ClawPrimaryButton +import ai.openclaw.app.ui.design.ClawSecondaryButton +import ai.openclaw.app.ui.design.ClawSegmentedControl +import ai.openclaw.app.ui.design.ClawStatus +import ai.openclaw.app.ui.design.ClawStatusPill +import ai.openclaw.app.ui.design.ClawTextField +import ai.openclaw.app.ui.design.ClawTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import java.text.DateFormat +import java.util.Date + +@Composable +internal fun CronJobManagementPanel( + job: GatewayCronJobDetail, + editorDraft: CronEditorDraftState, + onEditorDraftChange: (CronEditorDraftState) -> Unit, + historyState: GatewayCronRunHistoryState, + actionState: GatewayCronActionState, + runPending: Boolean, + operatorAdminScopeAvailable: Boolean, + onRun: () -> Unit, + onToggleEnabled: () -> Unit, + onSave: (GatewayCronJobEdit) -> Unit, + onRefreshHistory: () -> Unit, + onDelete: () -> Unit, +) { + val busy = actionState is GatewayCronActionState.Running + val notice = (actionState as? GatewayCronActionState.Notice)?.takeIf { it.id == job.id } + var showDeleteConfirmation by remember(job.id) { mutableStateOf(false) } + + if (showDeleteConfirmation) { + AlertDialog( + onDismissRequest = { showDeleteConfirmation = false }, + confirmButton = { + TextButton( + onClick = { + showDeleteConfirmation = false + onDelete() + }, + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirmation = false }) { + Text("Cancel") + } + }, + title = { Text("Delete cron job?") }, + text = { Text("This permanently removes the scheduled job from the gateway.") }, + ) + } + + notice?.let { value -> + ClawPanel { + Text( + text = value.message, + style = ClawTheme.type.body, + color = + when (value.kind) { + GatewayCronNoticeKind.Success -> ClawTheme.colors.success + GatewayCronNoticeKind.Warning -> ClawTheme.colors.warning + GatewayCronNoticeKind.Error -> ClawTheme.colors.danger + }, + ) + } + } + + if (!operatorAdminScopeAvailable) CronAdminAccessPanel() + if (editorDraft.requiresResolution) { + ClawPanel { + Text( + text = + if (editorDraft.hasIncomingConflict) { + "This job changed while you were editing. Revert to the latest gateway version before saving." + } else { + "Save or revert your edits before running, enabling, disabling, deleting, or refreshing this job." + }, + style = ClawTheme.type.body, + color = ClawTheme.colors.warning, + ) + } + } + + CronActionPanel( + job = job, + enabled = operatorAdminScopeAvailable && !busy && !editorDraft.requiresResolution, + busy = busy, + runPending = runPending, + onRun = onRun, + onToggleEnabled = onToggleEnabled, + onDelete = { showDeleteConfirmation = true }, + ) + CronEditorPanel( + job = job, + draft = editorDraft, + onDraftChange = onEditorDraftChange, + enabled = + operatorAdminScopeAvailable && + !busy && + !editorDraft.savePending && + !editorDraft.saveSucceeded, + canRevert = !busy && !editorDraft.savePending && !editorDraft.saveSucceeded, + busy = busy, + onSave = onSave, + ) + CronRunHistoryPanel( + jobId = job.id, + state = historyState, + onRefresh = onRefreshHistory, + ) +} + +@Composable +private fun CronAdminAccessPanel() { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(7.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(17.dp), + tint = ClawTheme.colors.text, + ) + Text(text = "Admin access required", style = ClawTheme.type.section, color = ClawTheme.colors.text) + } + Text( + text = + "Cron changes require operator.admin. Setup codes intentionally do not grant it. " + + "Reconnect with the gateway's shared token or password to request admin access. " + + "If this device still lacks it, approve the pending scope upgrade from an existing admin client.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + } + } +} + +@Composable +private fun CronActionPanel( + job: GatewayCronJobDetail, + enabled: Boolean, + busy: Boolean, + runPending: Boolean, + onRun: () -> Unit, + onToggleEnabled: () -> Unit, + onDelete: () -> Unit, +) { + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ClawPrimaryButton( + text = + when { + busy -> "Working" + runPending -> "Run Pending" + else -> "Run Now" + }, + onClick = onRun, + modifier = Modifier.weight(1f), + enabled = enabled && !runPending, + icon = Icons.Default.PlayArrow, + ) + ClawSecondaryButton( + text = if (job.enabled) "Disable" else "Enable", + onClick = onToggleEnabled, + modifier = Modifier.weight(1f), + enabled = enabled, + icon = if (job.enabled) Icons.Default.Pause else Icons.Default.PlayArrow, + ) + } + ClawSecondaryButton( + text = "Delete Job", + onClick = onDelete, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + icon = Icons.Default.Delete, + ) + } + } +} + +@Composable +private fun CronEditorPanel( + job: GatewayCronJobDetail, + draft: CronEditorDraftState, + onDraftChange: (CronEditorDraftState) -> Unit, + enabled: Boolean, + canRevert: Boolean, + busy: Boolean, + onSave: (GatewayCronJobEdit) -> Unit, +) { + val edit = draft.edit + ClawPanel { + Column(verticalArrangement = Arrangement.spacedBy(9.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + modifier = Modifier.size(17.dp), + tint = ClawTheme.colors.text, + ) + Text(text = "Edit Job", style = ClawTheme.type.section, color = ClawTheme.colors.text) + } + CronSwitchRow( + title = "Enabled", + subtitle = "Allow the scheduler to run this job.", + checked = edit.enabled, + onCheckedChange = { onDraftChange(draft.withEdit(edit.copy(enabled = it))) }, + enabled = enabled, + ) + if (edit.schedule is GatewayCronScheduleEdit.At) { + CronSwitchRow( + title = "Delete after run", + subtitle = "Remove this job after a successful one-shot run.", + checked = edit.deleteAfterRun, + onCheckedChange = { onDraftChange(draft.withEdit(edit.copy(deleteAfterRun = it))) }, + enabled = enabled, + ) + } + ClawTextField( + value = edit.name, + onValueChange = { onDraftChange(draft.withEdit(edit.copy(name = it))) }, + placeholder = "Job name", + label = "Name", + enabled = enabled, + ) + ClawTextField( + value = edit.description, + onValueChange = { onDraftChange(draft.withEdit(edit.copy(description = it))) }, + placeholder = "Optional description", + label = "Description", + enabled = enabled, + minLines = 2, + ) + CronScheduleEditor( + schedule = edit.schedule, + enabled = enabled, + onChange = { onDraftChange(draft.withEdit(edit.withSchedule(it))) }, + ) + ClawTextField( + value = edit.sessionTarget, + onValueChange = { onDraftChange(draft.withEdit(edit.copy(sessionTarget = it))) }, + placeholder = "main, isolated, current, or session:", + label = "Session target", + enabled = enabled, + ) + ClawSegmentedControl( + options = listOf("next-heartbeat", "now"), + selected = edit.wakeMode, + onSelect = { onDraftChange(draft.withEdit(edit.copy(wakeMode = it))) }, + modifier = Modifier.fillMaxWidth(), + enabledOptions = if (enabled) setOf("next-heartbeat", "now") else emptySet(), + ) + CronPayloadEditor( + payload = edit.payload, + originalCommandCwd = job.payloadCommandCwd, + enabled = enabled, + onChange = { onDraftChange(draft.withEdit(edit.copy(payload = it))) }, + ) + ClawPrimaryButton( + text = if (busy) "Working" else "Save Changes", + onClick = { + onDraftChange(draft.saveStarted()) + onSave(edit) + }, + modifier = Modifier.fillMaxWidth(), + enabled = + enabled && + draft.isDirty && + !draft.hasIncomingConflict && + !draft.savePending && + !draft.saveSucceeded, + icon = Icons.Default.Save, + ) + if (draft.requiresResolution) { + ClawSecondaryButton( + text = "Revert Changes", + onClick = { onDraftChange(CronEditorDraftState.from(job)) }, + modifier = Modifier.fillMaxWidth(), + enabled = canRevert, + ) + } + } + } +} + +@Composable +private fun CronSwitchRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth().heightIn(min = 50.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(9.dp), + ) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text(text = title, style = ClawTheme.type.body, color = ClawTheme.colors.text) + Text( + text = subtitle, + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + modifier = Modifier.semantics { contentDescription = title }, + ) + } +} + +@Composable +private fun CronScheduleEditor( + schedule: GatewayCronScheduleEdit, + enabled: Boolean, + onChange: (GatewayCronScheduleEdit) -> Unit, +) { + Text( + text = "Schedule · ${cronScheduleKindLabel(schedule)}", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + when (schedule) { + is GatewayCronScheduleEdit.At -> + ClawTextField( + value = schedule.at, + onValueChange = { onChange(schedule.copy(at = it)) }, + placeholder = "ISO time, e.g. 2026-07-09T09:30:00Z", + label = "Run at", + enabled = enabled, + ) + is GatewayCronScheduleEdit.Every -> { + ClawTextField( + value = schedule.everyMs, + onValueChange = { onChange(schedule.copy(everyMs = it.filter(Char::isDigit))) }, + placeholder = "Milliseconds", + label = "Interval", + enabled = enabled, + ) + ClawTextField( + value = schedule.anchorMs, + onValueChange = { onChange(schedule.copy(anchorMs = it.filter(Char::isDigit))) }, + placeholder = "Epoch milliseconds (optional)", + label = "Anchor", + enabled = enabled, + ) + } + is GatewayCronScheduleEdit.Cron -> { + ClawTextField( + value = schedule.expression, + onValueChange = { onChange(schedule.copy(expression = it)) }, + placeholder = "Cron expression, e.g. 0 9 * * *", + label = "Expression", + enabled = enabled, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ClawTextField( + value = schedule.timezone, + onValueChange = { onChange(schedule.copy(timezone = it)) }, + placeholder = "e.g. America/New_York", + label = "Timezone", + enabled = enabled, + modifier = Modifier.weight(1f), + ) + ClawTextField( + value = schedule.staggerMs, + onValueChange = { onChange(schedule.copy(staggerMs = it.filter(Char::isDigit))) }, + placeholder = "0 = exact", + label = "Stagger ms", + enabled = enabled, + modifier = Modifier.weight(1f), + ) + } + } + is GatewayCronScheduleEdit.OnExit -> { + ClawTextField( + value = schedule.command, + onValueChange = { onChange(schedule.copy(command = it)) }, + placeholder = "Command to watch", + label = "Command", + enabled = enabled, + ) + ClawTextField( + value = schedule.cwd, + onValueChange = { onChange(schedule.copy(cwd = it)) }, + placeholder = "Optional path", + label = "Working directory", + enabled = enabled, + ) + } + } +} + +@Composable +private fun CronPayloadEditor( + payload: GatewayCronPayloadEdit, + originalCommandCwd: String?, + enabled: Boolean, + onChange: (GatewayCronPayloadEdit) -> Unit, +) { + Text( + text = "Payload · ${cronPayloadKindLabel(payload)}", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + when (payload) { + is GatewayCronPayloadEdit.SystemEvent -> + ClawTextField( + value = payload.text, + onValueChange = { onChange(payload.copy(text = it)) }, + placeholder = "System event text", + label = "Event text", + enabled = enabled, + minLines = 3, + ) + is GatewayCronPayloadEdit.AgentTurn -> { + ClawTextField( + value = payload.message, + onValueChange = { onChange(payload.copy(message = it)) }, + placeholder = "Agent message", + label = "Message", + enabled = enabled, + minLines = 3, + ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ClawTextField( + value = payload.model, + onValueChange = { onChange(payload.copy(model = it)) }, + placeholder = "Optional override", + label = "Model", + enabled = enabled, + modifier = Modifier.weight(1f), + ) + ClawTextField( + value = payload.thinking, + onValueChange = { onChange(payload.copy(thinking = it)) }, + placeholder = "Optional override", + label = "Thinking", + enabled = enabled, + modifier = Modifier.weight(1f), + ) + } + } + is GatewayCronPayloadEdit.Command -> { + val commandCwdCanBeCleared = originalCommandCwd == null + ClawTextField( + value = payload.argvJson, + onValueChange = { onChange(payload.copy(argvJson = it)) }, + placeholder = "Command argv JSON array", + label = "Arguments", + enabled = enabled, + minLines = 2, + ) + ClawTextField( + value = payload.cwd, + onValueChange = { value -> + if (commandCwdCanBeCleared || value.isNotBlank()) { + onChange(payload.copy(cwd = value)) + } + }, + placeholder = "Optional path", + label = + if (commandCwdCanBeCleared) { + "Command working directory" + } else { + "Command working directory · cannot clear" + }, + enabled = enabled, + ) + if (!commandCwdCanBeCleared) { + Text( + text = "The gateway can change this path but cannot clear an existing path.", + style = ClawTheme.type.caption, + color = ClawTheme.colors.textMuted, + ) + } + } + } +} + +@Composable +private fun CronRunHistoryPanel( + jobId: String, + state: GatewayCronRunHistoryState, + onRefresh: () -> Unit, +) { + val loading = (state as? GatewayCronRunHistoryState.Loading)?.id == jobId + val runs = (state as? GatewayCronRunHistoryState.Loaded)?.takeIf { it.id == jobId }?.runs.orEmpty() + val error = (state as? GatewayCronRunHistoryState.Error)?.takeIf { it.id == jobId }?.message + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + modifier = Modifier.size(17.dp), + tint = ClawTheme.colors.text, + ) + Text( + text = "Recent Runs", + style = ClawTheme.type.section, + color = ClawTheme.colors.text, + modifier = Modifier.weight(1f), + ) + ClawSecondaryButton( + text = if (loading) "Loading" else "Reload", + onClick = onRefresh, + enabled = !loading, + icon = Icons.Default.Refresh, + ) + } + when { + error != null -> + ClawPanel { + Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning) + } + runs.isEmpty() -> + ClawPanel { + Text( + text = if (loading) "Loading recent runs…" else "No recent runs yet.", + style = ClawTheme.type.body, + color = ClawTheme.colors.textMuted, + ) + } + else -> ClawListPanel(items = runs) { run -> CronRunHistoryRow(run) } + } +} + +@Composable +private fun CronRunHistoryRow(run: GatewayCronRunSummary) { + val status = cronRunStatus(run.status) + ClawDetailRow( + title = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(run.ts)), + subtitle = cronRunSubtitle(run), + leading = { ClawIconBadge(icon = Icons.Default.Schedule) }, + trailing = { ClawStatusPill(text = cronRunStatusText(run.status), status = status) }, + ) +} + +private fun cronScheduleKindLabel(schedule: GatewayCronScheduleEdit): String = + when (schedule) { + is GatewayCronScheduleEdit.At -> "One time" + is GatewayCronScheduleEdit.Every -> "Interval" + is GatewayCronScheduleEdit.Cron -> "Cron" + is GatewayCronScheduleEdit.OnExit -> "On command exit" + } + +private fun cronPayloadKindLabel(payload: GatewayCronPayloadEdit): String = + when (payload) { + is GatewayCronPayloadEdit.SystemEvent -> "System event" + is GatewayCronPayloadEdit.AgentTurn -> "Agent turn" + is GatewayCronPayloadEdit.Command -> "Command" + } + +private fun cronRunSubtitle(run: GatewayCronRunSummary): String = + listOfNotNull( + run.durationMs?.let { "${it}ms" }, + run.deliveryStatus, + run.model, + run.error ?: run.summary, + ).joinToString(" · ").ifBlank { "No details" } + +private fun cronRunStatusText(status: String?): String = + when (status?.lowercase()) { + "ok" -> "OK" + "error" -> "Issue" + "skipped" -> "Skipped" + else -> "Unknown" + } + +private fun cronRunStatus(status: String?): ClawStatus = + when (status?.lowercase()) { + "ok" -> ClawStatus.Success + "error" -> ClawStatus.Danger + "skipped" -> ClawStatus.Warning + else -> ClawStatus.Neutral + } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index 82ba78e1b511..b259d2a3e44a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -4,12 +4,16 @@ import ai.openclaw.app.AndroidLicenseNotice import ai.openclaw.app.AppLanguage import ai.openclaw.app.AppearanceThemeMode import ai.openclaw.app.BuildConfig +import ai.openclaw.app.CronEditorDraftState import ai.openclaw.app.GatewayAgentSummary import ai.openclaw.app.GatewayConnectionDisplay import ai.openclaw.app.GatewayConnectionProblem +import ai.openclaw.app.GatewayCronActionState import ai.openclaw.app.GatewayCronJobDetail import ai.openclaw.app.GatewayCronJobDetailState +import ai.openclaw.app.GatewayCronJobEdit import ai.openclaw.app.GatewayCronJobSummary +import ai.openclaw.app.GatewayCronRunHistoryState import ai.openclaw.app.GatewayExecApprovalSummary import ai.openclaw.app.GatewayTalkSetupReadiness import ai.openclaw.app.GatewayTalkSetupState @@ -31,6 +35,7 @@ import ai.openclaw.app.loadAndroidLicenseNotices import ai.openclaw.app.locationModeAfterBackgroundSettings import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.photoReadPermissionsForRequest +import ai.openclaw.app.reconcileRestoredAction import ai.openclaw.app.setAppLanguage import ai.openclaw.app.ui.design.ClawDetailRow import ai.openclaw.app.ui.design.ClawIconBadge @@ -66,6 +71,7 @@ import android.os.Looper import android.provider.Settings import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke @@ -299,7 +305,7 @@ private fun CronJobsSettingsScreen( ) ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.fillMaxWidth()) ClawPanel { - Text(text = "Android shows scheduled work status. Create and edit schedules from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = "Open a job to inspect its configuration and run history. Admin-scoped connections can also run, edit, enable, disable, or delete it.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } cronErrorText?.let { errorText -> ClawPanel { @@ -315,7 +321,7 @@ private fun CronJobsSettingsScreen( ClawPanel { Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text) - Text(text = "Create recurring OpenClaw work from the desktop app.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = "Scheduled work created on the gateway will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } } else -> CronJobsPanel(jobs = cronJobs, onJobClick = { selectedJobId = it.id }) @@ -330,13 +336,29 @@ private fun CronJobDetailSettingsScreen( jobName: String?, onBack: () -> Unit, ) { - BackHandler(onBack = onBack) + fun leaveDetail() { + viewModel.cronEditorDraftMemory.clear(jobId) + viewModel.dismissCronActionNotice(jobId) + onBack() + } + BackHandler(onBack = ::leaveDetail) val detailState by viewModel.cronJobDetailState.collectAsState() + val historyState by viewModel.cronRunHistoryState.collectAsState() + val actionState by viewModel.cronActionState.collectAsState() + val pendingCronRunJobIds by viewModel.pendingCronRunJobIds.collectAsState() + val operatorAdminScopeAvailable by viewModel.operatorAdminScopeAvailable.collectAsState() val isConnected by viewModel.isConnected.collectAsState() + val activity = LocalActivity.current - DisposableEffect(viewModel, jobId) { - onDispose { viewModel.clearCronJobDetail() } + DisposableEffect(activity, viewModel, jobId) { + onDispose { + viewModel.clearCronJobDetail() + if (cronDetailDisposalClearsTransientState(activity?.isChangingConfigurations == true)) { + viewModel.cronEditorDraftMemory.clear(jobId) + viewModel.dismissCronActionNotice(jobId) + } + } } LaunchedEffect(isConnected, jobId) { @@ -346,18 +368,75 @@ private fun CronJobDetailSettingsScreen( } val current = (detailState as? GatewayCronJobDetailState.Loaded)?.job?.takeIf { it.id == jobId } + var editorDraft by remember(viewModel, jobId) { + mutableStateOf(viewModel.cronEditorDraftMemory.get(jobId)) + } + var restoredDraftNeedsActionCheck by remember(viewModel, jobId) { + mutableStateOf(editorDraft?.savePending == true) + } + + fun updateEditorDraft(value: CronEditorDraftState?) { + editorDraft = value + viewModel.cronEditorDraftMemory.set(jobId, value) + } + LaunchedEffect(isConnected, actionState, restoredDraftNeedsActionCheck) { + if (restoredDraftNeedsActionCheck) { + updateEditorDraft( + editorDraft?.reconcileRestoredAction( + isConnected = isConnected, + jobId = jobId, + actionState = actionState, + ), + ) + restoredDraftNeedsActionCheck = false + } + } + LaunchedEffect(isConnected) { + if (!isConnected) updateEditorDraft(editorDraft?.saveAborted()) + } + LaunchedEffect(current) { + current?.let { job -> + updateEditorDraft(editorDraft?.observeJob(job) ?: CronEditorDraftState.from(job)) + } + } + LaunchedEffect(actionState, current) { + val notice = actionState as? GatewayCronActionState.Notice + if (notice?.id == jobId) { + val observed = editorDraft?.observeSaveNotice(notice.kind) + updateEditorDraft( + current?.let { job -> + observed?.observeJob(job) ?: CronEditorDraftState.from(job) + } ?: observed, + ) + } + } val loading = (detailState as? GatewayCronJobDetailState.Loading)?.id == jobId val errorText = (detailState as? GatewayCronJobDetailState.Error)?.takeIf { it.id == jobId }?.message + val deleted = + (actionState as? GatewayCronActionState.Notice) + ?.takeIf { it.id == jobId } + ?.deleted == true + + LaunchedEffect(deleted) { + if (deleted) leaveDetail() + } SettingsDetailFrame( title = current?.name ?: jobName ?: "Cron Job", subtitle = "Inspect scheduled gateway work.", icon = Icons.Default.Bolt, - onBack = onBack, + onBack = ::leaveDetail, ) { ClawSecondaryButton( text = if (loading) "Refreshing" else "Refresh", onClick = { viewModel.loadCronJobDetail(jobId) }, - enabled = isConnected && !loading, + enabled = + cronDetailRefreshEnabled( + isConnected = isConnected, + loading = loading, + hasCurrentJob = current != null, + draftRequiresResolution = editorDraft?.requiresResolution == true, + saveSucceeded = editorDraft?.saveSucceeded == true, + ), modifier = Modifier.fillMaxWidth(), ) @@ -374,11 +453,40 @@ private fun CronJobDetailSettingsScreen( ClawPanel { Text(text = if (loading) "Loading cron job…" else "Cron job not loaded.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } - else -> CronJobDetailPanel(current) + else -> + CronJobDetailPanel( + job = current, + editorDraft = editorDraft ?: CronEditorDraftState.from(current), + onEditorDraftChange = ::updateEditorDraft, + historyState = historyState, + actionState = actionState, + runPending = jobId in pendingCronRunJobIds, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + onRun = { viewModel.runCronJob(current.id) }, + onToggleEnabled = { + viewModel.setCronJobEnabled(id = current.id, enabled = !current.enabled) + }, + onSave = { edit -> viewModel.updateCronJob(original = current, edit = edit) }, + onRefreshHistory = { viewModel.refreshCronRunHistory(current.id) }, + onDelete = { viewModel.deleteCronJob(current.id) }, + ) } } } +internal fun cronDetailRefreshEnabled( + isConnected: Boolean, + loading: Boolean, + hasCurrentJob: Boolean, + draftRequiresResolution: Boolean, + saveSucceeded: Boolean, +): Boolean = + isConnected && + !loading && + (!hasCurrentJob || !draftRequiresResolution || saveSucceeded) + +internal fun cronDetailDisposalClearsTransientState(isChangingConfigurations: Boolean): Boolean = !isChangingConfigurations + @Composable private fun AgentsSettingsScreen( viewModel: MainViewModel, @@ -1905,7 +2013,7 @@ private fun CronJobListRow( ClawDetailRow( title = job.name, subtitle = cronJobSubtitle(job), - modifier = Modifier.clickable(onClick = onClick), + modifier = Modifier.clickable(onClickLabel = "Open cron job detail", onClick = onClick), leading = { ClawIconBadge(icon = Icons.Default.Bolt) }, trailing = { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -1919,7 +2027,32 @@ private fun CronJobListRow( @Composable private fun CronJobDetailPanel( job: GatewayCronJobDetail, + editorDraft: CronEditorDraftState, + onEditorDraftChange: (CronEditorDraftState) -> Unit, + historyState: GatewayCronRunHistoryState, + actionState: GatewayCronActionState, + runPending: Boolean, + operatorAdminScopeAvailable: Boolean, + onRun: () -> Unit, + onToggleEnabled: () -> Unit, + onSave: (GatewayCronJobEdit) -> Unit, + onRefreshHistory: () -> Unit, + onDelete: () -> Unit, ) { + CronJobManagementPanel( + job = job, + editorDraft = editorDraft, + onEditorDraftChange = onEditorDraftChange, + historyState = historyState, + actionState = actionState, + runPending = runPending, + operatorAdminScopeAvailable = operatorAdminScopeAvailable, + onRun = onRun, + onToggleEnabled = onToggleEnabled, + onSave = onSave, + onRefreshHistory = onRefreshHistory, + onDelete = onDelete, + ) SettingsMetricPanel( rows = listOf( diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt index 8a6f94c4860b..d540fb2ee7ca 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -506,26 +508,39 @@ internal fun ClawTextField( placeholder: String, modifier: Modifier = Modifier, minLines: Int = 1, + label: String? = null, + enabled: Boolean = true, ) { + val fieldModifier = + if (label == null) modifier else modifier.semantics { contentDescription = label } BasicTextField( value = value, onValueChange = onValueChange, + enabled = enabled, modifier = - modifier + fieldModifier .fillMaxWidth() .clip(RoundedCornerShape(ClawTheme.radii.control)) .background(ClawTheme.colors.surfaceRaised) .border(1.dp, ClawTheme.colors.border, RoundedCornerShape(ClawTheme.radii.control)) .padding(horizontal = 11.dp, vertical = 8.dp), - textStyle = ClawTheme.type.body.copy(color = ClawTheme.colors.text), + textStyle = + ClawTheme.type.body.copy( + color = if (enabled) ClawTheme.colors.text else ClawTheme.colors.textSubtle, + ), cursorBrush = SolidColor(ClawTheme.colors.primary), minLines = minLines, decorationBox = { innerTextField -> - Box(modifier = Modifier.fillMaxWidth()) { - if (value.isEmpty()) { - Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + label?.let { + Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted) + } + Box(modifier = Modifier.fillMaxWidth()) { + if (value.isEmpty()) { + Text(text = placeholder, style = ClawTheme.type.body, color = ClawTheme.colors.textSubtle) + } + innerTextField() } - innerTextField() } }, ) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt index 10d2507441e6..610f0093b802 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/AndroidScreenshotFixtureTest.kt @@ -23,6 +23,22 @@ class AndroidScreenshotFixtureTest { json .parseToJsonElement(AndroidScreenshotFixture.request("chat.metadata", null)) .jsonObject + val cronJobs = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.list", null)) + .jsonObject["jobs"] + ?.jsonArray + .orEmpty() + val cronDetail = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.get", null)) + .jsonObject + val cronRunEntries = + json + .parseToJsonElement(AndroidScreenshotFixture.request("cron.runs", null)) + .jsonObject["entries"] + ?.jsonArray + val parsedCronRuns = parseGatewayCronRunHistory(cronRunEntries) assertEquals(3, sessions.size) assertEquals( @@ -35,6 +51,20 @@ class AndroidScreenshotFixtureTest { ) assertEquals(1, metadata["models"]?.jsonArray?.size) assertEquals(1, metadata["commands"]?.jsonArray?.size) + assertEquals( + AndroidScreenshotFixture.cronJobName, + cronJobs + .single() + .jsonObject["name"] + ?.jsonPrimitive + ?.content, + ) + assertEquals(AndroidScreenshotFixture.cronJobId, cronDetail["id"]?.jsonPrimitive?.content) + assertEquals(2, parsedCronRuns.size) + assertEquals("android-release-digest-run-2", parsedCronRuns.first().runId) + assertEquals("Release checklist ready", parsedCronRuns.first().summary) + assertEquals("android-release-digest-run-1", parsedCronRuns.last().runId) + assertEquals("Play publish blocked", parsedCronRuns.last().error) } @Test diff --git a/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt index e30eac5dbcca..a76772c28c4a 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/CronJobDetailTest.kt @@ -18,10 +18,17 @@ class CronJobDetailTest { requireNotNull(detail) assertEquals("job-1", detail.id) assertEquals("Daily report", detail.name) + assertEquals("sha256:fixture", detail.configRevision) + assertEquals("cron", detail.scheduleKind) assertEquals("0 9 * * *", detail.scheduleLabel) assertEquals("0 9 * * * · Europe/Vienna · Stagger Every 5m", detail.scheduleDetail) + assertEquals("0 9 * * *", detail.scheduleCronExpr) + assertEquals("Europe/Vienna", detail.scheduleTimezone) + assertEquals(300000L, detail.scheduleStaggerMs) assertEquals("Agent turn · openai/gpt-5.5 · Thinking high", detail.payloadLabel) assertEquals("Summarize the day", detail.payloadText) + assertEquals("openai/gpt-5.5", detail.payloadModel) + assertEquals("high", detail.payloadThinking) assertEquals("Announce · telegram · chat-42 · Account primary", detail.deliveryLabel) assertEquals("After 3 · Announce · telegram · ops · Cooldown Every 1h", detail.failureAlertLabel) assertEquals(2L, detail.consecutiveErrors) @@ -40,6 +47,7 @@ class CronJobDetailTest { requireNotNull(detail) assertEquals("printf done", detail.payloadText) + assertEquals(listOf("printf", "done"), detail.payloadCommandArgv) assertFalse(detail.payloadText.orEmpty().contains("secret-value")) } @@ -75,6 +83,24 @@ class CronJobDetailTest { assertNull(guard.begin(" ")) } + @Test + fun requestGuardConditionsReloadAndCancellationOnCurrentSelection() { + val guard = CronJobDetailRequestGuard() + requireNotNull(guard.begin("job-a")) + requireNotNull(guard.begin("job-b")) + var loadingId = "none" + var cancelled = false + + assertNull(guard.beginIfCurrent("job-a") { loadingId = it.id }) + val reload = guard.beginIfCurrent("job-b") { loadingId = it.id } + assertEquals("job-b", reload?.id) + assertEquals("job-b", loadingId) + assertFalse(guard.cancelIfCurrent("job-a") { cancelled = true }) + assertFalse(cancelled) + assertTrue(guard.cancelIfCurrent("job-b") { cancelled = true }) + assertTrue(cancelled) + } + private fun parseJob( payload: String = """{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""", @@ -90,6 +116,7 @@ class CronJobDetailTest { "deleteAfterRun": false, "createdAtMs": 1000, "updatedAtMs": 2000, + "configRevision": "sha256:fixture", "schedule": {"kind":"cron","expr":"0 9 * * *","tz":"Europe/Vienna","staggerMs":300000}, "sessionTarget": "isolated", "wakeMode": "now", diff --git a/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt new file mode 100644 index 000000000000..5a33d38529d5 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/CronJobManagementTest.kt @@ -0,0 +1,472 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayConnectErrorDetails +import ai.openclaw.app.gateway.GatewaySession +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CronJobManagementTest { + @Test + fun parsesEveryClosedCronRunOutcome() { + val started = parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":true}""")) + val queued = + parseGatewayCronRunOutcome( + objectJson("""{"ok":true,"enqueued":true,"runId":"run-1"}"""), + ) + + assertEquals(GatewayCronRunOutcome.Started(runId = null), started) + assertEquals(GatewayCronRunOutcome.Started(runId = "run-1"), queued) + mapOf( + "not-due" to GatewayCronRunSkipReason.NotDue, + "already-running" to GatewayCronRunSkipReason.AlreadyRunning, + "restart-recovery-pending" to GatewayCronRunSkipReason.RestartRecoveryPending, + "invalid-spec" to GatewayCronRunSkipReason.InvalidSpec, + "stopped" to GatewayCronRunSkipReason.Stopped, + ).forEach { (raw, reason) -> + assertEquals( + GatewayCronRunOutcome.Skipped(reason), + parseGatewayCronRunOutcome( + objectJson("""{"ok":true,"ran":false,"reason":"$raw"}"""), + ), + ) + } + assertEquals( + GatewayCronRunOutcome.Rejected, + parseGatewayCronRunOutcome(objectJson("""{"ok":false}""")), + ) + assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"ran":false,"reason":"future"}"""))) + assertEquals(null, parseGatewayCronRunOutcome(objectJson("""{"ok":true,"enqueued":true}"""))) + } + + @Test + fun updatePatchIsMinimalAndClearsAgentOverridesWithNull() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.AgentTurn + val edit = initial.copy(payload = payload.copy(model = "", thinking = "")) + + val root = objectJson(buildCronUpdateParams(original = original, edit = edit)) + val patch = root.getValue("patch").jsonObject + val payloadPatch = patch.getValue("payload").jsonObject + + assertEquals("sha256:fixture", root.getValue("expectedConfigRevision").jsonPrimitive.content) + assertEquals(setOf("payload"), patch.keys) + assertEquals("agentTurn", payloadPatch.getValue("kind").jsonPrimitive.content) + assertEquals(JsonNull, payloadPatch["model"]) + assertEquals(JsonNull, payloadPatch["thinking"]) + assertFalse(payloadPatch.containsKey("message")) + } + + @Test + fun intervalPatchPreservesAnchorAndOmitsUnchangedPayload() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(schedule = """{"kind":"every","everyMs":60000,"anchorMs":1000}"""), + ), + ) + val initial = original.toCronJobEdit() + val schedule = initial.schedule as GatewayCronScheduleEdit.Every + val edit = initial.copy(schedule = schedule.copy(everyMs = "120000")) + + val patch = + objectJson(buildCronUpdateParams(original = original, edit = edit)) + .getValue("patch") + .jsonObject + val schedulePatch = patch.getValue("schedule").jsonObject + + assertEquals(setOf("schedule"), patch.keys) + assertEquals("120000", schedulePatch.getValue("everyMs").jsonPrimitive.content) + assertEquals("1000", schedulePatch.getValue("anchorMs").jsonPrimitive.content) + } + + @Test + fun deleteAfterRunStaysAvailableOnlyForOneShotSchedules() { + val recurring = + requireNotNull( + parseGatewayCronJobDetail(jobJson(deleteAfterRun = true)), + ).toCronJobEdit() + val oneShot = + requireNotNull( + parseGatewayCronJobDetail( + jobJson( + deleteAfterRun = true, + schedule = """{"kind":"at","at":"2026-07-10T09:00:00Z"}""", + ), + ), + ).toCronJobEdit() + + assertFalse(recurring.deleteAfterRun) + assertTrue(oneShot.deleteAfterRun) + assertFalse( + oneShot + .withSchedule(GatewayCronScheduleEdit.Every(everyMs = "60000", anchorMs = "")) + .deleteAfterRun, + ) + } + + @Test + fun commandArgvRejectsNonStringJsonPrimitives() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""), + ), + ) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.Command + val edit = initial.copy(payload = payload.copy(argvJson = """["echo",1,true,null]""")) + + val error = runCatching { buildCronUpdateParams(original = original, edit = edit) }.exceptionOrNull() + + assertEquals("Command argv entries must be non-empty strings.", error?.message) + } + + @Test + fun commandArgvPreservesWhitespaceOnlyEntriesAllowedByGateway() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["printf"," "],"cwd":"/tmp"}"""), + ), + ) + val edit = original.toCronJobEdit().copy(name = "Renamed command") + + val patch = + objectJson(buildCronUpdateParams(original = original, edit = edit)) + .getValue("patch") + .jsonObject + + assertEquals(setOf("name"), patch.keys) + assertEquals("Renamed command", patch.getValue("name").jsonPrimitive.content) + } + + @Test + fun commandPayloadRejectsClearingAnExistingWorkingDirectory() { + val original = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(payload = """{"kind":"command","argv":["echo"],"cwd":"/tmp"}"""), + ), + ) + val initial = original.toCronJobEdit() + val payload = initial.payload as GatewayCronPayloadEdit.Command + + val error = + runCatching { + buildCronUpdateParams( + original = original, + edit = initial.copy(payload = payload.copy(cwd = "")), + ) + }.exceptionOrNull() + + assertEquals("The gateway does not support clearing a command working directory.", error?.message) + } + + @Test + fun historyParserRequiresTimestampAndKeepsUsefulFields() { + val entries = + Json + .parseToJsonElement( + """ + [ + {"ts":1000,"runId":"run-1","status":"ok","summary":"done","durationMs":42}, + {"runId":"missing-ts"} + ] + """.trimIndent(), + ).jsonArray + + val runs = parseGatewayCronRunHistory(entries) + + assertEquals(1, runs.size) + assertEquals("run-1", runs.single().runId) + assertEquals(42L, runs.single().durationMs) + } + + @Test + fun invalidSpecSkipRefreshesPersistedDiagnosticsWithoutRefreshingOtherSkips() { + assertTrue(cronRunShouldRefresh(GatewayCronRunOutcome.Started(runId = "run-1"))) + assertTrue( + cronRunShouldRefresh( + GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.InvalidSpec), + ), + ) + assertFalse( + cronRunShouldRefresh( + GatewayCronRunOutcome.Skipped(GatewayCronRunSkipReason.AlreadyRunning), + ), + ) + assertFalse(cronRunShouldRefresh(GatewayCronRunOutcome.Rejected)) + } + + @Test + fun queuedRunCompletionNoticeMatchesTerminalHistoryStatus() { + listOf( + Triple("ok", "Cron run finished.", GatewayCronNoticeKind.Success), + Triple("skipped", "Cron run skipped.", GatewayCronNoticeKind.Warning), + Triple("error", "Cron run failed.", GatewayCronNoticeKind.Error), + Triple(null, "Cron run finished with an unknown status.", GatewayCronNoticeKind.Warning), + ).forEach { (status, message, kind) -> + assertEquals( + GatewayCronActionState.Notice(id = "job", message = message, kind = kind), + cronRunCompletionNotice("job", status), + ) + } + } + + @Test + fun pendingRunRegistryDedupesOnlyTheSameJobAndIgnoresStaleTrackers() { + val registry = PendingCronRunRegistry() + val snapshots = mutableListOf>() + + assertTrue(registry.begin("job-a", "run-a") { snapshots += it }) + assertFalse(registry.begin("job-a", "run-a-duplicate") { snapshots += it }) + assertTrue(registry.begin("job-b", "run-b") { snapshots += it }) + assertTrue(registry.contains("job-a")) + assertTrue(registry.contains("job-b")) + assertFalse(registry.finish("job-a", "stale-run") { snapshots += it }) + assertTrue(registry.finish("job-a", "run-a") { snapshots += it }) + assertFalse(registry.contains("job-a")) + assertTrue(registry.contains("job-b")) + registry.clear { snapshots += it } + assertFalse(registry.contains("job-b")) + assertEquals( + listOf(setOf("job-a"), setOf("job-a", "job-b"), setOf("job-b"), emptySet()), + snapshots, + ) + } + + @Test + fun mapsCronRevisionConflicts() { + val conflict = + GatewaySession.ErrorShape( + code = "INVALID_REQUEST", + message = "changed", + details = + GatewayConnectErrorDetails( + code = "CRON_JOB_CHANGED", + canRetryWithDeviceToken = false, + recommendedNextStep = null, + ), + ) + val generic = conflict.copy(details = conflict.details?.copy(code = "OTHER")) + + assertTrue(isCronJobRevisionConflict(conflict)) + assertFalse(isCronJobRevisionConflict(generic)) + } + + @Test + fun updateFailsClosedWhenGatewayDoesNotProvideConfigRevision() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson(configRevision = null))) + val error = + runCatching { + buildCronUpdateParams( + original = original, + edit = original.toCronJobEdit().copy(name = "Renamed"), + ) + }.exceptionOrNull() + + assertEquals("Update the gateway before saving cron changes from Android.", error?.message) + } + + @Test + fun detailAndHistoryGenerationsAdvanceIndependently() { + val detailGuard = CronJobDetailRequestGuard() + val historyGuard = CronJobDetailRequestGuard() + val detailA = requireNotNull(detailGuard.begin("job-a")) + val historyA = requireNotNull(historyGuard.begin("job-a")) + val historyB = requireNotNull(historyGuard.begin("job-b")) + var detailPublished = false + var historyPublished = "none" + + assertTrue(detailGuard.publishIfCurrent(detailA) { detailPublished = true }) + assertFalse(historyGuard.publishIfCurrent(historyA) { historyPublished = "a" }) + assertTrue(historyGuard.publishIfCurrent(historyB) { historyPublished = "b" }) + assertTrue(detailPublished) + assertEquals("b", historyPublished) + } + + @Test + fun editorDraftPreservesDirtyFieldsAndMarksIncomingRevisionConflict() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + var draft = CronEditorDraftState.from(original) + draft = draft.withEdit(draft.edit.copy(name = "Unsaved name")) + val unrelated = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Gateway revision"), + ), + ) + + draft = draft.observeJob(unrelated) + + assertEquals("Unsaved name", draft.edit.name) + assertTrue(draft.isDirty) + assertTrue(draft.hasIncomingConflict) + assertTrue(draft.requiresResolution) + val returnedToBaseline = draft.withEdit(draft.baseline) + assertFalse(returnedToBaseline.isDirty) + assertTrue(returnedToBaseline.requiresResolution) + val reverted = CronEditorDraftState.from(unrelated) + assertEquals("Gateway revision", reverted.edit.name) + assertFalse(reverted.isDirty) + assertFalse(reverted.hasIncomingConflict) + } + + @Test + fun editorDraftIgnoresRuntimeOnlyTimestampUpdates() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val draft = + CronEditorDraftState + .from(original) + .withEdit(original.toCronJobEdit().copy(name = "Unsaved name")) + val runtimeUpdate = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(updatedAtMs = 3000), + ), + ) + + val observed = draft.observeJob(runtimeUpdate) + + assertEquals("Unsaved name", observed.edit.name) + assertTrue(observed.isDirty) + assertFalse(observed.hasIncomingConflict) + } + + @Test + fun editorDraftAdoptsOnlyTheNewRevisionAfterSuccessfulSave() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + var draft = CronEditorDraftState.from(original) + draft = draft.withEdit(draft.edit.copy(name = "Saved name")) + + draft = draft.saveStarted().saveAborted() + assertFalse(draft.savePending) + assertEquals("Saved name", draft.edit.name) + draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Error) + assertEquals("Saved name", draft.edit.name) + draft = draft.saveStarted().observeSaveNotice(GatewayCronNoticeKind.Success) + assertEquals("Saved name", draft.observeJob(original).edit.name) + + val saved = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Saved name", updatedAtMs = 4000, configRevision = "sha256:saved"), + ), + ) + draft = draft.observeJob(saved) + + assertEquals("Saved name", draft.baseline.name) + assertEquals(draft.baseline, draft.edit) + assertFalse(draft.savePending) + assertFalse(draft.saveSucceeded) + } + + @Test + fun restoredPendingSaveTracksRetainedRuntimeAndRecoversAfterProcessDeath() { + val original = requireNotNull(parseGatewayCronJobDetail(jobJson())) + val pending = + CronEditorDraftState + .from(original) + .withEdit(original.toCronJobEdit().copy(name = "Saved name")) + .saveStarted() + val running = GatewayCronActionState.Running(id = original.id, action = GatewayCronAction.Save) + val success = + GatewayCronActionState.Notice( + id = original.id, + message = "Cron job updated.", + kind = GatewayCronNoticeKind.Success, + ) + + assertEquals( + pending, + pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = running), + ) + assertEquals( + pending, + pending.reconcileRestoredAction(isConnected = true, jobId = original.id, actionState = success), + ) + assertFalse( + pending + .reconcileRestoredAction( + isConnected = true, + jobId = original.id, + actionState = GatewayCronActionState.Idle, + ).savePending, + ) + assertFalse( + pending + .reconcileRestoredAction( + isConnected = false, + jobId = original.id, + actionState = running, + ).savePending, + ) + + val applied = + requireNotNull( + parseGatewayCronJobDetail( + jobJson(name = "Saved name", updatedAtMs = 4000), + ), + ) + val recovered = pending.saveAborted().observeJob(applied) + assertEquals(recovered.baseline, recovered.edit) + assertFalse(recovered.requiresResolution) + } + + @Test + fun latestRefreshGuardRejectsStaleAndInvalidatedResults() { + val guard = LatestGatewayRefreshGuard() + val stale = guard.begin() + val current = guard.begin() + var published = "none" + + assertFalse(guard.publishIfCurrent(stale) { published = "stale" }) + assertTrue(guard.publishIfCurrent(current) { published = "current" }) + guard.invalidate() + assertFalse(guard.publishIfCurrent(current) { published = "invalidated" }) + assertEquals("current", published) + } + + private fun objectJson(raw: String) = Json.parseToJsonElement(raw).jsonObject + + private fun jobJson( + name: String = "Daily report", + updatedAtMs: Long = 2000, + configRevision: String? = "sha256:fixture", + deleteAfterRun: Boolean = false, + schedule: String = """{"kind":"cron","expr":"0 9 * * *","tz":"UTC"}""", + payload: String = + """{"kind":"agentTurn","message":"Summarize the day","model":"openai/gpt-5.5","thinking":"high"}""", + ): JsonObject { + val configRevisionField = + configRevision?.let { """"configRevision":"$it",""" }.orEmpty() + return objectJson( + """ + { + "id":"job-1", + "name":"$name", + "description":"Daily digest", + "enabled":true, + "deleteAfterRun":$deleteAfterRun, + "createdAtMs":1000, + "updatedAtMs":$updatedAtMs, + $configRevisionField + "schedule":$schedule, + "sessionTarget":"isolated", + "wakeMode":"next-heartbeat", + "payload":$payload, + "state":{} + } + """.trimIndent(), + ) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt new file mode 100644 index 000000000000..25a22cc1012a --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/CronRuntimeGuardTest.kt @@ -0,0 +1,220 @@ +package ai.openclaw.app + +import ai.openclaw.app.gateway.GatewayEndpoint +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.UUID + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class CronRuntimeGuardTest { + @Before + fun clearPlainPrefs() { + RuntimeEnvironment + .getApplication() + .getSharedPreferences("openclaw.node", android.content.Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun nonAdminConnectionRejectsMutationBeforeGatewayRequest() { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + + runtime.runCronJob("job-1") + + assertEquals( + GatewayCronActionState.Notice( + id = "job-1", + message = "Cron changes require operator.admin access.", + kind = GatewayCronNoticeKind.Error, + ), + runtime.cronActionState.value, + ) + } + + @Test + fun activeCronActionSerializesLaterMutationCalls() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>>(runtime, "_operatorScopes").value = + listOf("operator.admin") + withTimeout(2_000) { + while (!runtime.operatorAdminScopeAvailable.value) delay(10) + } + val actionMutex = readField(runtime, "cronActionMutex") + actionMutex.lock() + try { + runtime.runCronJob("job-1") + runtime.setCronJobEnabled(id = "job-1", enabled = false) + delay(50) + + assertEquals( + GatewayCronActionState.Notice( + id = "job-1", + message = "Another cron action is still finishing.", + kind = GatewayCronNoticeKind.Warning, + ), + runtime.cronActionState.value, + ) + } finally { + actionMutex.unlock() + } + } + + @Test + fun completedDeleteDoesNotClearNewerJobSelection() { + val runtime = createTestRuntime() + val detailState = readField>(runtime, "_cronJobDetailState") + val historyState = readField>(runtime, "_cronRunHistoryState") + requireNotNull(readField(runtime, "cronJobDetailRequestGuard").begin("job-b")) + requireNotNull(readField(runtime, "cronRunHistoryRequestGuard").begin("job-b")) + detailState.value = GatewayCronJobDetailState.Loading("job-b") + historyState.value = GatewayCronRunHistoryState.Loading("job-b") + + invokeStringMethod(runtime, "clearDeletedCronSelection", "job-a") + + assertEquals(GatewayCronJobDetailState.Loading("job-b"), detailState.value) + assertEquals(GatewayCronRunHistoryState.Loading("job-b"), historyState.value) + + invokeStringMethod(runtime, "clearDeletedCronSelection", "job-b") + + assertEquals(GatewayCronJobDetailState.Idle, detailState.value) + assertEquals(GatewayCronRunHistoryState.Idle, historyState.value) + } + + @Test + fun detailDisposalRetainsNoticeUntilExplicitJobDismissal() { + val runtime = createTestRuntime() + val actionState = readField>(runtime, "_cronActionState") + val notice = + GatewayCronActionState.Notice( + id = "job-a", + message = "Cron job updated.", + kind = GatewayCronNoticeKind.Success, + ) + actionState.value = notice + + runtime.clearCronJobDetail() + assertEquals(notice, actionState.value) + runtime.dismissCronActionNotice("job-b") + assertEquals(notice, actionState.value) + runtime.dismissCronActionNotice("job-a") + assertEquals(GatewayCronActionState.Idle, actionState.value) + } + + @Test + fun pendingCronRunSurvivesReconnectButClearsWhenGatewayScopeRetires() { + val runtime = createTestRuntime() + val registry = readField(runtime, "pendingCronRunRegistry") + val pending = readField>>(runtime, "_pendingCronRunJobIds") + assertEquals(true, registry.begin("job-1", "run-1") { pending.value = it }) + + invokeBooleanMethod(runtime, "clearOperatorGatewayState", false) + assertEquals(setOf("job-1"), pending.value) + + invokeBooleanMethod(runtime, "clearOperatorGatewayState", true) + assertEquals(emptySet(), pending.value) + } + + @Test + fun runningStateBlocksMutationAfterMutexRelease() = + runBlocking { + val runtime = createTestRuntime() + seedConnectedRuntime(runtime) + readField>>(runtime, "_operatorScopes").value = + listOf("operator.admin") + withTimeout(2_000) { + while (!runtime.operatorAdminScopeAvailable.value) delay(10) + } + val running = GatewayCronActionState.Running(id = "job-1", action = GatewayCronAction.Save) + readField>(runtime, "_cronActionState").value = running + + runtime.runCronJob("job-1") + delay(50) + + assertEquals(running, runtime.cronActionState.value) + } + + private fun createTestRuntime(): NodeRuntime { + val app = RuntimeEnvironment.getApplication() + val securePrefs = + app.getSharedPreferences( + "openclaw.node.cron.guard.test.${UUID.randomUUID()}", + android.content.Context.MODE_PRIVATE, + ) + return NodeRuntime(app, SecurePrefs(app, securePrefsOverride = securePrefs)) + } + + private fun seedConnectedRuntime(runtime: NodeRuntime) { + writeField(runtime, "connectedEndpoint", GatewayEndpoint.manual("127.0.0.1", 18789)) + writeField(runtime, "operatorConnected", true) + } + + private fun writeField( + target: Any, + name: String, + value: Any?, + ) { + findField(target, name).set(target, value) + } + + private fun readField( + target: Any, + name: String, + ): T { + @Suppress("UNCHECKED_CAST") + return findField(target, name).get(target) as T + } + + private fun findField( + target: Any, + name: String, + ): Field { + var type: Class<*>? = target.javaClass + while (type != null) { + try { + return type.getDeclaredField(name).apply { isAccessible = true } + } catch (_: NoSuchFieldException) { + type = type.superclass + } + } + error("Field $name not found on ${target.javaClass.name}") + } + + private fun invokeStringMethod( + target: Any, + name: String, + value: String, + ) { + target.javaClass + .getDeclaredMethod(name, String::class.java) + .apply { isAccessible = true } + .invoke(target, value) + } + + private fun invokeBooleanMethod( + target: Any, + name: String, + value: Boolean, + ) { + target.javaClass + .getDeclaredMethod(name, java.lang.Boolean.TYPE) + .apply { isAccessible = true } + .invoke(target, value) + } +} diff --git a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt index d2552e1a386f..61d7d8b1b24e 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/GatewayNodeApprovalStateTest.kt @@ -180,7 +180,7 @@ class GatewayNodeApprovalStateTest { @Test fun ignoresStaleNodeApprovalRefreshResults() { - val guard = GatewayNodeApprovalRefreshGuard() + val guard = LatestGatewayRefreshGuard() var approvalState = GatewayNodeApprovalState.Loading val staleRefresh = guard.begin() val currentRefresh = guard.begin() diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt index dd03d7800432..fcedb3956cc1 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainViewModelTest.kt @@ -1,6 +1,8 @@ package ai.openclaw.app +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -32,4 +34,41 @@ class MainViewModelTest { ), ) } + + @Test + fun cronEditorDraftMemoryIsBoundedAndClearsOnlyItsOwningJob() { + val memory = CronEditorDraftMemory() + val first = draft("First") + val second = draft("Second") + + memory.set("job-a", first) + assertEquals(first, memory.get("job-a")) + assertNull(memory.get("job-b")) + + memory.set("job-b", second) + assertNull(memory.get("job-a")) + memory.clear("job-a") + assertEquals(second, memory.get("job-b")) + + memory.set("job-b", null) + assertNull(memory.get("job-b")) + } + + private fun draft(name: String): CronEditorDraftState { + val edit = + GatewayCronJobEdit( + name = name, + description = "", + enabled = true, + deleteAfterRun = false, + schedule = GatewayCronScheduleEdit.At("2026-07-10T09:00:00Z"), + sessionTarget = "isolated", + wakeMode = "now", + payload = GatewayCronPayloadEdit.SystemEvent("Wake up"), + ) + return CronEditorDraftState( + baseline = edit, + edit = edit.copy(name = "$name draft"), + ) + } } diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index f38fc373a171..cddd7861ef31 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -92,6 +92,36 @@ class SettingsScreensTest { assertEquals(null, gatewayNodeApprovalCommand(GatewayNodeCapabilityApproval.Approved)) } + @Test + fun cronDetailRefreshRecoversWhenDirtyDraftHasNoLoadedJob() { + assertEquals( + true, + cronDetailRefreshEnabled( + isConnected = true, + loading = false, + hasCurrentJob = false, + draftRequiresResolution = true, + saveSucceeded = false, + ), + ) + assertEquals( + false, + cronDetailRefreshEnabled( + isConnected = true, + loading = false, + hasCurrentJob = true, + draftRequiresResolution = true, + saveSucceeded = false, + ), + ) + } + + @Test + fun cronDetailDisposalRetainsTransientStateOnlyForActivityRecreation() { + assertEquals(false, cronDetailDisposalClearsTransientState(isChangingConfigurations = true)) + assertEquals(true, cronDetailDisposalClearsTransientState(isChangingConfigurations = false)) + } + private fun authProblem(code: String): GatewayConnectionProblem = GatewayConnectionProblem( code = code, diff --git a/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt new file mode 100644 index 000000000000..8302e4bbab45 --- /dev/null +++ b/apps/android/benchmark/src/main/java/ai/openclaw/app/benchmark/CronJobNavigationTest.kt @@ -0,0 +1,81 @@ +package ai.openclaw.app.benchmark + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class CronJobNavigationTest { + private lateinit var device: UiDevice + + @Before + fun setUp() { + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.executeShellCommand("am force-stop $packageName") + device.executeShellCommand( + "am start -W -n $packageName/.MainActivity " + + "--ez openclaw.screenshotMode true --es openclaw.screenshotScene settings", + ) + assertNotNull(device.wait(Until.findObject(By.text("Settings")), waitTimeoutMs)) + } + + @Test + fun opensCronJobFixtureDetail() { + findTextAfterScrolling("Cron Jobs").click() + + val cronJobLabel = findTextAfterScrolling("Android release digest") + val cronJobRow = + checkNotNull( + generateSequence(cronJobLabel) { it.parent } + .firstOrNull { it.isClickable }, + ) { "Cron fixture row must expose a click action" } + assertTrue("Cron fixture row must expose a click action", cronJobRow.isClickable) + assertFalse(device.hasObject(By.text("Inspect scheduled gateway work."))) + cronJobRow.click() + + assertNotNull(device.wait(Until.findObject(By.text("Inspect scheduled gateway work.")), waitTimeoutMs)) + assertNotNull(findTextAfterScrolling("Run Now")) + assertNotNull(findTextAfterScrolling("Recent Runs")) + assertNotNull(findTextAfterScrolling("Release checklist ready", exact = false)) + assertNotNull(findTextAfterScrolling("OK")) + assertNotNull(findTextAfterScrolling("Play publish blocked", exact = false)) + assertNotNull(findTextAfterScrolling("Issue")) + } + + private fun findTextAfterScrolling( + text: String, + exact: Boolean = true, + ): UiObject2 { + val selector = if (exact) By.text(text) else By.textContains(text) + repeat(maxScrolls + 1) { attempt -> + device.wait(Until.findObject(selector), shortWaitMs)?.let { return it } + if (attempt < maxScrolls) { + device.swipe( + device.displayWidth / 2, + (device.displayHeight * 0.8f).toInt(), + device.displayWidth / 2, + (device.displayHeight * 0.25f).toInt(), + 24, + ) + device.waitForIdle() + } + } + error("Could not find UI text: $text") + } + + private companion object { + const val packageName = "ai.openclaw.app" + const val waitTimeoutMs = 10_000L + const val shortWaitMs = 1_000L + const val maxScrolls = 6 + } +} diff --git a/apps/ios/Sources/Design/SettingsProTab.swift b/apps/ios/Sources/Design/SettingsProTab.swift index b94ef2a87d3d..5e15bcd90c10 100644 --- a/apps/ios/Sources/Design/SettingsProTab.swift +++ b/apps/ios/Sources/Design/SettingsProTab.swift @@ -60,6 +60,8 @@ struct SettingsProTab: View { @State var defaultShareInstruction = "" @State var showQRScanner = false @State var scannerError: String? + @State var showLocationAccessDialog = false + @State var pendingLocationMode: OpenClawLocationMode? @State var showResetOnboardingAlert = false @State var suppressCredentialPersist = false @State var locationStatusText: String? @@ -178,6 +180,7 @@ struct SettingsProTab: View { .onChange(of: self.scenePhase) { _, phase in if phase == .active { self.syncSettingsState() + self.applyPendingLocationModeIfAvailable() self.refreshNotificationSettings() } } @@ -287,8 +290,11 @@ struct SettingsProTab: View { "QR Scanner Unavailable", isPresented: Binding( get: { self.scannerError != nil }, - set: { if !$0 { self.scannerError = nil } })) - { + set: { + if !$0 { + self.scannerError = nil + } + })) { Button(role: .cancel) {} label: { Text("OK") .font(OpenClawType.subheadSemiBold) @@ -297,11 +303,40 @@ struct SettingsProTab: View { Text(self.scannerError ?? "") .font(OpenClawType.subhead) } + .confirmationDialog( + "Access Level", + isPresented: self.$showLocationAccessDialog, + titleVisibility: .visible) + { + Button { + self.selectLocationAccessLevel(.whileUsing) + } label: { + Text("While Using the App") + .font(OpenClawType.subheadSemiBold) + } + Button { + self.selectLocationAccessLevel(.always) + } label: { + Text("Always") + .font(OpenClawType.subheadSemiBold) + } + Button(role: .cancel) {} label: { + Text("Cancel") + .font(OpenClawType.subheadSemiBold) + } + } message: { + Text("Choose when OpenClaw may share this iPhone's location with gateway tools.") + .font(OpenClawType.subhead) + } .confirmationDialog( "Forget \(self.pendingForgetGateway?.name ?? "gateway")?", isPresented: Binding( get: { self.pendingForgetGateway != nil }, - set: { if !$0 { self.pendingForgetGateway = nil } }), + set: { + if !$0 { + self.pendingForgetGateway = nil + } + }), titleVisibility: .visible) { Button(role: .destructive) { @@ -317,7 +352,9 @@ struct SettingsProTab: View { .font(OpenClawType.subheadSemiBold) } } message: { - Text("This removes saved credentials, device access, TLS trust, and cached chats for this gateway.") + Text( + "This removes saved credentials, device access, TLS trust, " + + "and cached chats for this gateway.") .font(OpenClawType.subhead) } } diff --git a/apps/ios/Sources/Design/SettingsProTabActions.swift b/apps/ios/Sources/Design/SettingsProTabActions.swift index 37fea1803ff2..242ded974ed0 100644 --- a/apps/ios/Sources/Design/SettingsProTabActions.swift +++ b/apps/ios/Sources/Design/SettingsProTabActions.swift @@ -581,6 +581,8 @@ extension SettingsProTab { if mode == .off { _ = await self.appModel.requestLocationPermissions(mode: mode) + self.pendingLocationMode = nil + self.locationModeRaw = rawValue self.previousLocationModeRaw = rawValue self.refreshLocationPermissionSummary(desiredMode: mode) self.gatewayController.refreshActiveGatewayRegistrationFromSettings() @@ -590,17 +592,99 @@ extension SettingsProTab { let granted = await self.appModel.requestLocationPermissions(mode: mode) self.refreshLocationPermissionSummary(desiredMode: mode) if granted { + self.pendingLocationMode = nil + self.locationModeRaw = rawValue self.previousLocationModeRaw = rawValue self.gatewayController.refreshActiveGatewayRegistrationFromSettings() } else { self.locationModeRaw = previous self.previousLocationModeRaw = previous - self.locationStatusText = "Location permission was not granted." self.refreshLocationPermissionSummary( desiredMode: OpenClawLocationMode(rawValue: previous) ?? .off) + let presentation = self.locationSettingsPresentation(selectedMode: mode) + self.locationStatusText = presentation.statusText } } + var selectedLocationMode: OpenClawLocationMode { + OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off + } + + var displayedLocationMode: OpenClawLocationMode { + self.pendingLocationMode ?? self.selectedLocationMode + } + + var locationSettingsPresentation: LocationSettingsPresentation { + self.locationSettingsPresentation(selectedMode: self.displayedLocationMode) + } + + func locationSettingsPresentation(selectedMode: OpenClawLocationMode) -> LocationSettingsPresentation { + var summary = self.locationPermissionSummary + summary.desiredMode = selectedMode + return LocationSettingsPresentation(selectedMode: selectedMode, summary: summary) + } + + func handleLocationSharingTap() { + guard !self.isChangingLocationMode else { return } + self.performLocationSettingsAction(self.locationSettingsPresentation.toggleAction()) + } + + func selectLocationAccessLevel(_ mode: OpenClawLocationMode) { + guard mode != .off else { return } + guard !self.isChangingLocationMode else { return } + let presentation = self.locationSettingsPresentation(selectedMode: mode) + self.performLocationSettingsAction(presentation.accessLevelAction(mode: mode)) + } + + func performLocationSettingsAction(_ action: LocationSettingsAction) { + switch action { + case let .setMode(mode): + self.setLocationMode(mode) + case let .openAppSettings(mode): + self.pendingLocationMode = mode + self.locationStatusText = self.locationSettingsPresentation(selectedMode: mode).statusText + self.openLocationSettings() + } + } + + func setLocationMode(_ mode: OpenClawLocationMode) { + let rawValue = mode.rawValue + let previous = self.previousLocationModeRaw + if self.locationModeRaw != rawValue { + self.locationModeRaw = rawValue + return + } + Task { + await self.applyLocationMode(mode, rawValue: rawValue, previous: previous) + } + } + + func applyPendingLocationModeIfAvailable() { + guard let mode = self.pendingLocationMode else { return } + Task { + let locationServicesEnabled = await Self.locationServicesEnabled() + let manager = CLLocationManager() + let summary = LocationPermissionSummary( + desiredMode: mode, + locationServicesEnabled: locationServicesEnabled, + authorizationStatus: manager.authorizationStatus, + accuracyAuthorization: manager.accuracyAuthorization) + self.locationPermissionSummary = summary + let unavailableStatus = self.locationSettingsPresentation(selectedMode: mode).statusText + self.pendingLocationMode = nil + guard summary.effectiveMode != .off else { + self.locationStatusText = unavailableStatus + return + } + self.setLocationMode(mode) + } + } + + func openLocationSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } + func refreshNotificationSettings() { UNUserNotificationCenter.current().getNotificationSettings { settings in let status = settings.authorizationStatus @@ -1126,36 +1210,16 @@ extension SettingsProTab { return diagnosticsIssueCount == 0 ? OpenClawBrand.ok : OpenClawBrand.warn } - var privacyDetail: String { - let location = OpenClawLocationMode(rawValue: self.locationModeRaw) ?? .off - return switch (location, self.locationPermissionSummary.effectiveMode) { - case (.off, _): - "Location off" - case (.whileUsing, .whileUsing): - "Location While Using" - case (.whileUsing, .off): - "Location While Using, effective Off" - case (.whileUsing, .always): - "Location While Using, effective Always" - case (.always, .always): - "Location Always" - case (.always, .whileUsing): - "Location Always, effective While Using" - case (.always, .off): - "Location Always, effective Off" - } - } - - var locationPermissionDetailText: String { + var locationPermissionDetailText: String? { if self.isChangingLocationMode { return "Requesting iOS location permission…" } - return self.locationPermissionSummary.detailText + return self.locationSettingsPresentation.statusText } var locationPermissionWarningText: String? { guard let locationStatusText else { return nil } - guard locationStatusText != self.locationPermissionSummary.detailText else { return nil } + guard locationStatusText != self.locationPermissionDetailText else { return nil } return locationStatusText } diff --git a/apps/ios/Sources/Design/SettingsProTabSections.swift b/apps/ios/Sources/Design/SettingsProTabSections.swift index e567a5883e9a..4b92cdcaf748 100644 --- a/apps/ios/Sources/Design/SettingsProTabSections.swift +++ b/apps/ios/Sources/Design/SettingsProTabSections.swift @@ -552,13 +552,6 @@ extension SettingsProTab { Group { self.notificationsSection - self.detailStatusCard( - icon: "hand.raised", - title: "Privacy", - detail: "Control what device context OpenClaw can expose to the gateway.", - value: self.privacyDetail, - color: .secondary) - self.toggleCard( title: "Camera Access", isOn: self.$cameraEnabled) @@ -778,44 +771,69 @@ extension SettingsProTab { var locationModeCard: some View { Section { VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - SettingsIcon( - systemName: "location", - color: self.locationModeRaw == OpenClawLocationMode.off.rawValue ? .secondary : OpenClawBrand - .accent) - VStack(alignment: .leading, spacing: 3) { + Button { + self.handleLocationSharingTap() + } label: { + HStack { Text("Location") - .font(OpenClawType.subheadSemiBold) - Text("Controls whether location can be shared with gateway tools.") - .font(OpenClawType.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - Spacer(minLength: 8) - if self.isChangingLocationMode { - ProgressView() - .controlSize(.small) + .font(OpenClawType.body) + .foregroundStyle(.primary) + Spacer(minLength: 8) + ZStack { + OpenClawToggleIndicator(isOn: self.locationSettingsPresentation.sharingControlIsOn) + .opacity(self.isChangingLocationMode ? 0 : 1) + if self.isChangingLocationMode { + ProgressView() + .controlSize(.small) + } + } } + .contentShape(Rectangle()) } - - Picker("Location", selection: self.$locationModeRaw) { - Text("Off") - .font(OpenClawType.captionSemiBold) - .tag(OpenClawLocationMode.off.rawValue) - Text("While Using") - .font(OpenClawType.captionSemiBold) - .tag(OpenClawLocationMode.whileUsing.rawValue) - Text("Always") - .font(OpenClawType.captionSemiBold) - .tag(OpenClawLocationMode.always.rawValue) - } - .pickerStyle(.segmented) + .buttonStyle(.plain) .disabled(self.isChangingLocationMode) + .accessibilityIdentifier("settings-location-sharing-toggle") + .accessibilityLabel("Location Sharing") + .accessibilityValue(self.locationSettingsPresentation.sharingControlIsOn ? "On" : "Off") - Text(self.locationPermissionDetailText) - .font(OpenClawType.caption2) - .foregroundStyle( - self.locationPermissionSummary.needsAttention ? OpenClawBrand.warn : .secondary) + if self.locationSettingsPresentation.showsAccessLevel, + let accessLevelText = self.locationSettingsPresentation.accessLevelText + { + Divider() + Button { + self.showLocationAccessDialog = true + } label: { + HStack(alignment: .firstTextBaseline) { + Text("Access Level") + .font(OpenClawType.body) + .foregroundStyle(.primary) + Spacer(minLength: 8) + Text(accessLevelText) + .font(OpenClawType.subhead) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(self.isChangingLocationMode) + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("settings-location-access-level") + .accessibilityLabel("Access Level") + .accessibilityValue(accessLevelText) + .accessibilityHint("Chooses While Using the App or Always") + } + + if let locationPermissionDetailText { + Text(locationPermissionDetailText) + .font(OpenClawType.caption2) + .foregroundStyle(OpenClawBrand.warn) + } if let locationPermissionWarningText { Text(locationPermissionWarningText) @@ -974,29 +992,45 @@ extension SettingsProTab { } func discoveredGatewayRow(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> some View { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 3) { - Text(verbatim: gateway.name) - .font(OpenClawType.subheadSemiBold) - Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • ")) - .font(OpenClawType.caption) - .foregroundStyle(.secondary) - .lineLimit(2) - } - Spacer(minLength: 8) - Button { - Task { await self.connect(gateway) } - } label: { - if self.connectingGatewayID == gateway.id { - ProgressView().controlSize(.small) + let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway) + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: gateway.name) + .font(OpenClawType.subheadSemiBold) + Text(verbatim: self.gatewayDetailLines(gateway).joined(separator: " • ")) + .font(OpenClawType.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 8) + if availability.canConnect { + Button { + Task { await self.connect(gateway) } + } label: { + if self.connectingGatewayID == gateway.id { + ProgressView().controlSize(.small) + } else { + Text(availability.actionTitle) + .font(OpenClawType.captionSemiBold) + } + } + .font(OpenClawType.captionSemiBold) + .buttonStyle(.bordered) + .disabled(self.connectingGatewayID != nil) } else { - Text("Connect") + Text(availability.actionTitle) .font(OpenClawType.captionSemiBold) + .foregroundStyle(OpenClawBrand.warn) } } - .font(OpenClawType.captionSemiBold) - .buttonStyle(.bordered) - .disabled(self.connectingGatewayID != nil) + + if let guidanceText = availability.guidanceText { + Text(guidanceText) + .font(OpenClawType.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } diff --git a/apps/ios/Sources/Gateway/GatewayConnectionController.swift b/apps/ios/Sources/Gateway/GatewayConnectionController.swift index f7bb074d6e3f..59221974cf9c 100644 --- a/apps/ios/Sources/Gateway/GatewayConnectionController.swift +++ b/apps/ios/Sources/Gateway/GatewayConnectionController.swift @@ -68,6 +68,34 @@ private func defaultGatewayTLSFingerprintProbe(url: URL) async -> GatewayTLSFing @MainActor @Observable final class GatewayConnectionController { + enum DiscoveredGatewayConnectionAvailability: Equatable { + case available + case secureTransportRequired + + var canConnect: Bool { + self == .available + } + + var actionTitle: String { + switch self { + case .available: + String(localized: "Connect") + case .secureTransportRequired: + String(localized: "TLS required") + } + } + + var guidanceText: String? { + switch self { + case .available: + nil + case .secureTransportRequired: + String( + localized: "Enable Gateway TLS, or enter your Tailscale Serve HTTPS host in Manual Setup. Use Unencrypted only with a trusted private-LAN address.") + } + } + } + static func resolvedManualPort(host: String, port: Int) -> Int? { if port > 0 { return port <= 65535 ? port : nil @@ -251,10 +279,28 @@ final class GatewayConnectionController { await self.connectDiscoveredGateway(gateway) } + func discoveredGatewayConnectionAvailability( + _ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> DiscoveredGatewayConnectionAvailability + { + if gateway.tlsEnabled || GatewayTLSStore.loadFingerprint(stableID: gateway.stableID) != nil { + return .available + } + return .secureTransportRequired + } + + func preferredDiscoveredGateway() -> GatewayDiscoveryModel.DiscoveredGateway? { + self.gateways.first(where: { + self.discoveredGatewayConnectionAvailability($0).canConnect + }) ?? self.gateways.first + } + private func connectDiscoveredGateway( _ gateway: GatewayDiscoveryModel.DiscoveredGateway, forceReconnect: Bool = false) async -> String? { + let availability = self.discoveredGatewayConnectionAvailability(gateway) + guard availability.canConnect else { return availability.guidanceText } + let connectAttempt = self.beginConnectAttempt() self.pendingConnectionStableID = gateway.stableID defer { self.finishConnectAttempt(connectAttempt.suppressionLease) } @@ -285,10 +331,6 @@ final class GatewayConnectionController { let tlsRequired = true let stored = GatewayTLSStore.loadFingerprint(stableID: stableID) - guard gateway.tlsEnabled || stored != nil else { - return "Discovered gateway is missing TLS and no trusted fingerprint is stored." - } - if tlsRequired, stored == nil { guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true) else { return "Failed to build TLS URL for trust verification." } @@ -1256,7 +1298,12 @@ final class GatewayConnectionController { { switch failure { case .endpointUnreachable: - "Can't reach gateway at \(host):\(port). Check Tailscale or LAN." + if host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")).hasSuffix(".ts.net") { + String( + localized: "Can't reach gateway at \(host):\(port). Verify Tailscale Serve is enabled and publishes this Gateway.") + } else { + String(localized: "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.") + } case .tlsHandshakeTimeout: "TLS fingerprint verification timed out for \(host):\(port). " + "Secure endpoint was reached, but TLS did not finish in time." diff --git a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift index c39229eee33f..b4c27f0e2394 100644 --- a/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift +++ b/apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift @@ -10,11 +10,17 @@ struct GatewayQuickSetupSheet: View { @Environment(GatewayConnectionController.self) private var gatewayController @Environment(\.dismiss) private var dismiss + let onUseManualSetup: () -> Void + @AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false @State private var connecting: Bool = false @State private var connectError: String? @State private var showGatewayProblemDetails: Bool = false + init(onUseManualSetup: @escaping () -> Void = {}) { + self.onUseManualSetup = onUseManualSetup + } + var body: some View { NavigationStack { ScrollView { @@ -34,6 +40,7 @@ struct GatewayQuickSetupSheet: View { } if let candidate = self.bestCandidate { + let availability = self.gatewayController.discoveredGatewayConnectionAvailability(candidate) GatewayQuickSetupCandidatePanel( name: candidate.name, debugID: candidate.debugID, @@ -44,33 +51,58 @@ struct GatewayQuickSetupSheet: View { nodeStatusText: self.appModel.nodeStatusText, operatorStatusText: self.appModel.operatorStatusText) - Button { - self.connectError = nil - self.connecting = true - Task { - let err = await self.gatewayController.connectWithDiagnostics(candidate) - await MainActor.run { - self.connecting = false - self.connectError = err + if availability.canConnect { + Button { + self.connectError = nil + self.connecting = true + Task { + let err = await self.gatewayController.connectWithDiagnostics(candidate) + await MainActor.run { + self.connecting = false + self.connectError = err + } } - } - } label: { - Group { - if self.connecting { - HStack(spacing: 8) { - ProgressView().progressViewStyle(.circular) - Text("Connecting…") + } label: { + Group { + if self.connecting { + HStack(spacing: 8) { + ProgressView().progressViewStyle(.circular) + Text("Connecting…") + .font(OpenClawType.subheadSemiBold) + } + } else { + Text("Connect to this Gateway") .font(OpenClawType.subheadSemiBold) } - } else { - Text("Connect to this Gateway") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(OpenClawPrimaryActionButtonStyle()) + .disabled(self.connecting) + } else if let guidanceText = availability.guidanceText { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .foregroundStyle(OpenClawBrand.warn) + VStack(alignment: .leading, spacing: 3) { + Text(availability.actionTitle) .font(OpenClawType.subheadSemiBold) + Text(guidanceText) + .font(OpenClawType.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } - .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + + Button { + self.onUseManualSetup() + } label: { + Text("Use Manual Setup") + .font(OpenClawType.subheadSemiBold) + .frame(maxWidth: .infinity) + } + .buttonStyle(OpenClawSecondaryActionButtonStyle()) } - .buttonStyle(OpenClawPrimaryActionButtonStyle()) - .disabled(self.connecting) if let connectError { GatewayQuickSetupErrorView(message: connectError) @@ -124,7 +156,7 @@ struct GatewayQuickSetupSheet: View { } private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? { - self.gatewayController.gateways.first + self.gatewayController.preferredDiscoveredGateway() } private func fullRowToggle(_ title: LocalizedStringKey, isOn: Binding) -> some View { @@ -160,6 +192,11 @@ struct GatewayQuickSetupSheet: View { } guard problem.retryable else { return } guard let candidate = self.bestCandidate else { return } + let availability = self.gatewayController.discoveredGatewayConnectionAvailability(candidate) + guard availability.canConnect else { + self.connectError = availability.guidanceText + return + } self.connectError = nil self.connecting = true let err = await self.gatewayController.connectWithDiagnostics(candidate) diff --git a/apps/ios/Sources/Location/LocationSettingsPresentation.swift b/apps/ios/Sources/Location/LocationSettingsPresentation.swift new file mode 100644 index 000000000000..5cd74b72bb9b --- /dev/null +++ b/apps/ios/Sources/Location/LocationSettingsPresentation.swift @@ -0,0 +1,87 @@ +import CoreLocation +import Foundation +import OpenClawKit + +enum LocationSettingsAction: Equatable { + case setMode(OpenClawLocationMode) + case openAppSettings(OpenClawLocationMode) +} + +struct LocationSettingsPresentation: Equatable { + var selectedMode: OpenClawLocationMode + var summary: LocationPermissionSummary + + var sharingControlIsOn: Bool { + self.selectedMode != .off + } + + var showsAccessLevel: Bool { + self.selectedMode != .off + } + + var accessLevelText: String? { + self.selectedMode.locationAccessLevelText + } + + var statusText: String? { + guard self.selectedMode != .off else { return nil } + guard self.summary.needsAttention else { return nil } + + if !self.summary.locationServicesEnabled { + return String(localized: "Location Services are off in iOS Settings.") + } + + switch self.summary.authorizationStatus { + case .notDetermined: + return String(localized: "iOS permission is required to share location.") + case .denied: + return String(localized: "Location permission is denied in iOS Settings.") + case .restricted: + return String(localized: "Location permission is restricted on this device.") + case .authorizedWhenInUse where self.selectedMode == .always: + return String(localized: "iOS currently allows location only while using the app.") + case .authorizedWhenInUse, .authorizedAlways: + return nil + default: + return String(localized: "OpenClaw cannot determine the current iOS location permission.") + } + } + + func toggleAction(defaultEnabledMode: OpenClawLocationMode = .whileUsing) -> LocationSettingsAction { + if self.sharingControlIsOn { + return .setMode(.off) + } + let mode = self.selectedMode == .off ? defaultEnabledMode : self.selectedMode + return self.enableAction(mode: mode) + } + + func accessLevelAction(mode: OpenClawLocationMode) -> LocationSettingsAction { + self.enableAction(mode: mode) + } + + private func enableAction(mode: OpenClawLocationMode) -> LocationSettingsAction { + if !self.summary.locationServicesEnabled { + return .openAppSettings(mode) + } + + switch self.summary.authorizationStatus { + case .denied, .restricted: + return .openAppSettings(mode) + default: + return .setMode(mode) + } + } +} + +extension OpenClawLocationMode { + var locationAccessLevelText: String? { + switch self { + case .off: + nil + case .whileUsing: + String(localized: "While Using the App") + case .always: + String(localized: "Always") + } + } +} diff --git a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift index 6712a58a4c9b..6f2707d943bc 100644 --- a/apps/ios/Sources/Onboarding/OnboardingWizardView.swift +++ b/apps/ios/Sources/Onboarding/OnboardingWizardView.swift @@ -719,35 +719,47 @@ struct OnboardingWizardView: View { .foregroundStyle(.secondary) } else { ForEach(self.gatewayController.gateways) { gateway in - let hasHost = self.gatewayHasResolvableHost(gateway) + let availability = self.gatewayController.discoveredGatewayConnectionAvailability(gateway) - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(gateway.name) - .font(OpenClawType.body) - if let host = gateway.lanHost ?? gateway.tailnetDns { - Text(host) - .font(OpenClawType.footnote) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(gateway.name) + .font(OpenClawType.body) + if let host = gateway.lanHost ?? gateway.tailnetDns { + Text(host) + .font(OpenClawType.footnote) + .foregroundStyle(.secondary) + } } - } - Spacer() - Button { - Task { await self.connectDiscoveredGateway(gateway) } - } label: { - if self.connectingGatewayID == gateway.id { - ProgressView() - .progressViewStyle(.circular) - } else if !hasHost { - Text("Resolving…") - .font(OpenClawType.subheadSemiBold) + Spacer() + if availability.canConnect { + Button { + Task { await self.connectDiscoveredGateway(gateway) } + } label: { + if self.connectingGatewayID == gateway.id { + ProgressView() + .progressViewStyle(.circular) + } else { + Text(availability.actionTitle) + .font(OpenClawType.subheadSemiBold) + } + } + .font(OpenClawType.subheadSemiBold) + .disabled(self.connectingGatewayID != nil) } else { - Text("Connect") + Text(availability.actionTitle) .font(OpenClawType.subheadSemiBold) + .foregroundStyle(OpenClawBrand.warn) } } - .font(OpenClawType.subheadSemiBold) - .disabled(self.connectingGatewayID != nil || !hasHost) + + if let guidanceText = availability.guidanceText { + Text(guidanceText) + .font(OpenClawType.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } } } @@ -1660,13 +1672,6 @@ extension OnboardingWizardView { } } - private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { - let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !lanHost.isEmpty { return true } - let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !tailnetDns.isEmpty - } - private func connectManual(setupAttemptID: UUID? = nil) async { if let setupAttemptID { guard self.setupAttemptID == setupAttemptID else { return } diff --git a/apps/ios/Sources/RootTabs.swift b/apps/ios/Sources/RootTabs.swift index 580c097473ea..299d75f1c53d 100644 --- a/apps/ios/Sources/RootTabs.swift +++ b/apps/ios/Sources/RootTabs.swift @@ -909,10 +909,13 @@ struct RootTabs: View { .sheet(item: self.$presentedSheet) { sheet in switch sheet { case .quickSetup: - GatewayQuickSetupSheet() - .environment(self.appModel) - .environment(self.gatewayController) - .openClawSheetChrome() + GatewayQuickSetupSheet(onUseManualSetup: { + self.presentedSheet = nil + self.selectSettingsRoute(.gateway) + }) + .environment(self.appModel) + .environment(self.gatewayController) + .openClawSheetChrome() } } .fullScreenCover(isPresented: self.$showOnboarding) { diff --git a/apps/ios/Tests/GatewayConnectionSecurityTests.swift b/apps/ios/Tests/GatewayConnectionSecurityTests.swift index 9af8361595fd..8e8b8a371df5 100644 --- a/apps/ios/Tests/GatewayConnectionSecurityTests.swift +++ b/apps/ios/Tests/GatewayConnectionSecurityTests.swift @@ -16,7 +16,8 @@ import Testing lanHost: String?, tailnetDns: String?, gatewayPort: Int?, - fingerprint: String?) -> GatewayDiscoveryModel.DiscoveredGateway + fingerprint: String?, + tlsEnabled: Bool = true) -> GatewayDiscoveryModel.DiscoveredGateway { let endpoint: NWEndpoint = .service(name: "Test", type: "_openclaw-gw._tcp", domain: "local.", interface: nil) return GatewayDiscoveryModel.DiscoveredGateway( @@ -28,7 +29,7 @@ import Testing tailnetDns: tailnetDns, gatewayPort: gatewayPort, canvasPort: nil, - tlsEnabled: true, + tlsEnabled: tlsEnabled, tlsFingerprintSha256: fingerprint, cliPath: nil) } @@ -75,6 +76,117 @@ import Testing #expect(params?.allowTOFU == false) } + @Test @MainActor func `discovered gateway availability requires advertised TLS or a stored pin`() { + let unpinnedID = "test|\(UUID().uuidString)" + let pinnedID = "test|\(UUID().uuidString)" + defer { + clearTLSFingerprint(stableID: unpinnedID) + clearTLSFingerprint(stableID: pinnedID) + } + self.clearTLSFingerprint(stableID: unpinnedID) + self.clearTLSFingerprint(stableID: pinnedID) + + let controller = self.makeController() + let unavailable = self.makeDiscoveredGateway( + stableID: unpinnedID, + lanHost: "gateway.local", + tailnetDns: nil, + gatewayPort: 18789, + fingerprint: "untrusted-txt-fingerprint", + tlsEnabled: false) + let advertisedTLS = self.makeDiscoveredGateway( + stableID: unpinnedID, + lanHost: "gateway.local", + tailnetDns: nil, + gatewayPort: 18789, + fingerprint: nil) + let pinned = self.makeDiscoveredGateway( + stableID: pinnedID, + lanHost: "gateway.local", + tailnetDns: nil, + gatewayPort: 18789, + fingerprint: nil, + tlsEnabled: false) + + #expect(controller.discoveredGatewayConnectionAvailability(unavailable) == .secureTransportRequired) + #expect(controller.discoveredGatewayConnectionAvailability(unavailable).canConnect == false) + #expect(controller.discoveredGatewayConnectionAvailability(unavailable).guidanceText? + .contains("trusted private-LAN") == true) + #expect(controller.discoveredGatewayConnectionAvailability(advertisedTLS) == .available) + + GatewayTLSStore.saveFingerprint("stored-pin", stableID: pinnedID) + #expect(controller.discoveredGatewayConnectionAvailability(pinned) == .available) + } + + @Test @MainActor func `blocked discovered gateway does no connection work`() async { + let stableID = "test|\(UUID().uuidString)" + defer { clearTLSFingerprint(stableID: stableID) } + self.clearTLSFingerprint(stableID: stableID) + let tcpCalls = OSAllocatedUnfairLock(initialState: 0) + let tlsCalls = OSAllocatedUnfairLock(initialState: 0) + let resolverCalls = OSAllocatedUnfairLock(initialState: 0) + let appModel = NodeAppModel() + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in + tcpCalls.withLock { $0 += 1 } + return true + }, + tlsFingerprintProbe: { _ in + tlsCalls.withLock { $0 += 1 } + return .fingerprint("unexpected") + }, + serviceEndpointResolver: { _ in + resolverCalls.withLock { $0 += 1 } + return (host: "unexpected.example", port: 443) + }) + let gateway = self.makeDiscoveredGateway( + stableID: stableID, + lanHost: "untrusted-txt.example", + tailnetDns: nil, + gatewayPort: 18789, + fingerprint: "untrusted-txt-fingerprint", + tlsEnabled: false) + + let message = await controller.connectWithDiagnostics(gateway) + + #expect(message?.contains("Manual Setup") == true) + #expect(resolverCalls.withLock { $0 } == 0) + #expect(tcpCalls.withLock { $0 } == 0) + #expect(tlsCalls.withLock { $0 } == 0) + #expect(controller.pendingTrustPrompt == nil) + #expect(appModel.activeGatewayConnectConfig == nil) + } + + @Test @MainActor func `quick setup prefers an eligible discovered gateway`() { + let blockedID = "test|\(UUID().uuidString)" + let eligibleID = "test|\(UUID().uuidString)" + defer { + clearTLSFingerprint(stableID: blockedID) + clearTLSFingerprint(stableID: eligibleID) + } + self.clearTLSFingerprint(stableID: blockedID) + self.clearTLSFingerprint(stableID: eligibleID) + let controller = self.makeController() + let blocked = self.makeDiscoveredGateway( + stableID: blockedID, + lanHost: nil, + tailnetDns: nil, + gatewayPort: nil, + fingerprint: nil, + tlsEnabled: false) + let eligible = self.makeDiscoveredGateway( + stableID: eligibleID, + lanHost: nil, + tailnetDns: nil, + gatewayPort: nil, + fingerprint: nil) + controller._test_setGateways([blocked, eligible]) + + #expect(controller.preferredDiscoveredGateway()?.stableID == eligibleID) + } + @Test @MainActor func `autoconnect requires stored pin for discovered gateways`() { let stableID = "test|\(UUID().uuidString)" defer { clearTLSFingerprint(stableID: stableID) } @@ -288,6 +400,24 @@ import Testing #expect(appModel.gatewayStatusText == "Can't reach gateway at \(host):\(port). Check Tailscale or LAN.") } + @Test @MainActor func `unreachable tailscale host explains serve publishing`() async { + let host = "gateway-\(UUID().uuidString).example.ts.net" + let port = 443 + let stableID = "manual|\(host.lowercased())|\(port)" + defer { clearTLSFingerprint(stableID: stableID) } + self.clearTLSFingerprint(stableID: stableID) + let appModel = NodeAppModel() + let controller = GatewayConnectionController( + appModel: appModel, + startDiscovery: false, + tcpReachabilityProbe: { _, _, _, _ in false }) + + await controller.connectManual(host: host, port: port, useTLS: true) + + #expect(appModel.gatewayStatusText == + "Can't reach gateway at \(host):\(port). Verify Tailscale Serve is enabled and publishes this Gateway.") + } + @Test @MainActor func `manual first use TLS probe reports handshake timeout without trust prompt`() async { let host = "gateway-\(UUID().uuidString).example.com" let port = 18789 diff --git a/apps/ios/Tests/LocationPermissionSummaryTests.swift b/apps/ios/Tests/LocationPermissionSummaryTests.swift index 60dc1b9c120d..f43ed90039aa 100644 --- a/apps/ios/Tests/LocationPermissionSummaryTests.swift +++ b/apps/ios/Tests/LocationPermissionSummaryTests.swift @@ -4,6 +4,157 @@ import Testing @testable import OpenClawKit @Suite(.serialized) struct LocationPermissionSummaryTests { + @Test func `location settings presentation uses apple access labels`() { + let whileUsing = LocationSettingsPresentation( + selectedMode: .whileUsing, + summary: LocationPermissionSummary( + desiredMode: .whileUsing, + locationServicesEnabled: true, + authorizationStatus: .authorizedWhenInUse, + accuracyAuthorization: .fullAccuracy)) + let always = LocationSettingsPresentation( + selectedMode: .always, + summary: LocationPermissionSummary( + desiredMode: .always, + locationServicesEnabled: true, + authorizationStatus: .authorizedAlways, + accuracyAuthorization: .fullAccuracy)) + let whileUsingWithAlwaysGrant = LocationSettingsPresentation( + selectedMode: .whileUsing, + summary: LocationPermissionSummary( + desiredMode: .whileUsing, + locationServicesEnabled: true, + authorizationStatus: .authorizedAlways, + accuracyAuthorization: .fullAccuracy)) + + #expect(whileUsing.accessLevelText == "While Using the App") + #expect(always.accessLevelText == "Always") + #expect(whileUsingWithAlwaysGrant.accessLevelText == "While Using the App") + #expect(OpenClawLocationMode.off.locationAccessLevelText == nil) + } + + @Test func `location sharing control follows selected mode while permission is pending`() { + let presentation = LocationSettingsPresentation( + selectedMode: .whileUsing, + summary: LocationPermissionSummary( + desiredMode: .whileUsing, + locationServicesEnabled: true, + authorizationStatus: .notDetermined, + accuracyAuthorization: .fullAccuracy)) + + #expect(presentation.sharingControlIsOn) + #expect(presentation.showsAccessLevel) + #expect(presentation.accessLevelText == "While Using the App") + #expect(presentation.statusText == "iOS permission is required to share location.") + #expect(presentation.toggleAction() == .setMode(.off)) + } + + @Test func `location sharing toggle from off requests while using by default`() { + let presentation = LocationSettingsPresentation( + selectedMode: .off, + summary: LocationPermissionSummary( + desiredMode: .off, + locationServicesEnabled: true, + authorizationStatus: .notDetermined, + accuracyAuthorization: .fullAccuracy)) + + #expect(!presentation.sharingControlIsOn) + #expect(!presentation.showsAccessLevel) + #expect(presentation.toggleAction() == .setMode(.whileUsing)) + } + + @Test func `access level stays hidden when sharing is off despite retained ios grant`() { + let presentation = LocationSettingsPresentation( + selectedMode: .off, + summary: LocationPermissionSummary( + desiredMode: .off, + locationServicesEnabled: true, + authorizationStatus: .authorizedAlways, + accuracyAuthorization: .fullAccuracy)) + + #expect(!presentation.sharingControlIsOn) + #expect(!presentation.showsAccessLevel) + #expect(presentation.accessLevelText == nil) + } + + @Test func `location sharing toggle opens app settings when denied`() { + let presentation = LocationSettingsPresentation( + selectedMode: .off, + summary: LocationPermissionSummary( + desiredMode: .off, + locationServicesEnabled: true, + authorizationStatus: .denied, + accuracyAuthorization: .fullAccuracy)) + + #expect(!presentation.sharingControlIsOn) + #expect(!presentation.showsAccessLevel) + #expect(presentation.accessLevelText == nil) + #expect(presentation.statusText == nil) + #expect(presentation.toggleAction() == .openAppSettings(.whileUsing)) + } + + @Test func `access level reports selection and warns when ios grant is lower`() { + let presentation = LocationSettingsPresentation( + selectedMode: .always, + summary: LocationPermissionSummary( + desiredMode: .always, + locationServicesEnabled: true, + authorizationStatus: .authorizedWhenInUse, + accuracyAuthorization: .fullAccuracy)) + + #expect(presentation.sharingControlIsOn) + #expect(presentation.showsAccessLevel) + #expect(presentation.accessLevelText == "Always") + #expect(presentation.statusText == "iOS currently allows location only while using the app.") + #expect(presentation.accessLevelAction(mode: .always) == .setMode(.always)) + #expect(presentation.accessLevelAction(mode: .whileUsing) == .setMode(.whileUsing)) + #expect(presentation.toggleAction() == .setMode(.off)) + } + + @Test func `healthy location sharing hides redundant status copy`() { + let presentation = LocationSettingsPresentation( + selectedMode: .whileUsing, + summary: LocationPermissionSummary( + desiredMode: .whileUsing, + locationServicesEnabled: true, + authorizationStatus: .authorizedWhenInUse, + accuracyAuthorization: .fullAccuracy)) + + #expect(presentation.sharingControlIsOn) + #expect(presentation.statusText == nil) + } + + @Test func `global location services off opens app settings action`() { + let presentation = LocationSettingsPresentation( + selectedMode: .off, + summary: LocationPermissionSummary( + desiredMode: .off, + locationServicesEnabled: false, + authorizationStatus: .authorizedWhenInUse, + accuracyAuthorization: .fullAccuracy)) + + #expect(!presentation.sharingControlIsOn) + #expect(!presentation.showsAccessLevel) + #expect(presentation.statusText == nil) + #expect(presentation.toggleAction() == .openAppSettings(.whileUsing)) + } + + @Test func `restricted location permission shows settings guidance`() { + let presentation = LocationSettingsPresentation( + selectedMode: .whileUsing, + summary: LocationPermissionSummary( + desiredMode: .whileUsing, + locationServicesEnabled: true, + authorizationStatus: .restricted, + accuracyAuthorization: .fullAccuracy)) + + #expect(presentation.sharingControlIsOn) + #expect(presentation.showsAccessLevel) + #expect(presentation.statusText == "Location permission is restricted on this device.") + #expect(presentation.toggleAction() == .setMode(.off)) + #expect(presentation.accessLevelAction(mode: .whileUsing) == .openAppSettings(.whileUsing)) + } + @Test func `always desired when in use authorized needs attention`() { let summary = LocationPermissionSummary( desiredMode: .always, diff --git a/apps/ios/Tests/RootTabsSourceGuardTests.swift b/apps/ios/Tests/RootTabsSourceGuardTests.swift index 2fef4e677737..cecf4fabd608 100644 --- a/apps/ios/Tests/RootTabsSourceGuardTests.swift +++ b/apps/ios/Tests/RootTabsSourceGuardTests.swift @@ -790,13 +790,55 @@ struct RootTabsSourceGuardTests { sectionsSource, from: "var privacyDestination: some View", to: "var notificationsDestination: some View") + let locationCard = try Self.extract( + sectionsSource, + from: "var locationModeCard: some View", + to: "var agentSelectionCard: some View") + let pendingLocationApplication = try Self.extract( + actionsSource, + from: "func applyPendingLocationModeIfAvailable()", + to: "func openLocationSettings()") #expect(!settingsList.contains("route: .notifications")) #expect(privacyDestination.contains("self.notificationsSection")) + #expect(privacyDestination.contains("title: \"Camera Access\"")) + #expect(privacyDestination.contains("self.locationModeCard")) + #expect(privacyDestination.contains("title: \"Background Listening\"")) + #expect(!privacyDestination.contains("title: \"Privacy\"")) #expect(sectionsSource.contains("Toggle(\"Notifications\", isOn: self.notificationToggleBinding)")) + #expect(locationCard.contains("Text(\"Location\")")) + #expect(locationCard.contains(".font(OpenClawType.body)")) + #expect(locationCard.contains(".accessibilityLabel(\"Location Sharing\")")) + #expect(!locationCard.contains("Text(\"Location Sharing\")")) + #expect(!locationCard.contains("SettingsIcon(")) + #expect(locationCard.contains("Text(\"Access Level\")")) + #expect(!locationCard.contains("Text(\"Open iOS Settings\")")) + #expect(locationCard.contains(".opacity(self.isChangingLocationMode ? 0 : 1)")) + #expect(locationCard.contains(".multilineTextAlignment(.trailing)")) + #expect(locationCard.contains(".lineLimit(2)")) + #expect(locationCard.contains(".accessibilityElement(children: .ignore)")) + #expect(locationCard.contains(".accessibilityLabel(\"Access Level\")")) + #expect(!locationCard.contains(".minimumScaleFactor(")) + #expect(locationCard.contains("showLocationAccessDialog")) + #expect(locationCard.contains("chevron.up.chevron.down")) + #expect(locationCard.contains("Chooses While Using the App or Always")) + #expect(!locationCard.contains("Picker(\"Location\"")) + #expect(!locationCard.contains("Text(\"While Using\")")) + #expect(!locationCard.contains("Choose a location mode")) + #expect(!actionsSource.contains("Location permission was not granted.")) + #expect(!actionsSource.contains("presentation.showsOpenSettingsAction")) + #expect(actionsSource.contains("func selectLocationAccessLevel")) + #expect(actionsSource.contains("presentation.accessLevelAction(mode: mode)")) + #expect(actionsSource.contains("self.pendingLocationMode ?? self.selectedLocationMode")) + #expect(pendingLocationApplication.contains( + "self.locationSettingsPresentation(selectedMode: mode).statusText")) + let pendingClear = try #require(pendingLocationApplication.range(of: "self.pendingLocationMode = nil")) + let unavailableReturn = try #require( + pendingLocationApplication.range(of: "guard summary.effectiveMode != .off else")) + #expect(pendingClear.lowerBound < unavailableReturn.lowerBound) #expect(actionsSource.contains("UIApplication.shared.unregisterForRemoteNotifications()")) #expect(actionsSource.contains("UIApplication.openNotificationSettingsURLString")) - #expect(!actionsSource.contains("UIApplication.openSettingsURLString")) + #expect(actionsSource.contains("UIApplication.openSettingsURLString")) } @Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws { @@ -1081,6 +1123,34 @@ struct RootTabsSourceGuardTests { #expect(!modelSource.contains("expectedGeneration: UInt64?")) } + @Test func `discovered gateway surfaces share secure connection availability`() throws { + let controllerSource = try String( + contentsOf: Self.gatewayConnectionControllerSourceURL(), + encoding: .utf8) + let settingsSource = try String( + contentsOf: Self.settingsProTabSectionsSourceURL(), + encoding: .utf8) + let quickSetupSource = try String( + contentsOf: Self.gatewayQuickSetupSourceURL(), + encoding: .utf8) + let onboardingSource = try String( + contentsOf: Self.onboardingWizardSourceURL(), + encoding: .utf8) + let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8) + + #expect(controllerSource.contains("enum DiscoveredGatewayConnectionAvailability")) + #expect(controllerSource.contains("gateway.tlsEnabled || GatewayTLSStore.loadFingerprint")) + #expect(controllerSource.contains("enter your Tailscale Serve HTTPS host in Manual Setup")) + #expect(settingsSource.contains("discoveredGatewayConnectionAvailability(gateway)")) + #expect(quickSetupSource.contains("discoveredGatewayConnectionAvailability(candidate)")) + #expect(quickSetupSource.contains("Text(\"Use Manual Setup\")")) + #expect(quickSetupSource.contains("self.gatewayController.preferredDiscoveredGateway()")) + #expect(onboardingSource.contains("discoveredGatewayConnectionAvailability(gateway)")) + #expect(!onboardingSource.contains("gatewayHasResolvableHost")) + #expect(rootSource.contains("GatewayQuickSetupSheet(onUseManualSetup:")) + #expect(rootSource.contains("self.selectSettingsRoute(.gateway)")) + } + @Test func `gateway credential fields update before endpoint persistence is available`() throws { let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8) let settingsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8) @@ -1110,7 +1180,7 @@ struct RootTabsSourceGuardTests { let modeDefaults = try Self.extract( source, from: "private func applyModeDefaults(_ mode: OnboardingConnectionMode)", - to: "private func gatewayHasResolvableHost") + to: "private func connectManual") #expect(modeDefaults.contains("let previousStableID = self.currentManualGatewayStableID")) #expect(modeDefaults.contains("previousStableID != self.currentManualGatewayStableID")) @@ -1540,6 +1610,13 @@ struct RootTabsSourceGuardTests { .appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift") } + private static func gatewayQuickSetupSourceURL() -> URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/Gateway/GatewayQuickSetupSheet.swift") + } + private static func qrScannerSourceURL() -> URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent() diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 6b994115f706..6ea1ee1a5ced 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -61,6 +61,25 @@ struct SwiftUIRenderSmokeTests { } } + @Test @MainActor func `settings Privacy destination builds across appearance and type size`() { + for scheme in [ColorScheme.light, ColorScheme.dark] { + for typeSize in [DynamicTypeSize.large, .accessibility2] { + let appModel = NodeAppModel() + let gatewayController = GatewayConnectionController(appModel: appModel, startDiscovery: false) + + let root = SettingsProTab(directRoute: .privacy) + .environment(AppAppearanceModel()) + .environment(appModel) + .environment(appModel.voiceWake) + .environment(gatewayController) + .preferredColorScheme(scheme) + .environment(\.dynamicTypeSize, typeSize) + + _ = Self.host(root, size: CGSize(width: 393, height: 852)) + } + } + } + @Test @MainActor func `settings Licenses destination builds in light and dark mode`() { var windows: [UIWindow] = [] defer { windows.forEach { $0.isHidden = true } } @@ -188,7 +207,7 @@ struct SwiftUIRenderSmokeTests { } } - @Test @MainActor func gatewayQuickSetupBuildsCandidateAndEmptyStates() { + @Test @MainActor func `gateway quick setup builds candidate and empty states`() { let gateways: [GatewayDiscoveryModel.DiscoveredGateway?] = [ .previewGateway, nil, @@ -211,7 +230,7 @@ struct SwiftUIRenderSmokeTests { } } - @Test @MainActor func onboardingActivationScreensBuildAcrossAppearanceAndTypeSize() { + @Test @MainActor func `onboarding activation screens build across appearance and type size`() { let screens: [AnyView] = [ AnyView(OnboardingIntroStep(onContinue: {})), AnyView(OnboardingWelcomeStep( diff --git a/apps/macos/Sources/OpenClaw/AppState.swift b/apps/macos/Sources/OpenClaw/AppState.swift index 2543d3e81d5c..843b38004725 100644 --- a/apps/macos/Sources/OpenClaw/AppState.swift +++ b/apps/macos/Sources/OpenClaw/AppState.swift @@ -14,6 +14,7 @@ final class AppState { private var isInitializing = true private var isApplyingRemoteTokenConfig = false private var configWatcher: ConfigFileWatcher? + private var lastConfigFingerprint: Data? private var suppressVoiceWakeGlobalSync = false private var voiceWakeGlobalSyncTask: Task? @@ -365,6 +366,7 @@ final class AppState { } let configRoot = OpenClawConfigFile.loadDict() + self.lastConfigFingerprint = Self.configFingerprint(configRoot) let configRemoteToken = GatewayRemoteConfig.resolveTokenValue(root: configRoot) let configRemoteResolution = GatewayRemoteConfig.resolveTransportResolution(root: configRoot) let configRemoteTransport = configRemoteResolution.transport @@ -579,8 +581,18 @@ final class AppState { private func applyConfigFromDisk() { let root = OpenClawConfigFile.loadDict() + let fingerprint = Self.configFingerprint(root) + let changed = fingerprint != self.lastConfigFingerprint + self.lastConfigFingerprint = fingerprint self.applyConfigOverrides(root) MacNodeModeCoordinator.shared.refresh() + if changed { + NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil) + } + } + + private static func configFingerprint(_ root: [String: Any]) -> Data? { + try? JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) } private func applyConfigOverrides(_ root: [String: Any]) { @@ -733,6 +745,8 @@ final class AppState { Self.logger.warning("gateway config sync rejected to protect persisted gateway auth/mode") return } + self.lastConfigFingerprint = Self.configFingerprint(synced.root) + NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil) } func triggerVoiceEars(ttl: TimeInterval? = 5) { diff --git a/apps/macos/Sources/OpenClaw/ConfigStore.swift b/apps/macos/Sources/OpenClaw/ConfigStore.swift index b16fef293e6c..c07ffbbe176d 100644 --- a/apps/macos/Sources/OpenClaw/ConfigStore.swift +++ b/apps/macos/Sources/OpenClaw/ConfigStore.swift @@ -83,6 +83,7 @@ enum ConfigStore { } } } + NotificationCenter.default.post(name: .openclawConfigDidChange, object: nil) } @MainActor @@ -153,3 +154,7 @@ enum ConfigStore { } #endif } + +extension Notification.Name { + static let openclawConfigDidChange = Notification.Name("openclaw.config.did-change") +} diff --git a/apps/macos/Sources/OpenClaw/CrestodianSettings.swift b/apps/macos/Sources/OpenClaw/CrestodianSettings.swift index b0e79912ed16..35b46ea39446 100644 --- a/apps/macos/Sources/OpenClaw/CrestodianSettings.swift +++ b/apps/macos/Sources/OpenClaw/CrestodianSettings.swift @@ -1,12 +1,17 @@ import SwiftUI +enum CrestodianAvailability { + static func shouldShow(configuredModel: String?) -> Bool { + !(configuredModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } +} + /// Settings pane hosting the Crestodian setup/repair chat. /// -/// Crestodian answers even when no model is configured (deterministic engine -/// on the gateway), so this pane is the "always works" place to fix config, -/// switch models, connect channels, or run doctor — in plain language. +/// The parent settings view exposes this pane only after inference is configured. struct CrestodianSettings: View { let isActive: Bool + let onReplyReceived: () -> Void @State private var chat = CrestodianOnboardingChatModel( welcomeVariant: nil, sessionPrefix: "mac-settings-crestodian") @@ -15,8 +20,8 @@ struct CrestodianSettings: View { VStack(alignment: .leading, spacing: 20) { SettingsPageHeader( title: "Crestodian", - subtitle: "Your setup helper. It can check status, fix config, switch models, " + - "and connect channels — even when the agent itself is not working.") + subtitle: "Your AI-powered setup helper. It can check status, fix config, " + + "switch models, and connect channels.") SettingsCardGroup("Chat") { CrestodianOnboardingChatView(model: self.chat) @@ -31,10 +36,21 @@ struct CrestodianSettings: View { .settingsDetailContent() .task(id: self.isActive) { guard self.isActive else { return } - self.chat.onAgentHandoff = { - AppNavigationActions.openChat() - } + Self.configureChatCallbacks( + for: self.chat, + onReplyReceived: self.onReplyReceived) await self.chat.startIfNeeded() } } + + @MainActor + static func configureChatCallbacks( + for chat: CrestodianOnboardingChatModel, + onReplyReceived: @escaping () -> Void) + { + chat.onAgentHandoff = { + AppNavigationActions.openChat() + } + chat.onReplyReceived = onReplyReceived + } } diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index 03e0f37f23bc..6c0e5af96cbe 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -354,6 +354,15 @@ actor GatewayConnection { } } + func isCurrentRoute(_ route: Route) async -> Bool { + guard let cfg = try? await configProvider() else { return false } + return route.generation == self.routeGeneration && + route.matches(cfg) && + self.configuredURL == route.url && + self.configuredToken == route.token && + self.configuredPassword == route.password + } + func supportsServerCapability( _ capability: GatewayServerCapability, ifCurrentRoute route: Route) async -> Bool? @@ -388,6 +397,27 @@ actor GatewayConnection { return SessionRoutingIdentity(defaultAgentID: result.defaultid, contract: contract) } + func configuredInferenceModel(ifCurrentRoute route: Route) async throws -> String? { + let data = try await request( + method: "agents.list", + params: [:], + timeoutMs: 15000, + ifCurrentRoute: route) + guard await self.isCurrentRoute(route) else { + throw CancellationError() + } + return try Self.decodeConfiguredInferenceModel(data) + } + + static func decodeConfiguredInferenceModel(_ data: Data) throws -> String? { + let result = try JSONDecoder().decode(AgentsListResult.self, from: data) + let primary = result.agents + .first(where: { $0.id == result.defaultid })? + .model?["primary"]?.value as? String + let trimmed = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + func authSource() async -> GatewayAuthSource? { guard let client else { return nil } return await client.authSource() diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index 625c32d1c67c..8af92364b311 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -373,7 +373,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if let state { let shouldWaitForConnection = state.connectionMode != .unconfigured if !shouldWaitForConnection { - self.scheduleFirstRunOnboardingIfNeeded(gatewayConnected: false) + Task { @MainActor in + await self.scheduleFirstRunOnboardingIfNeeded(gatewayConnected: false) + } } Task { @MainActor in // Validate PATH selection before local startup. Existing installs may not @@ -385,7 +387,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { mode: state.connectionMode, paused: state.isPaused) guard shouldWaitForConnection else { return } - self.scheduleFirstRunOnboardingIfNeeded( + await self.scheduleFirstRunOnboardingIfNeeded( gatewayConnected: ControlChannel.shared.state == .connected) } } @@ -457,22 +459,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { connectionMode: AppState.ConnectionMode, onboardingSeen: Bool, hasStoredConnectionMode: Bool, - gatewayConnected: Bool) -> Bool + gatewayConnected: Bool, + configuredInferenceModel: String?) -> Bool { - connectionMode != .unconfigured && !onboardingSeen && !hasStoredConnectionMode && gatewayConnected + let model = configuredInferenceModel?.trimmingCharacters(in: .whitespacesAndNewlines) + return connectionMode != .unconfigured && + !onboardingSeen && + !hasStoredConnectionMode && + gatewayConnected && + model?.isEmpty == false } - private func scheduleFirstRunOnboardingIfNeeded(gatewayConnected: Bool) { + private func scheduleFirstRunOnboardingIfNeeded(gatewayConnected: Bool) async { let connectionMode = AppStateStore.shared.connectionMode let onboardingSeen = AppStateStore.shared.onboardingSeen // A stored app mode means onboarding already selected a Gateway; reconnecting // must not turn an interrupted first-run flow into a completed installation. let hasStoredConnectionMode = UserDefaults.standard.object(forKey: connectionModeKey) != nil + var configuredInferenceModel: String? + if connectionMode != .unconfigured, + !onboardingSeen, + !hasStoredConnectionMode, + gatewayConnected, + let route = await GatewayConnection.shared.captureRoute() + { + // Bind inference discovery to the connected route. A socket without a + // default-agent model cannot run Crestodian and must stay in onboarding. + configuredInferenceModel = try? await GatewayConnection.shared.configuredInferenceModel( + ifCurrentRoute: route) + } let shouldOpenDashboard = Self.shouldOpenDashboardInsteadOfOnboarding( connectionMode: connectionMode, onboardingSeen: onboardingSeen, hasStoredConnectionMode: hasStoredConnectionMode, - gatewayConnected: gatewayConnected) + gatewayConnected: gatewayConnected, + configuredInferenceModel: configuredInferenceModel) if connectionMode != .unconfigured, onboardingSeen || shouldOpenDashboard { OnboardingController.markComplete() if shouldOpenDashboard { diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 22e42af96aaf..491898d3f6e3 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -128,7 +128,7 @@ struct OnboardingView: View { @State var gatewayDiscovery: GatewayDiscoveryModel @State var onboardingChatModel: OpenClawChatViewModel @State var onboardingSkillsModel = SkillsSettingsModel() - @State var crestodianChat = CrestodianOnboardingChatModel() + @State var crestodianState = OnboardingCrestodianChatState() @State var aiSetup = OnboardingAISetupModel() @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? @@ -147,9 +147,15 @@ struct OnboardingView: View { let permissionsPageIndex = 5 - /// Chat-like pages shrink the mascot so the conversation gets the room. + /// Only the full-page chat shrinks the mascot so the conversation gets the room. var usesCompactHero: Bool { - [self.aiPageIndex, self.onboardingChatPageIndex].contains(self.activePageIndex) + Self.shouldUseCompactHero( + activePageIndex: self.activePageIndex, + onboardingChatPageIndex: self.onboardingChatPageIndex) + } + + static func shouldUseCompactHero(activePageIndex: Int, onboardingChatPageIndex: Int) -> Bool { + activePageIndex == onboardingChatPageIndex } var heroFrameHeight: CGFloat { @@ -228,9 +234,28 @@ struct OnboardingView: View { /// server-side on that success). "Configure later" on the connection page /// remains the explicit skip path. var isAISetupBlocking: Bool { - self.activePageIndex == self.aiPageIndex && - self.state.connectionMode != .unconfigured && - !self.aiSetup.connected + Self.shouldBlockAISetup( + currentPage: self.currentPage, + pageOrder: self.pageOrder, + aiPageIndex: self.aiPageIndex, + connectionMode: self.state.connectionMode, + connected: self.aiSetup.connected) + } + + static func shouldBlockAISetup( + currentPage: Int, + pageOrder: [Int], + aiPageIndex: Int, + connectionMode: AppState.ConnectionMode, + connected: Bool) -> Bool + { + guard connectionMode != .unconfigured, + !connected, + let aiPageCursor = pageOrder.firstIndex(of: aiPageIndex) + else { + return false + } + return currentPage >= aiPageCursor } var canAdvance: Bool { diff --git a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift index 7e8c53b0f63c..93f44135747b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingAISetup.swift @@ -1,6 +1,8 @@ +import AppKit import Foundation import Observation import OpenClawIPC +import OpenClawKit import SwiftUI /// Structured "Connect your AI" onboarding step. @@ -30,10 +32,19 @@ final class OnboardingAISetupModel { enum CandidateStatus: Equatable { case untried case testing - case failed(message: String) + case failed(Failure) case connected } + struct Failure: Equatable { + let summary: String + let detail: String? + + var copyText: String { + self.detail ?? self.summary + } + } + enum Phase: Equatable { case idle case detecting @@ -65,14 +76,14 @@ final class OnboardingAISetupModel { private(set) var selectedKind: String? private(set) var connectedModelRef: String? private(set) var connectedLatencyMs: Int? - private(set) var detectError: String? + private(set) var detectError: Failure? /// Set once every detected candidate failed; opens the manual key form. private(set) var exhaustedAutoCandidates = false var manualProviderID = "" var manualKey: String = "" private(set) var manualTesting = false - private(set) var manualError: String? + private(set) var manualError: Failure? var showManualEntry = false var selectedManualProvider: ManualProvider? { @@ -125,7 +136,15 @@ final class OnboardingAISetupModel { } func retryFromScratch() { + self.resetForGatewayChange() + self.started = true + Task { await self.detectAndAutoConnect() } + } + + /// Cancel route-bound work and discard results that belong to the previous Gateway. + func resetForGatewayChange() { self.attemptToken = UUID() + self.started = false self.phase = .idle self.candidates = [] self.manualProviders = [] @@ -133,12 +152,15 @@ final class OnboardingAISetupModel { self.providerCatalogError = nil self.statuses = [:] self.selectedKind = nil + self.connectedModelRef = nil + self.connectedLatencyMs = nil self.detectError = nil self.exhaustedAutoCandidates = false + self.manualProviderID = "" + self.manualKey = "" self.manualError = nil self.manualTesting = false self.showManualEntry = false - Task { await self.detectAndAutoConnect() } } func detectAndAutoConnect() async { @@ -186,7 +208,7 @@ final class OnboardingAISetupModel { } catch { guard token == self.attemptToken else { return } self.phase = .ready - self.detectError = Self.friendlyTransportError(error.localizedDescription) + self.detectError = Self.transportFailure(error.localizedDescription) self.showManualEntry = self.candidates.isEmpty } } @@ -200,6 +222,45 @@ final class OnboardingAISetupModel { return raw } + static func activationRequestTimeoutMs(for kind: String) -> Double { + // Codex can spend 305s installing its runtime plugin before the 90s live probe. + // Keep a bounded client deadline with room for registry refresh and finalization. + kind == "codex-cli" ? 480_000 : 150_000 + } + + static func activationOutcomeDeadlineMs(for kind: String) -> Double { + // A request timeout removes only the client waiter. Keep a short final window + // to observe config that the still-running Gateway operation just persisted. + self.activationRequestTimeoutMs(for: kind) + 30000 + } + + static func activationIsPersisted( + expectedModel: String, + setupComplete: Bool, + configuredModel: String?) -> Bool + { + setupComplete && configuredModel == expectedModel + } + + enum ActivationReconciliationMode: Equatable { + case none + case immediate + case polling + } + + static func activationReconciliationMode(after error: Error) -> ActivationReconciliationMode { + // Decode failures happen after the side-effectful RPC returned bytes, so check persisted + // state once. Only transport-unknown outcomes need the bounded polling window. + if error is DecodingError { return .immediate } + if error is GatewayResponseError || + error is GatewayConnectAuthError || + error is GatewayTLSValidationError + { + return .none + } + return .polling + } + /// Candidates the automatic ladder may try: skip definitively logged-out /// installs and anything already attempted. private func autoCandidateAfter(kind: String?) -> Candidate? { @@ -222,6 +283,10 @@ final class OnboardingAISetupModel { func activate(kind: String) async { let token = self.attemptToken + let clock = ContinuousClock() + let requestTimeoutMs = Self.activationRequestTimeoutMs(for: kind) + let outcomeDeadlineMs = Self.activationOutcomeDeadlineMs(for: kind) + let reconciliationDeadline = clock.now.advanced(by: .milliseconds(Int64(outcomeDeadlineMs))) self.selectedKind = kind self.phase = .testing self.statuses[kind] = .testing @@ -229,14 +294,14 @@ final class OnboardingAISetupModel { let data = try await GatewayConnection.shared.request( method: "crestodian.setup.activate", params: ["kind": AnyCodable(kind)], - timeoutMs: 150_000, + timeoutMs: requestTimeoutMs, retryTransportFailures: false) guard token == self.attemptToken else { return } let result = try JSONDecoder().decode(ActivateResult.self, from: data) if result.ok { self.finishConnected(kind: kind, result: result) } else { - self.statuses[kind] = .failed(message: Self.friendlyFailure( + self.statuses[kind] = .failed(Self.failure( label: self.candidates.first { $0.kind == kind }?.label ?? kind, status: result.status, error: result.error)) @@ -244,15 +309,27 @@ final class OnboardingAISetupModel { } } catch { guard token == self.attemptToken else { return } - // Activating a CLI candidate can install a provider plugin (Codex), - // and the gateway restarts itself to load it — dropping this RPC's - // socket after the server already tested and persisted the model. - // A transport error means "outcome unknown", not "failed": re-read - // server state before reporting failure. - if await self.reconcileActivationAfterTransportDrop(kind: kind, token: token) { return } + // Activation can persist config before a response is decoded, and Codex plugin + // setup can outlive a dropped socket. Re-read state with an error-specific budget. + switch Self.activationReconciliationMode(after: error) { + case .none: + break + case .immediate: + if await self.reconcilePersistedActivation(kind: kind, token: token) { return } + case .polling: + if await self.reconcileActivationAfterTransportDrop( + kind: kind, + token: token, + deadline: reconciliationDeadline) + { + return + } + } guard token == self.attemptToken else { return } - self.statuses[kind] = .failed(message: Self.friendlyTransportError(error.localizedDescription)) - await self.tryNextAfterFailure(of: kind) + self.statuses[kind] = .failed(Self.transportFailure(error.localizedDescription)) + // Do not start another provider after an RPC or protocol failure: setup may + // already have applied, or a late Codex completion could race the next attempt. + self.phase = .ready } } @@ -260,34 +337,50 @@ final class OnboardingAISetupModel { /// (the gateway restart takes a few seconds) and count the attempt as /// connected only when the server persisted exactly the model this /// candidate would have written. Returns true when reconciled. - private func reconcileActivationAfterTransportDrop(kind: String, token: UUID) async -> Bool { - guard let expected = self.candidates.first(where: { $0.kind == kind })?.modelRef else { - return false - } - for delayMs in [2000, 4000, 6000] { - try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000) - guard token == self.attemptToken else { return false } - guard let data = try? await GatewayConnection.shared.request( - method: "crestodian.setup.detect", - params: [:], - timeoutMs: 10000, - retryTransportFailures: true) - else { continue } - guard token == self.attemptToken else { return false } - guard let result = try? JSONDecoder().decode(DetectResult.self, from: data) else { return false } - if result.setupComplete, result.configuredModel == expected { - self.finishConnected( - kind: kind, - result: ActivateResult(ok: true, modelRef: expected, latencyMs: nil, status: nil, error: nil)) - return true + private func reconcileActivationAfterTransportDrop( + kind: String, + token: UUID, + deadline: ContinuousClock.Instant) async -> Bool + { + let clock = ContinuousClock() + var delayMs: UInt64 = 2000 + while clock.now < deadline { + do { + try await Task.sleep(nanoseconds: delayMs * 1_000_000) + } catch { + return false } - // The gateway answered and setup is not complete: the activation - // genuinely failed before persisting — report the original error. - return false + guard token == self.attemptToken else { return false } + delayMs = min(delayMs * 2, 15000) + if await self.reconcilePersistedActivation(kind: kind, token: token) { return true } + // A healthy detect can race the still-running activation whose socket dropped; + // keep polling instead of falling through to another provider. } return false } + private func reconcilePersistedActivation(kind: String, token: UUID) async -> Bool { + guard let expected = self.candidates.first(where: { $0.kind == kind })?.modelRef, + let data = try? await GatewayConnection.shared.request( + method: "crestodian.setup.detect", + params: [:], + timeoutMs: 10000, + retryTransportFailures: true), + token == self.attemptToken, + let result = try? JSONDecoder().decode(DetectResult.self, from: data), + Self.activationIsPersisted( + expectedModel: expected, + setupComplete: result.setupComplete, + configuredModel: result.configuredModel) + else { + return false + } + self.finishConnected( + kind: kind, + result: ActivateResult(ok: true, modelRef: expected, latencyMs: nil, status: nil, error: nil)) + return true + } + func submitManualKey() { let key = self.manualKey.trimmingCharacters(in: .whitespacesAndNewlines) guard let provider = self.selectedManualProvider, !key.isEmpty, !self.manualTesting else { return } @@ -295,7 +388,11 @@ final class OnboardingAISetupModel { self.manualTesting = true let token = self.attemptToken Task { - defer { self.manualTesting = false } + defer { + if token == self.attemptToken { + self.manualTesting = false + } + } do { let data = try await GatewayConnection.shared.request( method: "crestodian.setup.activate", @@ -312,14 +409,14 @@ final class OnboardingAISetupModel { self.manualKey = "" self.finishConnected(kind: "api-key", result: result) } else { - self.manualError = Self.friendlyFailure( + self.manualError = Self.failure( label: provider.label, status: result.status, error: result.error) } } catch { guard token == self.attemptToken else { return } - self.manualError = error.localizedDescription + self.manualError = Self.transportFailure(error.localizedDescription) } } } @@ -343,8 +440,23 @@ final class OnboardingAISetupModel { self.showManualEntry = true } - /// One friendly sentence per failure bucket; raw detail stays available - /// underneath so support/docs can work with it. + /// Keep the exact Gateway-sanitized error available behind the friendly + /// summary so users can copy it into support or diagnostics. + static func failure(label: String, status: String?, error: String?) -> Failure { + let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines) + return Failure( + summary: self.friendlyFailure(label: label, status: status, error: detail), + detail: detail?.isEmpty == false ? detail : nil) + } + + static func transportFailure(_ raw: String) -> Failure { + let detail = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return Failure( + summary: self.friendlyTransportError(detail), + detail: detail.isEmpty ? nil : detail) + } + + /// One friendly sentence per failure bucket. static func friendlyFailure(label: String, status: String?, error: String?) -> String { let detail = error?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" switch status { @@ -390,8 +502,8 @@ private enum OnboardingAISetupError: LocalizedError { struct OnboardingAISetupView: View { @Bindable var model: OnboardingAISetupModel - @State private var showCrestodianChat = false var crestodianChat: CrestodianOnboardingChatModel + @Binding var showCrestodianChat: Bool var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -446,7 +558,8 @@ struct OnboardingAISetupView: View { if let detectError = self.model.detectError { OnboardingErrorCard( title: "Couldn’t check this Mac for AI accounts", - message: detectError, + message: detectError.summary, + details: detectError.detail, docsSlug: "start/onboarding", retryTitle: "Try again") { @@ -480,15 +593,17 @@ struct OnboardingAISetupView: View { self.manualSection } - HStack { - Spacer(minLength: 0) - Button { - self.showCrestodianChat = true - } label: { - Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble") - .font(.caption) + if CrestodianAvailability.shouldShow(configuredModel: self.model.connectedModelRef) { + HStack { + Spacer(minLength: 0) + Button { + self.showCrestodianChat = true + } label: { + Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble") + .font(.caption) + } + .buttonStyle(.link) } - .buttonStyle(.link) } } @@ -536,41 +651,49 @@ struct OnboardingAISetupView: View { private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View { let status = self.model.statuses[candidate.kind] ?? .untried let selected = self.model.selectedKind == candidate.kind - return Button { - self.model.userSelect(kind: candidate.kind) - } label: { - HStack(alignment: .center, spacing: 12) { - Image(systemName: Self.symbol(for: candidate.kind)) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(candidate.label) - .font(.callout.weight(.semibold)) - if candidate.recommended, status != .connected { - Text("Recommended") - .font(.caption2.weight(.semibold)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Capsule().fill(Color.accentColor.opacity(0.16))) - .foregroundStyle(Color.accentColor) + return VStack(alignment: .leading, spacing: 0) { + Button { + self.model.userSelect(kind: candidate.kind) + } label: { + HStack(alignment: .center, spacing: 12) { + Image(systemName: Self.symbol(for: candidate.kind)) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(candidate.label) + .font(.callout.weight(.semibold)) + if candidate.recommended, status != .connected { + Text("Recommended") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.accentColor.opacity(0.16))) + .foregroundStyle(Color.accentColor) + } } + Text(self.subtitle(for: candidate, status: status)) + .font(.caption) + .foregroundStyle(self.subtitleStyle(for: status)) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) } - Text(self.subtitle(for: candidate, status: status)) - .font(.caption) - .foregroundStyle(self.subtitleStyle(for: status)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + self.trailingIndicator(status: status, selected: selected) } - Spacer(minLength: 0) - self.trailingIndicator(status: status, selected: selected) } - .openClawSelectableRowChrome(selected: selected && status != .failed(message: "")) + .buttonStyle(.plain) + .disabled(self.model.isBusy || self.model.connected) + + if case let .failed(failure) = status { + OnboardingErrorDetails(text: failure.copyText) + .padding(.leading, 38) + .padding(.top, 6) + } } - .buttonStyle(.plain) - .disabled(self.model.isBusy || self.model.connected) + .openClawSelectableRowChrome(selected: selected && !Self.isFailed(status)) } private func subtitle( @@ -580,8 +703,8 @@ struct OnboardingAISetupView: View { switch status { case .testing: "Testing — asking \(candidate.modelRef) for a quick reply…" - case let .failed(message): - message + case let .failed(failure): + failure.summary case .connected: self.model.connectedSummary case .untried: @@ -628,6 +751,13 @@ struct OnboardingAISetupView: View { } } + private static func isFailed(_ status: OnboardingAISetupModel.CandidateStatus) -> Bool { + if case .failed = status { + return true + } + return false + } + private var manualSection: some View { VStack(alignment: .leading, spacing: 10) { if self.model.manualProviders.isEmpty { @@ -695,7 +825,8 @@ struct OnboardingAISetupView: View { if let manualError = self.model.manualError { OnboardingErrorCard( title: "That key didn’t work", - message: manualError, + message: manualError.summary, + details: manualError.detail, docsSlug: "concepts/model-providers", retryTitle: nil, retry: nil) @@ -740,10 +871,27 @@ struct OnboardingAISetupView: View { struct OnboardingErrorCard: View { let title: String let message: String + var details: String? let docsSlug: String var retryTitle: String? var retry: (() -> Void)? + init( + title: String, + message: String, + details: String? = nil, + docsSlug: String, + retryTitle: String? = nil, + retry: (() -> Void)? = nil) + { + self.title = title + self.message = message + self.details = details + self.docsSlug = docsSlug + self.retryTitle = retryTitle + self.retry = retry + } + var body: some View { HStack(alignment: .top, spacing: 10) { Image(systemName: "exclamationmark.triangle.fill") @@ -757,6 +905,9 @@ struct OnboardingErrorCard: View { .foregroundStyle(.secondary) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) + if let details = self.details { + OnboardingErrorDetails(text: details) + } HStack(spacing: 14) { if let retryTitle = self.retryTitle, let retry = self.retry { Button(retryTitle, action: retry) @@ -770,6 +921,13 @@ struct OnboardingErrorCard: View { } .buttonStyle(.link) .font(.caption) + if self.details == nil { + Button("Copy error") { + OnboardingErrorDetails.copy(self.message) + } + .buttonStyle(.link) + .font(.caption) + } } .padding(.top, 2) } @@ -782,3 +940,49 @@ struct OnboardingErrorCard: View { .fill(Color.orange.opacity(0.10))) } } + +private struct OnboardingErrorDetails: View { + let text: String + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.15)) { + self.expanded.toggle() + } + } label: { + Label( + self.expanded ? "Hide details" : "Show details", + systemImage: self.expanded ? "chevron.down" : "chevron.right") + } + .buttonStyle(.link) + .font(.caption) + + if self.expanded { + Text(self.text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.05))) + Button { + Self.copy(self.text) + } label: { + Label("Copy error", systemImage: "doc.on.doc") + } + .buttonStyle(.link) + .font(.caption) + } + } + } + + static func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} diff --git a/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift b/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift index 8f603f286fd6..795363b5b83b 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift @@ -3,9 +3,22 @@ import Observation import OpenClawIPC import SwiftUI +@MainActor +@Observable +final class OnboardingCrestodianChatState { + var chat = CrestodianOnboardingChatModel() + var isPresented = false + + func resetForGatewayChange() { + self.isPresented = false + self.chat.invalidate() + self.chat = CrestodianOnboardingChatModel() + } +} + /// Onboarding talks to Crestodian over the gateway `crestodian.chat` RPC. -/// The conversation is the setup: no wizard steps, no forms. Crestodian works -/// before any model is configured, so this page functions on a fresh machine. +/// The conversation is available after structured setup establishes working +/// inference, so the model-backed helper can answer reliably. @MainActor @Observable final class CrestodianOnboardingChatModel { @@ -30,15 +43,26 @@ final class CrestodianOnboardingChatModel { /// Called after every assistant reply (setup may have applied config). var onReplyReceived: (() -> Void)? - private let sessionId: String + private var sessionId: String + private let sessionPrefix: String + private let gateway: GatewayConnection /// "onboarding" seeds the first-run setup proposal; nil gets the /// status/repair greeting (used by Settings → Crestodian). private let welcomeVariant: String? private var started = false + private var requestGeneration: UInt64? = 0 + private var requestTask: Task? + private var route: GatewayConnection.Route? - init(welcomeVariant: String? = "onboarding", sessionPrefix: String = "mac-onboarding") { + init( + welcomeVariant: String? = "onboarding", + sessionPrefix: String = "mac-onboarding", + gateway: GatewayConnection = .shared) + { self.welcomeVariant = welcomeVariant + self.sessionPrefix = sessionPrefix self.sessionId = "\(sessionPrefix)-\(UUID().uuidString)" + self.gateway = gateway } private struct ChatResult: Decodable { @@ -49,29 +73,96 @@ final class CrestodianOnboardingChatModel { } func startIfNeeded() async { - guard !self.started else { return } + guard !self.started, + self.errorMessage == nil, + let generation = self.requestGeneration + else { return } self.started = true - await self.requestReply(message: nil) + await self.requestReply(message: nil, generation: generation) + if Task.isCancelled, self.requestGeneration == generation { + self.started = false + self.errorMessage = "Crestodian was interrupted. Restart to try again." + } } - func send() { + @discardableResult + func send() -> Task? { let text = self.input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, !self.isSending, self.errorMessage == nil else { return } + guard let generation = self.requestGeneration, + !text.isEmpty, + !self.isSending, + self.errorMessage == nil + else { return nil } self.input = "" self.messages.append(Message( role: .user, text: self.expectsSensitiveReply ? "" : text)) - Task { await self.requestReply(message: text) } + let task = Task { [weak self] in + guard let self else { return } + await self.requestReply(message: text, generation: generation) + } + self.requestTask = task + return task } - func restartAfterError() { - Task { await self.requestReply(message: nil, reset: true) } + @discardableResult + func restartAfterError() -> Task? { + guard let previousGeneration = self.requestGeneration else { return nil } + let generation = previousGeneration &+ 1 + self.requestGeneration = generation + self.requestTask?.cancel() + self.sessionId = "\(self.sessionPrefix)-\(UUID().uuidString)" + self.route = nil + self.started = true + self.messages.removeAll() + self.input = "" + self.expectsSensitiveReply = false + let task = Task { [weak self] in + guard let self else { return } + await self.requestReply(message: nil, generation: generation) + } + self.requestTask = task + return task } - private func requestReply(message: String?, reset: Bool = false) async { + /// Invalidate before replacing the model so queued secret-bearing sends cannot + /// resume against whichever Gateway route becomes current next. + func invalidate() { + self.requestGeneration = nil + self.requestTask?.cancel() + self.requestTask = nil + self.isSending = false + } + + private func isCurrentRequest(_ generation: UInt64) -> Bool { + self.requestGeneration == generation && !Task.isCancelled + } + + private func sessionRoute(for generation: UInt64) async throws -> GatewayConnection.Route { + if let route = self.route { + return route + } + guard let route = await self.gateway.captureRoute() else { + guard self.isCurrentRequest(generation) else { throw CancellationError() } + throw NSError( + domain: "Gateway", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "gateway not configured"]) + } + guard self.isCurrentRequest(generation) else { throw CancellationError() } + self.route = route + return route + } + + private func requestReply(message: String?, generation: UInt64) async { + guard self.isCurrentRequest(generation) else { return } self.isSending = true self.errorMessage = nil - defer { self.isSending = false } + defer { + if self.requestGeneration == generation { + self.isSending = false + } + } do { var params: [String: AnyCodable] = [ "sessionId": AnyCodable(self.sessionId), @@ -82,19 +173,17 @@ final class CrestodianOnboardingChatModel { if let message { params["message"] = AnyCodable(message) } - if reset { - params["reset"] = AnyCodable(true) - } - let data = try await GatewayConnection.shared.request( + let route = try await self.sessionRoute(for: generation) + guard self.isCurrentRequest(generation) else { return } + let data = try await self.gateway.request( method: "crestodian.chat", params: params, timeoutMs: 190_000, - retryTransportFailures: false) + ifCurrentRoute: route) + guard self.isCurrentRequest(generation) else { return } + guard await self.gateway.isCurrentRoute(route) else { throw CancellationError() } let result = try JSONDecoder().decode(ChatResult.self, from: data) - if reset { - self.messages.removeAll() - self.input = "" - } + guard self.isCurrentRequest(generation) else { return } self.expectsSensitiveReply = result.sensitive == true self.messages.append(Message(role: .assistant, text: result.reply)) self.onReplyReceived?() @@ -102,6 +191,14 @@ final class CrestodianOnboardingChatModel { self.onAgentHandoff?() } } catch { + guard self.requestGeneration == generation else { return } + if error is CancellationError || Task.isCancelled { + self.started = false + self.errorMessage = Task.isCancelled + ? "Crestodian was interrupted. Restart to try again." + : "The Gateway connection changed. Restart Crestodian to reconnect." + return + } self.errorMessage = error.localizedDescription } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+AISetupPage.swift b/apps/macos/Sources/OpenClaw/OnboardingView+AISetupPage.swift index 9c46bef5043f..1058a690cf12 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+AISetupPage.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+AISetupPage.swift @@ -3,7 +3,8 @@ import SwiftUI extension OnboardingView { /// Structured AI setup: detect what's already on this machine, test the /// best option live, fall through automatically, offer an API-key form - /// when nothing works. Crestodian chat stays one click away for help. + /// when nothing works. Crestodian becomes available only after inference + /// has completed a live round-trip. func aiSetupPage() -> some View { VStack(spacing: 12) { Text("Connect your AI") @@ -16,7 +17,10 @@ extension OnboardingView { .fixedSize(horizontal: false, vertical: true) ScrollView { - OnboardingAISetupView(model: self.aiSetup, crestodianChat: self.crestodianChat) + OnboardingAISetupView( + model: self.aiSetup, + crestodianChat: self.crestodianState.chat, + showCrestodianChat: self.$crestodianState.isPresented) .padding(.vertical, 4) .padding(.trailing, 12) } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index e45a299226a6..aeb68bb88d5a 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -41,9 +41,7 @@ extension OnboardingView { self.updateMonitoring(for: self.activePageIndex(for: newValue)) } .onChange(of: state.connectionMode) { _, _ in - let oldActive = self.activePageIndex - self.reconcilePageForModeChange(previousActivePageIndex: oldActive) - self.updateDiscoveryMonitoring(for: self.activePageIndex) + self.handleConnectionModeChange() } .onChange(of: needsBootstrap) { _, _ in if self.currentPage >= self.pageOrder.count { @@ -87,6 +85,59 @@ extension OnboardingView { withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) } } + func handleConnectionModeChange(updatePageMonitoring: ((Int) -> Void)? = nil) { + self.resetGatewayBoundAIState() + let oldActive = self.activePageIndex + self.reconcilePageForModeChange(previousActivePageIndex: oldActive) + self.returnToInferenceSetupIfNeeded() + if let updatePageMonitoring { + updatePageMonitoring(self.activePageIndex) + return + } + // A mode swap can keep the same page cursor, so its onChange hook may not restart AI setup. + self.updateMonitoring(for: self.activePageIndex) + } + + func resetGatewayBoundAIState() { + self.aiSetup.resetForGatewayChange() + // Crestodian sessions belong to one Gateway. Dismiss and replace the chat so + // changing routes cannot send an old session ID to the new endpoint. + self.crestodianState.resetForGatewayChange() + } + + func restartGatewayBoundAISetup(updatePageMonitoring: ((Int) -> Void)? = nil) { + self.resetGatewayBoundAIState() + self.returnToInferenceSetupIfNeeded() + if let updatePageMonitoring { + updatePageMonitoring(self.activePageIndex) + return + } + // A route edit can leave the page cursor unchanged, so explicitly restart its work. + self.updateMonitoring(for: self.activePageIndex) + } + + private func returnToInferenceSetupIfNeeded() { + let targetPage = Self.pageCursorAfterGatewayReset( + currentPage: self.currentPage, + pageOrder: self.pageOrder, + aiPageIndex: self.aiPageIndex) + guard targetPage != self.currentPage else { return } + withAnimation { self.currentPage = targetPage } + } + + static func pageCursorAfterGatewayReset( + currentPage: Int, + pageOrder: [Int], + aiPageIndex: Int) -> Int + { + guard let aiPageCursor = pageOrder.firstIndex(of: aiPageIndex), + currentPage >= aiPageCursor + else { + return currentPage + } + return aiPageCursor + } + var navigationBar: some View { let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex) let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex) diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 5f4a5bddac35..cc400480fcc6 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -163,6 +163,12 @@ extension OnboardingView { .onChange(of: self.state.remoteUrl) { _, _ in self.resetRemoteProbeFeedback() } + .onChange(of: self.state.remoteToken) { _, _ in + self.resetRemoteProbeFeedback() + } + .onChange(of: self.state.remoteIdentity) { _, _ in + self.resetRemoteProbeFeedback() + } } private var localGatewaySubtitle: String { @@ -533,6 +539,7 @@ extension OnboardingView { private func resetRemoteProbeFeedback() { self.remoteProbeState = .idle self.remoteAuthIssue = nil + self.restartGatewayBoundAISetup() } static func remoteAuthPromptStyle( diff --git a/apps/macos/Sources/OpenClaw/SettingsRootView.swift b/apps/macos/Sources/OpenClaw/SettingsRootView.swift index e3738433ae63..03b878473fed 100644 --- a/apps/macos/Sources/OpenClaw/SettingsRootView.swift +++ b/apps/macos/Sources/OpenClaw/SettingsRootView.swift @@ -8,18 +8,33 @@ struct SettingsRootView: View { @State private var monitoringPermissions = false @State private var selectedTab: SettingsTab = .general @State private var cachedTabs: Set + @State private var inferenceConfiguration: InferenceConfiguration + @State private var trackedInferenceGatewayID: String? + @State private var inferenceRefreshTrigger = InferenceRefreshTrigger.invalidate(UUID()) + @State private var crestodianChatIdentity = UUID() + @State private var deferredTab: SettingsTab? @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var snapshotPaths: (configPath: String?, stateDir: String?) = (nil, nil) let updater: UpdaterProviding? private let isPreview = ProcessInfo.processInfo.isPreview private let isNixMode = ProcessInfo.processInfo.isNixMode - init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) { + init( + state: AppState, + updater: UpdaterProviding?, + initialTab: SettingsTab? = nil, + configuredInferenceModel: String? = nil) + { let initial = initialTab ?? .general self.state = state self.updater = updater self._selectedTab = State(initialValue: initial) self._cachedTabs = State(initialValue: [initial]) + self._inferenceConfiguration = State(initialValue: configuredInferenceModel.map { + .loaded($0) + } ?? .loading) + self._trackedInferenceGatewayID = State(initialValue: nil) + self._deferredTab = State(initialValue: nil) } var body: some View { @@ -45,43 +60,75 @@ struct SettingsRootView: View { .onReceive(NotificationCenter.default.publisher(for: .openclawSelectSettingsTab)) { note in if let tab = note.object as? SettingsTab { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - self.selectedTab = self.validTab(for: tab) + self.selectRequestedTab(tab) } } } .onAppear { if let pending = SettingsTabRouter.consumePending() { - self.selectedTab = self.validTab(for: pending) + self.selectRequestedTab(pending) + } else { + self.selectRequestedTab(self.selectedTab) } self.cacheSelectedTab() self.updatePermissionMonitoring(for: self.selectedTab) + self.trackedInferenceGatewayID = MacChatTranscriptCache.currentGatewayID() } .onChange(of: self.state.debugPaneEnabled) { _, enabled in if !enabled, self.selectedTab == .debug { self.selectedTab = .general } } + .onChange(of: self.inferenceConfiguration) { _, configuration in + if !CrestodianAvailability.shouldShow(configuredModel: configuration.configuredModel), + self.selectedTab == .crestodian + { + self.selectedTab = .general + } + } .onChange(of: self.selectedTab) { _, newValue in self.cachedTabs.insert(newValue) self.updatePermissionMonitoring(for: newValue) } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - guard self.selectedTab == .permissions else { return } - Task { await self.refreshPerms() } + if self.selectedTab == .permissions { + Task { await self.refreshPerms() } + } + self.scheduleInferenceRefresh(clearPrevious: false) + } + .onReceive(NotificationCenter.default.publisher(for: .openclawConfigDidChange)) { _ in + let gatewayID = MacChatTranscriptCache.currentGatewayID() + let plan = Self.configRefreshPlan( + selectedTab: self.selectedTab, + previousGatewayID: self.trackedInferenceGatewayID, + currentGatewayID: gatewayID) + self.trackedInferenceGatewayID = gatewayID + self.scheduleInferenceRefresh( + clearPrevious: plan.clearsPrevious, + resetCrestodian: plan.resetsCrestodian) } .onDisappear { self.stopPermissionMonitoring() } .task { guard !self.isPreview else { return } await self.refreshPerms() } - .task(id: self.state.connectionMode) { + .onChange(of: self.state.connectionMode) { _, _ in + self.trackedInferenceGatewayID = MacChatTranscriptCache.currentGatewayID() + self.scheduleInferenceRefresh(clearPrevious: true, resetCrestodian: true) + } + .task(id: self.inferenceRefreshTrigger) { guard !self.isPreview else { return } await self.refreshSnapshotPaths() + await self.refreshInferenceConfiguration( + clearPrevious: self.inferenceRefreshTrigger.clearsPrevious) } } private var visibleGroups: [SettingsTabGroup] { - SettingsTabGroup.defaultGroups(showDebug: self.state.debugPaneEnabled) + SettingsTabGroup.defaultGroups( + showDebug: self.state.debugPaneEnabled, + showCrestodian: CrestodianAvailability.shouldShow( + configuredModel: self.inferenceConfiguration.configuredModel)) } private var sidebarSelection: Binding { @@ -89,7 +136,7 @@ struct SettingsRootView: View { get: { self.selectedTab }, set: { tab in guard let tab else { return } - self.selectedTab = self.validTab(for: tab) + self.selectRequestedTab(tab) }) } @@ -178,7 +225,12 @@ struct SettingsRootView: View { case .voiceWake: AnyView(VoiceWakeSettings(state: self.state, isActive: self.selectedTab == .voiceWake)) case .crestodian: - AnyView(CrestodianSettings(isActive: self.selectedTab == tab)) + AnyView(CrestodianSettings( + isActive: self.selectedTab == tab, + onReplyReceived: { + self.scheduleInferenceRefresh(clearPrevious: false) + }) + .id(self.crestodianChatIdentity)) case .channels: AnyView(ChannelsSettings(isActive: self.selectedTab == tab)) case .skills: @@ -200,8 +252,49 @@ struct SettingsRootView: View { } } - private func validTab(for requested: SettingsTab) -> SettingsTab { - if requested == .debug, !self.state.debugPaneEnabled { return .general } + private func selectRequestedTab(_ requested: SettingsTab) { + let selection = Self.tabSelection( + requested: requested, + showDebug: self.state.debugPaneEnabled, + inferenceConfiguration: self.inferenceConfiguration) + self.deferredTab = selection.deferred + self.selectedTab = selection.selected + } + + struct TabSelection: Equatable { + let selected: SettingsTab + let deferred: SettingsTab? + } + + static func tabSelection( + requested: SettingsTab, + showDebug: Bool, + inferenceConfiguration: InferenceConfiguration) -> TabSelection + { + let showCrestodian = CrestodianAvailability.shouldShow( + configuredModel: inferenceConfiguration.configuredModel) + let deferred = requested == .crestodian && !showCrestodian && !inferenceConfiguration.isLoaded + ? requested + : nil + return TabSelection( + selected: Self.normalizedTab( + requested, + showDebug: showDebug, + showCrestodian: showCrestodian), + deferred: deferred) + } + + static func normalizedTab( + _ requested: SettingsTab, + showDebug: Bool, + showCrestodian: Bool) -> SettingsTab + { + if requested == .debug, !showDebug { + return .general + } + if requested == .crestodian, !showCrestodian { + return .general + } return requested } @@ -215,6 +308,113 @@ struct SettingsRootView: View { self.snapshotPaths = paths } + @MainActor + private func refreshInferenceConfiguration(clearPrevious: Bool) async { + if clearPrevious { + self.inferenceConfiguration = .loading + } + guard let route = await GatewayConnection.shared.captureRoute() else { return } + do { + let model = try await GatewayConnection.shared.configuredInferenceModel( + ifCurrentRoute: route) + guard !Task.isCancelled else { return } + self.inferenceConfiguration = Self.configurationAfterInferenceRefresh( + current: self.inferenceConfiguration, + result: .confirmed(model)) + if let deferredTab = self.deferredTab { + self.selectRequestedTab(deferredTab) + } + } catch is CancellationError { + // A route change or task cancellation must not apply stale gateway state. + } catch { + guard !Task.isCancelled else { return } + // Preserve only route-confirmed truth. If this route has never loaded, stay hidden + // until app activation, config invalidation, or a route change triggers another probe. + self.inferenceConfiguration = Self.configurationAfterInferenceRefresh( + current: self.inferenceConfiguration, + result: .failed) + } + } + + enum InferenceConfiguration: Equatable { + case loading + case loaded(String?) + + var configuredModel: String? { + switch self { + case .loading: nil + case let .loaded(model): model + } + } + + var isLoaded: Bool { + if case .loaded = self { + true + } else { + false + } + } + } + + enum InferenceRefreshResult { + case confirmed(String?) + case failed + } + + enum InferenceRefreshTrigger: Equatable { + case invalidate(UUID) + case verify(UUID) + + var clearsPrevious: Bool { + switch self { + case .invalidate: true + case .verify: false + } + } + } + + struct ConfigRefreshPlan: Equatable { + let clearsPrevious: Bool + let resetsCrestodian: Bool + } + + static func configRefreshPlan( + selectedTab: SettingsTab, + previousGatewayID: String?, + currentGatewayID: String?) -> ConfigRefreshPlan + { + let routeChanged = previousGatewayID != currentGatewayID + return ConfigRefreshPlan( + clearsPrevious: routeChanged || selectedTab != .crestodian, + resetsCrestodian: routeChanged) + } + + static func configurationAfterInferenceRefresh( + current: InferenceConfiguration, + result: InferenceRefreshResult) -> InferenceConfiguration + { + switch result { + case let .confirmed(model): .loaded(model) + case .failed: current + } + } + + private func scheduleInferenceRefresh(clearPrevious: Bool, resetCrestodian: Bool = false) { + if resetCrestodian { + // Crestodian sessions are gateway-owned. Re-key the cached detail so a route + // change cannot send old conversation state to a new endpoint. + self.crestodianChatIdentity = UUID() + } + if clearPrevious { + // Preserve an active or pending Crestodian request while config truth is revalidated. + // A confirmed model restores it; a confirmed missing model leaves General selected. + let requestedTab = self.deferredTab ?? self.selectedTab + self.inferenceConfiguration = .loading + self.selectRequestedTab(requestedTab) + } + self.inferenceRefreshTrigger = clearPrevious ? .invalidate(UUID()) : .verify(UUID()) + } + @MainActor private func refreshPerms() async { guard !self.isPreview else { return } @@ -231,7 +431,7 @@ struct SettingsRootView: View { } } -private struct SettingsTabGroup: Identifiable { +struct SettingsTabGroup: Identifiable { let title: String let tabs: [SettingsTab] @@ -239,9 +439,12 @@ private struct SettingsTabGroup: Identifiable { self.title } - static func defaultGroups(showDebug: Bool) -> [SettingsTabGroup] { + static func defaultGroups(showDebug: Bool, showCrestodian: Bool) -> [SettingsTabGroup] { + let basicTabs: [SettingsTab] = showCrestodian + ? [.general, .connection, .permissions, .voiceWake, .crestodian] + : [.general, .connection, .permissions, .voiceWake] var groups = [ - SettingsTabGroup(title: "Basics", tabs: [.general, .connection, .permissions, .voiceWake, .crestodian]), + SettingsTabGroup(title: "Basics", tabs: basicTabs), SettingsTabGroup(title: "Automation", tabs: [.channels, .skills, .cron, .execApprovals]), SettingsTabGroup(title: "Data", tabs: [.sessions, .instances]), SettingsTabGroup(title: "Advanced", tabs: [.config]), @@ -327,7 +530,11 @@ extension Notification.Name { struct SettingsRootView_Previews: PreviewProvider { static var previews: some View { ForEach(SettingsTab.allCases, id: \.self) { tab in - SettingsRootView(state: .preview, updater: DisabledUpdaterController(), initialTab: tab) + SettingsRootView( + state: .preview, + updater: DisabledUpdaterController(), + initialTab: tab, + configuredInferenceModel: tab == .crestodian ? "openai/gpt-5.5" : nil) .previewDisplayName(tab.title) .frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight) } diff --git a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift index 586c06c4217d..8675da8236bd 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ConfigStoreTests.swift @@ -40,6 +40,13 @@ struct ConfigStoreTests { @Test func `save routes to remote in remote mode`() async throws { var localHit = false var remoteHit = false + let changeCount = NotificationCount() + let observer = NotificationCenter.default.addObserver( + forName: .openclawConfigDidChange, + object: nil, + queue: nil) + { _ in changeCount.increment() } + defer { NotificationCenter.default.removeObserver(observer) } await ConfigStore._testSetOverrides(.init( isRemoteMode: { true }, saveLocal: { _ in localHit = true }, @@ -50,6 +57,7 @@ struct ConfigStoreTests { await ConfigStore._testClearOverrides() #expect(remoteHit) #expect(!localHit) + #expect(changeCount.value == 1) } @Test func `save routes to local in local mode`() async throws { @@ -67,6 +75,29 @@ struct ConfigStoreTests { #expect(!remoteHit) } + @Test func `failed save does not announce config change`() async { + let changeCount = NotificationCount() + let observer = NotificationCenter.default.addObserver( + forName: .openclawConfigDidChange, + object: nil, + queue: nil) + { _ in changeCount.increment() } + defer { NotificationCenter.default.removeObserver(observer) } + await ConfigStore._testSetOverrides(.init( + isRemoteMode: { true }, + saveRemote: { _ in + throw NSError(domain: "ConfigStoreTests", code: 1) + })) + + do { + try await ConfigStore.save(["remote": true]) + Issue.record("Expected save to fail") + } catch {} + + await ConfigStore._testClearOverrides() + #expect(changeCount.value == 0) + } + @Test func `local save does not fall back to direct write after stale gateway rejection`() async throws { let stateDir = FileManager().temporaryDirectory .appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true) @@ -139,3 +170,16 @@ struct ConfigStoreTests { } } } + +private final class NotificationCount: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + self.lock.withLock { self.count } + } + + func increment() { + self.lock.withLock { self.count += 1 } + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 779cb86b19e3..b5778fab8a69 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -239,6 +239,27 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes #expect(identity.contract == "global|primary|work") } + @Test(arguments: [ + ( + #"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":"openai/gpt-5.5"}}]}"#, + "openai/gpt-5.5"), + ( + #"{"defaultId":"work","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":"openai/gpt-5.5"}},{"id":"work","model":{"primary":"anthropic/claude-opus-4-8"}}]}"#, + "anthropic/claude-opus-4-8"), + ( + #"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main"},{"id":"work","model":{"primary":"openai/gpt-5.5"}}]}"#, + nil), + ( + #"{"defaultId":"main","mainKey":"main","scope":"per-sender","agents":[{"id":"main","model":{"primary":" "}}]}"#, + nil), + ]) + func `configured inference model follows the default agent`( + json: String, + expected: String?) throws + { + #expect(try GatewayConnection.decodeConfiguredInferenceModel(Data(json.utf8)) == expected) + } + private static func messageData(_ message: URLSessionWebSocketTask.Message) -> Data? { switch message { case let .string(text): diff --git a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift index c6f125d2c5b4..7993fc59c121 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MenuContentSmokeTests.swift @@ -81,24 +81,39 @@ struct MenuContentSmokeTests { #expect(!didOpenDashboard) } - @Test func `connected configured gateway opens dashboard instead of onboarding`() { + @Test func `connected configured gateway with inference opens dashboard instead of onboarding`() { for mode in [AppState.ConnectionMode.local, .remote] { let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding( connectionMode: mode, onboardingSeen: false, hasStoredConnectionMode: false, - gatewayConnected: true) + gatewayConnected: true, + configuredInferenceModel: " openai/gpt-5.5 ") #expect(shouldOpen) } } + @Test func `connected configured gateway without inference keeps onboarding`() { + for model in [String?.none, "", " "] { + let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding( + connectionMode: .remote, + onboardingSeen: false, + hasStoredConnectionMode: false, + gatewayConnected: true, + configuredInferenceModel: model) + + #expect(!shouldOpen) + } + } + @Test func `disconnected configured gateway keeps onboarding recovery`() { let shouldOpen = AppDelegate.shouldOpenDashboardInsteadOfOnboarding( connectionMode: .remote, onboardingSeen: false, hasStoredConnectionMode: false, - gatewayConnected: false) + gatewayConnected: false, + configuredInferenceModel: "openai/gpt-5.5") #expect(!shouldOpen) } @@ -108,7 +123,8 @@ struct MenuContentSmokeTests { connectionMode: .local, onboardingSeen: false, hasStoredConnectionMode: true, - gatewayConnected: true) + gatewayConnected: true, + configuredInferenceModel: "openai/gpt-5.5") #expect(!shouldOpen) } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift new file mode 100644 index 000000000000..9dfae6d5175c --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingAISetupTests.swift @@ -0,0 +1,91 @@ +import Foundation +import OpenClawKit +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct OnboardingAISetupTests { + @Test func `candidate failure keeps friendly summary and exact detail`() { + let failure = OnboardingAISetupModel.failure( + label: "Codex CLI", + status: "auth", + error: "Codex login expired (request 42)") + + #expect(failure.summary == "Codex CLI is installed, but the login didn’t work. Sign in again, then retry.") + #expect(failure.detail == "Codex login expired (request 42)") + #expect(failure.copyText == "Codex login expired (request 42)") + } + + @Test func `candidate failure omits empty detail`() { + let failure = OnboardingAISetupModel.failure( + label: "Codex CLI", + status: "timeout", + error: " ") + + #expect(failure.summary == "Codex CLI didn’t answer in time.") + #expect(failure.detail == nil) + #expect(failure.copyText == failure.summary) + } + + @Test func `transport failure preserves original detail`() { + let failure = OnboardingAISetupModel.transportFailure( + "Gateway request failed: connection reset") + + #expect(failure.summary == "Gateway request failed: connection reset") + #expect(failure.detail == "Gateway request failed: connection reset") + } + + @Test func `codex activation covers install probe and finalization`() { + #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") == 480_000) + #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "claude-cli") == 150_000) + #expect(OnboardingAISetupModel.activationRequestTimeoutMs(for: "codex-cli") >= (305 + 90) * 1000) + #expect(OnboardingAISetupModel.activationOutcomeDeadlineMs(for: "codex-cli") == 510_000) + } + + @Test func `incomplete detection is not a reconciled activation`() { + #expect(!OnboardingAISetupModel.activationIsPersisted( + expectedModel: "openai/gpt-5.5", + setupComplete: false, + configuredModel: nil)) + #expect(OnboardingAISetupModel.activationIsPersisted( + expectedModel: "openai/gpt-5.5", + setupComplete: true, + configuredModel: "openai/gpt-5.5")) + } + + @Test func `definitive gateway response does not enter reconciliation`() { + let responseError = GatewayResponseError( + method: "crestodian.setup.activate", + code: "UNKNOWN_METHOD", + message: "unknown method", + details: nil) + let timeout = NSError( + domain: "Gateway", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "gateway request timed out"]) + let decodeError = DecodingError.dataCorrupted(.init( + codingPath: [], + debugDescription: "invalid activation response")) + + #expect(OnboardingAISetupModel.activationReconciliationMode(after: responseError) == .none) + #expect(OnboardingAISetupModel.activationReconciliationMode(after: decodeError) == .immediate) + #expect(OnboardingAISetupModel.activationReconciliationMode(after: timeout) == .polling) + } + + @Test func `gateway change clears route-bound setup state`() { + let model = OnboardingAISetupModel() + model.manualProviderID = "openai" + model.manualKey = "temporary-key" + model.showManualEntry = true + + model.resetForGatewayChange() + + #expect(model.phase == .idle) + #expect(model.connectedModelRef == nil) + #expect(model.connectedLatencyMs == nil) + #expect(model.manualProviderID.isEmpty) + #expect(model.manualKey.isEmpty) + #expect(!model.showManualEntry) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingCrestodianChatTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingCrestodianChatTests.swift new file mode 100644 index 000000000000..9dd6e3f95ff7 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingCrestodianChatTests.swift @@ -0,0 +1,289 @@ +import Foundation +import OpenClawKit +import Testing +@testable import OpenClaw + +private actor CrestodianGatewayConfig { + private var token = "a" + + func snapshotToken() -> String { + self.token + } + + func setToken(_ token: String) { + self.token = token + } +} + +private actor CrestodianSessionRecorder { + private var sessionIDs: [String] = [] + + func record(_ sessionID: String) { + self.sessionIDs.append(sessionID) + } + + func snapshot() -> [String] { + self.sessionIDs + } +} + +private actor CrestodianRequestGate { + private var consumed = false + private var released = false + private var continuation: CheckedContinuation? + + func waitIfFirst() async -> Bool { + guard !self.consumed else { return false } + self.consumed = true + if !self.released { + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + return true + } + + func release() { + self.released = true + self.continuation?.resume() + self.continuation = nil + } +} + +private func crestodianSessionID(from message: URLSessionWebSocketTask.Message) -> String? { + let data: Data? = switch message { + case let .data(data): data + case let .string(string): string.data(using: .utf8) + @unknown default: nil + } + guard let data, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object["method"] as? String == "crestodian.chat", + let params = object["params"] as? [String: Any] + else { return nil } + return params["sessionId"] as? String +} + +private func crestodianResponse(id: String, action: String = "none") -> Data { + Data( + """ + { + "type": "res", + "id": "\(id)", + "ok": true, + "payload": { + "sessionId": "test-session", + "reply": "ready", + "action": "\(action)", + "sensitive": false + } + } + """.utf8) +} + +@Suite(.serialized) +@MainActor +struct OnboardingCrestodianChatTests { + @Test func `settings callback refreshes inference after assistant reply`() async throws { + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + task.emitReceiveSuccess(.data(crestodianResponse(id: id))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = CrestodianOnboardingChatModel(gateway: gateway) + var refreshCount = 0 + CrestodianSettings.configureChatCallbacks( + for: chat, + onReplyReceived: { refreshCount += 1 }) + + await chat.startIfNeeded() + + #expect(chat.messages.map(\.text) == ["ready"]) + #expect(refreshCount == 1) + } + + @Test func `gateway reset invalidates queued send and restart tasks`() async throws { + let session = GatewayTestWebSocketSession() + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = CrestodianOnboardingChatModel(gateway: gateway) + let state = OnboardingCrestodianChatState() + state.chat = chat + var replyCount = 0 + var handoffCount = 0 + chat.onReplyReceived = { replyCount += 1 } + chat.onAgentHandoff = { handoffCount += 1 } + chat.input = "route-bound secret" + state.isPresented = true + + let sendTask = try #require(chat.send()) + let restartTask = try #require(chat.restartAfterError()) + state.resetForGatewayChange() + await sendTask.value + await restartTask.value + + #expect(session.snapshotMakeCount() == 0) + #expect(chat.messages.isEmpty) + #expect(replyCount == 0) + #expect(handoffCount == 0) + #expect(!state.isPresented) + #expect(state.chat !== chat) + #expect(chat.send() == nil) + #expect(chat.restartAfterError() == nil) + } + + @Test func `chat session stays bound to its original gateway route`() async throws { + let config = CrestodianGatewayConfig() + let recorder = CrestodianSessionRecorder() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + if let sessionID = crestodianSessionID(from: message) { + await recorder.record(sessionID) + } + task.emitReceiveSuccess(.data(crestodianResponse(id: id))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { + let token = await config.snapshotToken() + return (url: url, token: token, password: nil) + }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = CrestodianOnboardingChatModel(gateway: gateway) + + await chat.startIfNeeded() + #expect(chat.messages.map(\.text) == ["ready"]) + #expect(session.snapshotMakeCount() == 1) + #expect(session.latestTask()?.snapshotSendCount() == 2) + let routeASessionIDs = await recorder.snapshot() + #expect(routeASessionIDs.count == 1) + let routeASessionID = try #require(routeASessionIDs.first) + + await config.setToken("b") + chat.input = "must stay on route a" + let sendTask = try #require(chat.send()) + await sendTask.value + + #expect(session.snapshotMakeCount() == 1) + #expect(session.latestTask()?.snapshotSendCount() == 2) + #expect(chat.messages.map(\.text) == ["ready", "must stay on route a"]) + #expect(chat.errorMessage == "The Gateway connection changed. Restart Crestodian to reconnect.") + #expect(await recorder.snapshot() == [routeASessionID]) + + let restartTask = try #require(chat.restartAfterError()) + await restartTask.value + + #expect(session.snapshotMakeCount() == 2) + #expect(session.latestTask()?.snapshotSendCount() == 2) + #expect(chat.messages.map(\.text) == ["ready"]) + #expect(chat.errorMessage == nil) + let sessionIDs = await recorder.snapshot() + #expect(sessionIDs.count == 2) + #expect(sessionIDs.first == routeASessionID) + #expect(sessionIDs.last != routeASessionID) + } + + @Test func `route change while reply is in flight discards reply and action`() async throws { + let config = CrestodianGatewayConfig() + let requestGate = CrestodianRequestGate() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + _ = await requestGate.waitIfFirst() + task.emitReceiveSuccess(.data(crestodianResponse(id: id, action: "open-agent"))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { + let token = await config.snapshotToken() + return (url: url, token: token, password: nil) + }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = CrestodianOnboardingChatModel(gateway: gateway) + var replyCount = 0 + var handoffCount = 0 + chat.onReplyReceived = { replyCount += 1 } + chat.onAgentHandoff = { handoffCount += 1 } + + let startTask = Task { await chat.startIfNeeded() } + var requestStarted = false + for _ in 0..<1000 { + if session.latestTask()?.snapshotSendCount() == 2 { + requestStarted = true + break + } + await Task.yield() + } + try #require(requestStarted) + await config.setToken("b") + await requestGate.release() + await startTask.value + + #expect(chat.messages.isEmpty) + #expect(replyCount == 0) + #expect(handoffCount == 0) + #expect(chat.errorMessage == "The Gateway connection changed. Restart Crestodian to reconnect.") + } + + @Test func `cancelled initial request exposes restart and recovers`() async throws { + let requestGate = CrestodianRequestGate() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { task, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + if sendIndex == 1, await requestGate.waitIfFirst() { + throw CancellationError() + } + task.emitReceiveSuccess(.data(crestodianResponse(id: id))) + }) + }) + let url = try #require(URL(string: "ws://example.invalid")) + let gateway = GatewayConnection( + configProvider: { (url: url, token: nil, password: nil) }, + sessionBox: WebSocketSessionBox(session: session)) + let chat = CrestodianOnboardingChatModel(gateway: gateway) + + let startTask = Task { await chat.startIfNeeded() } + var requestStarted = false + for _ in 0..<1000 { + if session.latestTask()?.snapshotSendCount() == 2 { + requestStarted = true + break + } + await Task.yield() + } + try #require(requestStarted) + startTask.cancel() + await requestGate.release() + await startTask.value + + #expect(chat.errorMessage == "Crestodian was interrupted. Restart to try again.") + #expect(!chat.isSending) + #expect(chat.messages.isEmpty) + + let restartTask = try #require(chat.restartAfterError()) + await restartTask.value + + #expect(chat.errorMessage == nil) + #expect(chat.messages.map(\.text) == ["ready"]) + #expect(session.snapshotMakeCount() == 2) + #expect(session.latestTask()?.snapshotSendCount() == 2) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 8f4ba08e1939..626df2dc0c3e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -34,7 +34,7 @@ struct OnboardingViewSmokeTests { #expect(!order.contains(8)) } - @Test func `fresh local setup installs CLI before the Crestodian chat`() { + @Test func `fresh local setup installs CLI before inference setup`() { let order = OnboardingView.pageOrder( for: .local, showOnboardingChat: false, @@ -53,6 +53,15 @@ struct OnboardingViewSmokeTests { #expect(!order.contains(2)) } + @Test func `only full page chat uses compact hero`() { + #expect(!OnboardingView.shouldUseCompactHero( + activePageIndex: 3, + onboardingChatPageIndex: 8)) + #expect(OnboardingView.shouldUseCompactHero( + activePageIndex: 8, + onboardingChatPageIndex: 8)) + } + @Test func `fresh onboarding defaults to this Mac`() { let state = AppState(preview: true) state.onboardingSeen = false @@ -110,6 +119,45 @@ struct OnboardingViewSmokeTests { installing: false)) } + @Test func `connection mode change restarts full page monitoring`() { + let state = AppState(preview: true) + let view = OnboardingView(state: state) + var monitoredPage: Int? + let previousCrestodianChat = view.crestodianState.chat + view.aiSetup.manualKey = "route-bound" + view.crestodianState.isPresented = true + + view.handleConnectionModeChange { pageIndex in + monitoredPage = pageIndex + } + + #expect(view.aiSetup.manualKey.isEmpty) + #expect(!view.crestodianState.isPresented) + #expect(view.crestodianState.chat !== previousCrestodianChat) + #expect(monitoredPage == view.activePageIndex) + } + + @Test func `gateway route reset returns later pages to inference setup`() throws { + let order = OnboardingView.pageOrder( + for: .remote, + showOnboardingChat: false, + requiresCLIInstall: false) + let permissionsCursor = try #require(order.firstIndex(of: 5)) + let aiCursor = try #require(order.firstIndex(of: 3)) + let resetCursor = OnboardingView.pageCursorAfterGatewayReset( + currentPage: permissionsCursor, + pageOrder: order, + aiPageIndex: 3) + + #expect(resetCursor == aiCursor) + #expect(OnboardingView.shouldBlockAISetup( + currentPage: resetCursor, + pageOrder: order, + aiPageIndex: 3, + connectionMode: .remote, + connected: false)) + } + @Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async { let override = FileManager().temporaryDirectory .appendingPathComponent("openclaw-config-\(UUID().uuidString)") diff --git a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift index 27c45b828341..81e8b706d10d 100644 --- a/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/SettingsViewSmokeTests.swift @@ -202,6 +202,83 @@ struct SettingsViewSmokeTests { _ = view.body } + @Test func `Crestodian settings require configured inference`() { + #expect(!CrestodianAvailability.shouldShow(configuredModel: nil)) + #expect(!CrestodianAvailability.shouldShow(configuredModel: " ")) + #expect(CrestodianAvailability.shouldShow(configuredModel: "openai/gpt-5.5")) + + let hiddenTabs = SettingsTabGroup.defaultGroups(showDebug: false, showCrestodian: false) + .flatMap(\.tabs) + let visibleTabs = SettingsTabGroup.defaultGroups(showDebug: false, showCrestodian: true) + .flatMap(\.tabs) + #expect(!hiddenTabs.contains(.crestodian)) + #expect(visibleTabs.contains(.crestodian)) + #expect(SettingsRootView.normalizedTab( + .crestodian, + showDebug: false, + showCrestodian: false) == .general) + #expect(SettingsRootView.normalizedTab( + .crestodian, + showDebug: false, + showCrestodian: true) == .crestodian) + let loadingSelection = SettingsRootView.tabSelection( + requested: .crestodian, + showDebug: false, + inferenceConfiguration: .loading) + #expect(loadingSelection.selected == .general) + #expect(loadingSelection.deferred == .crestodian) + let configuredSelection = SettingsRootView.tabSelection( + requested: loadingSelection.deferred ?? .general, + showDebug: false, + inferenceConfiguration: .loaded("openai/gpt-5.5")) + #expect(configuredSelection.selected == .crestodian) + #expect(configuredSelection.deferred == nil) + let unconfiguredSelection = SettingsRootView.tabSelection( + requested: .crestodian, + showDebug: false, + inferenceConfiguration: .loaded(nil)) + #expect(unconfiguredSelection.selected == .general) + #expect(unconfiguredSelection.deferred == nil) + #expect(SettingsRootView.configurationAfterInferenceRefresh( + current: .loaded("openai/gpt-5.5"), + result: .failed) == .loaded("openai/gpt-5.5")) + #expect(SettingsRootView.configurationAfterInferenceRefresh( + current: .loaded("openai/gpt-5.5"), + result: .confirmed(nil)) == .loaded(nil)) + } + + @Test func `Crestodian preserves same route and resets for gateway changes`() { + let stateDir = URL(fileURLWithPath: "/Users/tester/.openclaw") + let directA = MacChatTranscriptCache.gatewayID( + mode: .remote, + localStateDir: stateDir, + remoteTransport: .direct, + directURL: URL(string: "wss://gateway.example.com/team-a"), + sshTarget: "", + sshRemotePort: 18789) + let directB = MacChatTranscriptCache.gatewayID( + mode: .remote, + localStateDir: stateDir, + remoteTransport: .direct, + directURL: URL(string: "wss://gateway.example.com/team-b"), + sshTarget: "", + sshRemotePort: 18789) + + #expect(directA != directB) + #expect(SettingsRootView.configRefreshPlan( + selectedTab: .crestodian, + previousGatewayID: directA, + currentGatewayID: directA) == .init(clearsPrevious: false, resetsCrestodian: false)) + #expect(SettingsRootView.configRefreshPlan( + selectedTab: .general, + previousGatewayID: directA, + currentGatewayID: directA) == .init(clearsPrevious: true, resetsCrestodian: false)) + #expect(SettingsRootView.configRefreshPlan( + selectedTab: .crestodian, + previousGatewayID: directA, + currentGatewayID: directB) == .init(clearsPrevious: true, resetsCrestodian: true)) + } + @Test func `about settings builds body`() { let view = AboutSettings(updater: nil) _ = view.body diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 9f1d7bb34584..8dd362f74ae8 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -7184,6 +7184,7 @@ public struct CronJob: Codable, Sendable { public let deleteafterrun: Bool? public let createdatms: Int public let updatedatms: Int + public let configrevision: String? public let schedule: AnyCodable public let trigger: [String: AnyCodable]? public let sessiontarget: AnyCodable @@ -7216,6 +7217,7 @@ public struct CronJob: Codable, Sendable { deleteafterrun: Bool?, createdatms: Int, updatedatms: Int, + configrevision: String? = nil, schedule: AnyCodable, trigger: [String: AnyCodable]?, sessiontarget: AnyCodable, @@ -7247,6 +7249,7 @@ public struct CronJob: Codable, Sendable { self.deleteafterrun = deleteafterrun self.createdatms = createdatms self.updatedatms = updatedatms + self.configrevision = configrevision self.schedule = schedule self.trigger = trigger self.sessiontarget = sessiontarget @@ -7280,6 +7283,7 @@ public struct CronJob: Codable, Sendable { case deleteafterrun = "deleteAfterRun" case createdatms = "createdAtMs" case updatedatms = "updatedAtMs" + case configrevision = "configRevision" case schedule case trigger case sessiontarget = "sessionTarget" diff --git a/docs/cli/wiki.md b/docs/cli/wiki.md index 3b8fd5e8cb30..5a2f281ddfe7 100644 --- a/docs/cli/wiki.md +++ b/docs/cli/wiki.md @@ -47,11 +47,33 @@ openclaw wiki obsidian command workspace:quick-switcher openclaw wiki obsidian daily ``` +## Agent selection + +When `plugins.entries.memory-wiki.config.vault.scope` is `agent`, select the +vault with the top-level `--agent ` option: + +```bash +openclaw wiki --agent support status +openclaw wiki --agent support search "refund policy" +openclaw wiki --agent marketing ingest ./campaign-notes.md +``` + +In a setup with multiple configured agents, `--agent` is required for CLI +operations so a command cannot read or write an arbitrary default vault. If +only one agent is configured, that agent remains the default. Unknown agent ids +fail before the vault operation starts. The option does not change the selected +path when `vault.scope` is `global`. + +Gateway clients follow the same rule: pass `agentId` on vault-backed `wiki.*` +requests in an agent-scoped multi-agent setup. A missing or unknown id is an +error. Agent turns, wiki tools, memory corpus supplements, and compiled prompt +digests already carry the active runtime agent context. + ## Commands ### `wiki status` -Show vault mode, health, and Obsidian CLI availability. Use this first to check whether the vault is initialized, bridge mode is healthy, or Obsidian integration is available. +Show vault mode and scope, resolved agent, health, and Obsidian CLI availability. Use this first to check whether the intended vault is initialized, bridge mode is healthy, or Obsidian integration is available. When bridge mode is active and configured to read memory artifacts, this command queries the running Gateway so it sees the same active memory plugin context as agent/runtime memory. @@ -198,6 +220,11 @@ Roll back a previously applied ChatGPT import run, removing pages it created and Obsidian helper commands for vaults running in Obsidian-friendly mode: `status`, `search`, `open`, `command`, `daily`. These require the official `obsidian` CLI on `PATH` when `obsidian.useOfficialCli` is enabled. +Configuration validation rejects `obsidian.useOfficialCli: true` when +`vault.scope` is `agent` because `obsidian.vaultName` is one global setting, +not a per-agent mapping. Obsidian-friendly Markdown rendering remains +available. + ## Practical usage guidance - Use `wiki search` + `wiki get` when provenance and page identity matter. @@ -212,6 +239,8 @@ Obsidian helper commands for vaults running in Obsidian-friendly mode: `status`, `openclaw wiki` behavior is shaped by: - `plugins.entries.memory-wiki.config.vaultMode` +- `plugins.entries.memory-wiki.config.vault.scope` +- `plugins.entries.memory-wiki.config.vault.path` - `plugins.entries.memory-wiki.config.search.backend` - `plugins.entries.memory-wiki.config.search.corpus` - `plugins.entries.memory-wiki.config.bridge.*` diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index c866dc26a419..a1824e211145 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -122,6 +122,7 @@ A plugin can register a context engine using the plugin API: ```ts import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core"; +import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; export default function register(api) { api.registerContextEngine("my-engine", (ctx) => ({ @@ -136,7 +137,14 @@ export default function register(api) { return { ingested: true }; }, - async assemble({ sessionId, messages, tokenBudget, availableTools, citationsMode }) { + async assemble({ + sessionId, + sessionKey, + messages, + tokenBudget, + availableTools, + citationsMode, + }) { // Return messages that fit the budget return { messages: buildContext(messages, tokenBudget), @@ -144,6 +152,8 @@ export default function register(api) { systemPromptAddition: buildMemorySystemPromptAddition({ availableTools: availableTools ?? new Set(), citationsMode, + agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }), + agentSessionKey: sessionKey, }), }; }, diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 908608261f6b..b2833cb3a042 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -1,12 +1,12 @@ --- -summary: "Multi-agent routing: isolated agents, channel accounts, and bindings" +summary: "Multi-agent routing: agent boundaries, channel accounts, and bindings" title: "Multi-agent routing" sidebarTitle: "Multi-agent routing" -read_when: "You want multiple isolated agents (workspaces + auth) in one gateway process." +read_when: "You want multiple agents with separate workspaces, auth, and sessions in one Gateway process." status: active --- -Run multiple _isolated_ agents in one Gateway process, each with its own workspace, state directory (`agentDir`), and session store, plus multiple channel accounts (e.g. two WhatsApp numbers). Inbound messages route to the right agent through **bindings**. +Run multiple agents in one Gateway process, each with its own workspace, state directory (`agentDir`), and session store, plus multiple channel accounts (e.g. two WhatsApp numbers). Inbound messages route to the right agent through **bindings**. An **agent** is the full per-persona scope: workspace files, auth profiles, model registry, and session store. A **binding** maps a channel account (a Slack workspace, a WhatsApp number, etc.) to one of those agents. @@ -34,6 +34,11 @@ Never reuse `agentDir` across agents — it causes auth/session state collisions Skills load from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filter by the effective agent skill allowlist. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for a per-agent replacement (explicit entries replace the default, they do not merge). See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent allowlists](/tools/skills#agent-allowlists). +Plugin-owned storage follows that plugin's configuration; adding a second agent +does not automatically split every global plugin store. For example, configure +[Memory Wiki per-agent vaults](/concepts/multi-agent#per-agent-memory-wiki-vaults) +when personas must not share compiled wiki knowledge. + **Workspace note:** each agent's workspace is the **default cwd**, not a hard sandbox. Relative paths resolve inside the workspace, but absolute paths can reach other host locations unless sandboxing is enabled. See [Sandboxing](/gateway/sandboxing). @@ -114,13 +119,44 @@ openclaw agents list --bindings ## Multiple agents, multiple personas -Each configured `agentId` is a fully isolated persona: +Each configured `agentId` is a distinct persona boundary for core agent state: - Different accounts per channel (per `accountId`). - Different personalities (per-agent `AGENTS.md`/`SOUL.md`). -- Separate auth and sessions, with no cross-talk unless explicitly enabled. +- Separate auth and sessions, with cross-agent access enabled only through explicit features or plugin configuration. -This lets multiple people share one Gateway while keeping their agent state isolated. +This lets multiple people share one Gateway while keeping core agent state separate. + +## Per-agent Memory Wiki vaults + +Memory Wiki uses one global vault by default. To keep a support agent's +compiled knowledge separate from a marketing agent's, set +`plugins.entries.memory-wiki.config.vault.scope` to `agent`: + +```json5 +{ + plugins: { + entries: { + "memory-wiki": { + enabled: true, + config: { + vault: { + scope: "agent", + path: "~/.openclaw/wiki", + }, + }, + }, + }, + }, +} +``` + +The configured path is the parent directory. OpenClaw appends the normalized +agent id, producing paths such as `~/.openclaw/wiki/support` and +`~/.openclaw/wiki/marketing`. Agent-scoped CLI and Gateway operations require +an explicit agent when multiple agents are configured. See +[Memory Wiki per-agent vaults](/plugins/memory-wiki#per-agent-vaults) for bridge +filtering, migration, and trust-boundary details. ## Cross-agent QMD memory search diff --git a/docs/docs_map.md b/docs/docs_map.md index 200a91f49881..0675518102dd 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -2089,6 +2089,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H1: openclaw wiki - H2: Common commands + - H2: Agent selection - H2: Commands - H3: wiki status - H3: wiki doctor @@ -2640,6 +2641,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Agent helper - H2: Quick start - H2: Multiple agents, multiple personas + - H2: Per-agent Memory Wiki vaults - H2: Cross-agent QMD memory search - H2: One WhatsApp number, multiple people (DM split) - H2: Routing rules @@ -5664,6 +5666,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Agent tools - H2: Prompt and context behavior - H2: Configuration + - H3: Per-agent vaults - H3: Example: QMD + bridge mode - H2: CLI - H2: Obsidian support @@ -6937,6 +6940,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: When to use a harness - H2: What core still owns + - H3: Harness-owned auth bootstrap - H2: Register a harness - H2: Selection policy - H2: Provider plus harness pairing @@ -7382,6 +7386,8 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Route: /providers/clawrouter - Headings: - H2: Getting started + - H2: Managed non-interactive deployment + - H2: Readiness and live proof - H2: Model discovery - H2: Protocol and provider plugins - H2: Quotas and usage diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md index 36d7a69a35a1..fd1d70b9f8b1 100644 --- a/docs/help/testing-live.md +++ b/docs/help/testing-live.md @@ -582,6 +582,9 @@ request. Plugin dependencies are expected to be present before runtime load. - Provider-specific Vydra coverage: - `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_VYDRA_VIDEO=1 pnpm test:live -- extensions/vydra/vydra.live.test.ts` - That file runs `veo3` text-to-video plus a `kling` image-to-video lane that uses a remote image URL fixture by default (`OPENCLAW_LIVE_VYDRA_KLING_IMAGE_URL` to override). + - Provider-specific xAI Video 1.5 coverage: + - `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_XAI_VIDEO_15=1 pnpm test:live -- extensions/xai/xai.live.test.ts -t "Grok Imagine Video 1.5"` + - The case generates a local PNG first frame, requests a one-second 1080P image-to-video clip, polls to completion, and verifies the downloaded video buffer. - Current `videoToVideo` live coverage: - `runway` only when the selected model resolves to `gen4_aleph` - Current declared-but-skipped `videoToVideo` providers in the shared sweep: diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 6694415ce5d5..cd4399f6e373 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -1063,6 +1063,7 @@ pipeline rather than just add memory search or hooks. ```ts import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core"; +import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; export default function (api) { api.registerContextEngine("lossless-claw", (ctx) => ({ @@ -1070,13 +1071,15 @@ export default function (api) { async ingest() { return { ingested: true }; }, - async assemble({ messages, availableTools, citationsMode }) { + async assemble({ messages, sessionKey, availableTools, citationsMode }) { return { messages, estimatedTokens: 0, systemPromptAddition: buildMemorySystemPromptAddition({ availableTools: availableTools ?? new Set(), citationsMode, + agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }), + agentSessionKey: sessionKey, }), }; }, @@ -1108,6 +1111,7 @@ import { buildMemorySystemPromptAddition, delegateCompactionToRuntime, } from "openclaw/plugin-sdk/core"; +import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; export default function (api) { api.registerContextEngine("my-memory-engine", (ctx) => ({ @@ -1119,13 +1123,15 @@ export default function (api) { async ingest() { return { ingested: true }; }, - async assemble({ messages, availableTools, citationsMode }) { + async assemble({ messages, sessionKey, availableTools, citationsMode }) { return { messages, estimatedTokens: 0, systemPromptAddition: buildMemorySystemPromptAddition({ availableTools: availableTools ?? new Set(), citationsMode, + agentId: resolveSessionAgentId({ config: ctx.config, sessionKey }), + agentSessionKey: sessionKey, }), }; }, diff --git a/docs/plugins/memory-wiki.md b/docs/plugins/memory-wiki.md index 54cde360c1eb..0ec88944ade9 100644 --- a/docs/plugins/memory-wiki.md +++ b/docs/plugins/memory-wiki.md @@ -3,6 +3,7 @@ summary: "memory-wiki: compiled knowledge vault with provenance, claims, dashboa read_when: - You want persistent knowledge beyond plain MEMORY.md notes - You are configuring the bundled memory-wiki plugin + - You need separate wiki vaults for agents in one Gateway - You want to understand wiki_search, wiki_get, or bridge mode title: "Memory wiki" --- @@ -41,6 +42,18 @@ then confirm the active memory plugin supports public artifacts. - `bridge`: reads public memory artifacts and event logs from the active memory plugin through public plugin SDK seams. Use this to compile the memory plugin's exported artifacts without reaching into private plugin internals. - `unsafe-local`: explicit same-machine escape hatch for local private paths. Intentionally experimental and non-portable; use only when you understand the trust boundary and specifically need local filesystem access bridge mode cannot provide. +Vault mode and vault scope are separate choices: + +- `vaultMode` chooses where wiki inputs come from. +- `vault.scope` chooses whether all agents use one vault or each agent gets a child vault. + +`vault.scope: "global"` is the default and preserves the existing single-vault +behavior. Use `vault.scope: "agent"` with `isolated` or `bridge` mode when +agents must not share wiki pages, compiled digests, search results, or writes. +Agent scope cannot be combined with `unsafe-local` mode because those configured +private paths are not agent-owned inputs. Configuration validation rejects this +combination. + Bridge mode can index, per `bridge.*` config toggle: - exported memory artifacts (`indexMemoryRoot`) @@ -244,7 +257,7 @@ includes compact `Claim:` and `Evidence:` lines when available. | Tool | Purpose | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wiki_status` | current vault mode, health, Obsidian CLI availability | +| `wiki_status` | current vault mode and scope, resolved agent, health, Obsidian CLI availability | | `wiki_search` | search wiki pages and, when configured, the shared memory corpus; accepts `mode` for person lookup, question routing, source evidence, or raw claim drilldown | | `wiki_get` | read a wiki page by id/path, falling back to the shared memory corpus when shared search is enabled and the lookup misses | | `wiki_apply` | narrow synthesis/metadata mutations without freeform page surgery | @@ -276,6 +289,7 @@ Put config under `plugins.entries.memory-wiki.config`: config: { vaultMode: "isolated", vault: { + scope: "global", path: "~/.openclaw/wiki/main", renderMode: "obsidian", }, @@ -323,20 +337,83 @@ Put config under `plugins.entries.memory-wiki.config`: Key toggles: -| Key | Values / default | Notes | -| ------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------- | -| `vaultMode` | `isolated` (default), `bridge`, `unsafe-local` | | -| `vault.path` | default `~/.openclaw/wiki/main` | | -| `vault.renderMode` | `native` (default), `obsidian` | | -| `bridge.readMemoryArtifacts` | default `true` | import active memory plugin public artifacts | -| `bridge.followMemoryEvents` | default `true` | include event logs in bridge mode | -| `unsafeLocal.allowPrivateMemoryCoreAccess` | default `false` | required to run `unsafe-local` imports | -| `unsafeLocal.paths` | default `[]` | explicit local paths to import in `unsafe-local` mode | -| `search.backend` | `shared` (default), `local` | | -| `search.corpus` | `wiki` (default), `memory`, `all` | | -| `context.includeCompiledDigestPrompt` | default `false` | append compact digest snapshot to memory prompt sections | -| `render.createBacklinks` | default `true` | generate deterministic related blocks | -| `render.createDashboards` | default `true` | generate dashboard pages | +| Key | Values / default | Notes | +| ------------------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------- | +| `vaultMode` | `isolated` (default), `bridge`, `unsafe-local` | chooses input and integration behavior | +| `vault.scope` | `global` (default), `agent` | one shared vault or one child vault per agent | +| `vault.path` | global default `~/.openclaw/wiki/main` | exact vault globally; agent-scope parent defaults to `~/.openclaw/wiki` | +| `vault.renderMode` | `native` (default), `obsidian` | | +| `bridge.readMemoryArtifacts` | default `true` | import active memory plugin public artifacts | +| `bridge.followMemoryEvents` | default `true` | include event logs in bridge mode | +| `unsafeLocal.allowPrivateMemoryCoreAccess` | default `false` | required to run `unsafe-local` imports | +| `unsafeLocal.paths` | default `[]` | explicit local paths to import in `unsafe-local` mode | +| `search.backend` | `shared` (default), `local` | | +| `search.corpus` | `wiki` (default), `memory`, `all` | | +| `context.includeCompiledDigestPrompt` | default `false` | append the selected agent's compact digest snapshot to memory prompt sections | +| `render.createBacklinks` | default `true` | generate deterministic related blocks | +| `render.createDashboards` | default `true` | generate dashboard pages | + +### Per-agent vaults + +Set `vault.scope` to `agent` to give every configured agent a separate wiki. +In this scope, `vault.path` is a parent directory and OpenClaw appends the +normalized agent id: + +```json5 +{ + agents: { + list: [{ id: "support" }, { id: "marketing" }], + }, + plugins: { + entries: { + "memory-wiki": { + enabled: true, + config: { + vaultMode: "bridge", + vault: { + scope: "agent", + path: "~/.openclaw/wiki", + }, + bridge: { + enabled: true, + readMemoryArtifacts: true, + }, + }, + }, + }, + }, +} +``` + +This resolves to `~/.openclaw/wiki/support` and +`~/.openclaw/wiki/marketing`. If `vault.path` is omitted in agent scope, the +parent defaults to `~/.openclaw/wiki`. The default `main` agent therefore keeps +the existing `~/.openclaw/wiki/main` path. + +Agent tools, compiled prompt digests, and the wiki supplement exposed through +`memory_search` / `memory_get` resolve the vault from the active agent context. +For CLI and Gateway calls in a setup with multiple configured agents, provide +the agent explicitly with `openclaw wiki --agent ...` or the Gateway +request's `agentId`. A single configured agent remains the default when no id is +provided. + +In bridge mode, agent-scoped imports accept a public memory artifact only when +its `agentIds` includes the selected agent. Artifacts owned by another agent, +without ownership metadata, or with an unknown owner are skipped. Global scope +keeps the existing shared-artifact behavior. + + +Changing `vault.scope` does not copy or split an existing vault. In agent scope, +an explicitly configured `vault.path` becomes a parent directory, so move or +import existing pages deliberately before switching production agents. Back up +the vault first. + +Per-agent vaults are a same-process knowledge boundary, not an operating-system +security boundary. Plugins and unsandboxed tools with host filesystem access can +still read another agent's directory. Use [sandboxing](/gateway/sandboxing) or +[separate Gateway profiles](/gateway/multiple-gateways) when agents do not trust +each other. + ### Example: QMD + bridge mode @@ -411,6 +488,12 @@ probing, vault search, opening a page, invoking a command, and jumping to the daily note. This is optional; the wiki still works in native mode without Obsidian. +Agent-scoped vaults can still use Obsidian-friendly Markdown, but configuration +validation rejects `obsidian.useOfficialCli: true` with `vault.scope: "agent"`. +The current `obsidian.vaultName` setting is global and cannot select a distinct +Obsidian vault for each agent. Use the wiki tools and CLI operations instead, +or keep an Obsidian-operated wiki in global scope. + ## Recommended workflow diff --git a/docs/plugins/sdk-agent-harness.md b/docs/plugins/sdk-agent-harness.md index 7ada33f82e68..733552558f81 100644 --- a/docs/plugins/sdk-agent-harness.md +++ b/docs/plugins/sdk-agent-harness.md @@ -34,7 +34,7 @@ WebSocket model APIs, build a [provider plugin](/plugins/sdk-provider-plugins). Before a harness is selected, OpenClaw has already resolved: - provider and model -- runtime auth state +- runtime auth state, unless the harness declares that it owns auth bootstrap - thinking level and context budget - the OpenClaw transcript/session file - workspace, sandbox, and tool policy @@ -44,6 +44,20 @@ Before a harness is selected, OpenClaw has already resolved: A harness runs a prepared attempt; it does not pick providers, replace channel delivery, or silently switch models. +### Harness-owned auth bootstrap + +By default, core resolves provider credentials before calling a harness. A +trusted harness that can authenticate through its own native runtime may set +`authBootstrap: "harness"` on its static `AgentHarness` registration. Core then +skips its generic provider credential bootstrap and missing-credential failure +for every attempt claimed by that harness. + +Core still forwards a compatible, explicitly selected or ordered OpenClaw auth +profile and its scoped store when one exists. The harness must resolve that +profile or its native credentials before issuing model requests, keep secrets +scoped to the attempt, and surface actionable authentication failures. Do not +set this capability on a harness that only sometimes owns authentication. + The prepared attempt also includes `params.runtimePlan`, an OpenClaw-owned policy bundle for runtime decisions that must stay shared across OpenClaw and native harnesses: @@ -98,6 +112,9 @@ export default definePluginEntry({ }); ``` +`authBootstrap` is intentionally absent from this generic example. Add +`authBootstrap: "harness"` only when the harness meets the contract above. + ## Selection policy OpenClaw chooses a harness after provider/model resolution: @@ -154,9 +171,10 @@ for compatibility. For operator setup, model prefix examples, and Codex-only configs, see [Codex Harness](/plugins/codex-harness). -OpenClaw requires Codex app-server `0.142.0` or newer. The Codex plugin checks -the app-server initialize handshake and blocks older or unversioned servers, -so OpenClaw only runs against the protocol surface it has tested. +The Codex plugin enforces the minimum app-server version documented in +[Codex Harness](/plugins/codex-harness). It checks the initialize handshake and +blocks older or unversioned servers, so OpenClaw only runs against the protocol +surface it has tested. ### Tool-result middleware diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index a25234d84083..895fba6e751a 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -187,6 +187,14 @@ guidance remain available to non-Codex prompt surfaces for compatibility. | `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands | | `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` | +Memory prompt supplement builders receive optional `agentId`, +`agentSessionKey`, and `sandboxed` context. Memory corpus supplement `search` +and `get` calls receive optional `agentId` and `sandboxed` context. Plugins with +agent-owned storage should resolve that storage for each call instead of +capturing one global path during registration. If an agent id is required but +missing in a multi-agent operation, fail closed rather than choosing an +arbitrary agent. + Telegram interactive handlers can return `{ submitText }` to route text through Telegram's normal inbound agent path after the handler succeeds. OpenClaw keeps the callback button when inbound policy skips the text or processing fails, so diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 99a784bdf300..e856b1a53e45 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -934,6 +934,7 @@ catalog, API-key auth, and dynamic model resolution. id: "acme-ai", label: "Acme Video", defaultTimeoutMs: 600_000, + models: ["acme-video", "acme-image-video"], capabilities: { generate: { maxVideos: 1, maxDurationSeconds: 10, supportsResolution: true }, imageToVideo: { @@ -945,6 +946,21 @@ catalog, API-key auth, and dynamic model resolution. }, videoToVideo: { enabled: false }, }, + catalogByModel: { + "acme-image-video": { + modes: ["imageToVideo"], + capabilities: { + imageToVideo: { + enabled: true, + maxVideos: 1, + maxInputImages: 1, + resolutions: ["480P", "720P", "1080P"], + supportsResolution: true, + }, + videoToVideo: { enabled: false }, + }, + }, + }, generateVideo: async (req) => ({ videos: [] }), }); ``` @@ -952,6 +968,13 @@ catalog, API-key auth, and dynamic model resolution. `capabilities` is required on both provider types; `edit` and the video transform blocks (`imageToVideo`, `videoToVideo`) always need an explicit `enabled` flag. + + Use `catalogByModel` when a listed model's static modes or capabilities + differ from the provider defaults. This metadata keeps + `video_generate action=list` and model catalogs accurate without + invoking provider code. Request-time capability lookup and enforcement + still belong in `resolveModelCapabilities` and `generateVideo`; reuse + the same capability constant for both paths when possible. ```typescript diff --git a/docs/providers/clawrouter.md b/docs/providers/clawrouter.md index d86460a98d31..93e18c19cefc 100644 --- a/docs/providers/clawrouter.md +++ b/docs/providers/clawrouter.md @@ -69,6 +69,107 @@ you only need an issued ClawRouter credential. +## Managed non-interactive deployment + +Keep the proxy key in the workload's secret injection and store only a +SecretRef in `openclaw.json`. The canonical managed fields are: + +| Purpose | Config or environment field | +| ------------- | ------------------------------------------------------------------------ | +| Router origin | `models.providers.clawrouter.baseUrl` | +| Credential | `models.providers.clawrouter.apiKey` -> env SecretRef | +| Secret value | `CLAWROUTER_API_KEY` in the gateway process environment | +| Default model | `agents.defaults.model.primary` -> `clawrouter//` | +| Workload tag | `models.providers.clawrouter.headers.X-ClawRouter-Project-Id` (optional) | + +For example, a deployment controller can own this JSON5 patch: + +```json5 +{ + plugins: { + entries: { clawrouter: { enabled: true } }, + }, + models: { + providers: { + clawrouter: { + baseUrl: "https://clawrouter.internal.example", + apiKey: { + source: "env", + provider: "default", + id: "CLAWROUTER_API_KEY", + }, + headers: { + "X-ClawRouter-Project-Id": "fakeco", + }, + }, + }, + }, + agents: { + defaults: { + model: { primary: "clawrouter/openai/gpt-5.5" }, + }, + }, +} +``` + +If the deployment sets `plugins.allow`, preserve its existing entries and add +`clawrouter`. Validate and apply without an interactive wizard: + +```bash +openclaw config patch --file ./clawrouter.patch.json5 --dry-run --json +openclaw config patch --file ./clawrouter.patch.json5 +``` + +The dry run resolves the SecretRef but never prints its value. To rotate the +credential, update the external Secret that supplies `CLAWROUTER_API_KEY` and +restart the gateway workload so the new process environment is loaded. The +config file and model reference do not change. + +## Readiness and live proof + +These checks prove different boundaries; do not substitute one for another: + +```bash +# ClawRouter process health only; no credential or upstream model is exercised. +curl -fsS https://clawrouter.internal.example/v1/health + +# OpenClaw gateway startup readiness only; no model call is made. +curl -fsS http://127.0.0.1:18789/readyz + +# Credential-scoped catalog discovery. +openclaw models list --all --provider clawrouter --json + +# Minimal real inference probe through the configured ClawRouter provider. +openclaw models status --probe --probe-provider clawrouter --probe-max-tokens 8 --json + +# Workload canary using an exact granted model ref. +openclaw agent --agent main \ + --model clawrouter/openai/gpt-5.5 \ + --message "Reply exactly: CLAWROUTER_CANARY_OK" \ + --json +``` + +Use a model returned by the scoped catalog instead of copying the example +model blindly. A successful `/readyz` response means the gateway can serve +requests; it does not claim that ClawRouter, its credential, or an upstream +provider is ready. The model probe and agent canary are the inference proofs. + +For live diagnosis, issue the canary and inspect the gateway's standard logs. +The existing metadata-only model transport diagnostics emit lines shaped like: + +```text +[model-fetch] start provider=clawrouter api=openai-responses model=openai/gpt-5.5 method=POST url=https://clawrouter.internal.example/v1/responses +[model-fetch] response provider=clawrouter api=openai-responses model=openai/gpt-5.5 status=200 +``` + +The plugin sends bounded `X-ClawRouter-Client`, `X-ClawRouter-Agent-Id`, and +`X-ClawRouter-Session-Id` headers when those identifiers are available. Static +deployment metadata such as `X-ClawRouter-Project-Id` can be set in the +provider `headers` map. Explicit configured headers win over automatic values. +The transport diagnostic records routing and response metadata; it does not log +credentials, request ids, prompts, or completions. ClawRouter's own audit event +provides the selected upstream provider and content-retention state. + ## Model discovery `GET /v1/catalog` returns `{ providers: [...] }`, where each provider entry @@ -141,6 +242,8 @@ the same ClawRouter policy can change the remaining percentage. - Catalog discovery is scoped to the configured proxy key and cached per credential scope (agent dir, workspace dir, auth profile id, and base URL). - The proxy key is attached only at request dispatch; it is not stored in model metadata. +- Automatic attribution values are trimmed, control-character rejected, and bounded to 256 characters before dispatch. +- Model transport diagnostics contain metadata only and never include the proxy key or model content. - Native Anthropic and Gemini model ids are rewritten to their upstream ids only at dispatch. - Unsupported or ungranted catalog rows fail closed and are not selectable. diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 69606623e221..528e53ea9718 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -452,7 +452,9 @@ still account-based. OpenClaw selects auth in this order: `auth.order.openai`. Run `openclaw doctor --fix` to migrate older legacy Codex auth profile ids and auth order. 2. The app-server's existing account, such as a local Codex CLI ChatGPT - sign-in. + sign-in. For the default isolated agent home, OpenClaw bridges that native + CLI account into the app-server through its login RPC; it does not share the + CLI's config, plugins, or thread store. 3. For local stdio app-server launches only, and only when the app-server reports no account: `CODEX_API_KEY`, then `OPENAI_API_KEY`. diff --git a/docs/providers/xai.md b/docs/providers/xai.md index e3305eda23f4..9924fa7c669b 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -134,7 +134,7 @@ below or under known limits. | Server-side X search | `x_search` tool | Yes | | Server-side code execution | `code_execution` tool | Yes | | Images | `image_generate` | Yes | -| Videos | `video_generate` | Classic model; Video 1.5 is not exposed yet | +| Videos | `video_generate` | Classic full workflow; Video 1.5 image-to-video | | Batch text-to-speech | `messages.tts.provider: "xai"` / `tts` | Yes | | Streaming TTS | - | Not implemented by the xAI provider yet | | Batch speech-to-text | `tools.media.audio` media understanding | Yes | @@ -194,6 +194,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 ## Features + + `x_search` and `code_execution` run on xAI's servers. xAI bills $5 per 1,000 + tool calls, plus the model's input and output tokens. With each tool's + `enabled` setting omitted, OpenClaw exposes it only for an active xAI model. + A known non-xAI model provider requires an explicit per-tool `enabled: true`; + a missing or unresolved provider fails closed. xAI auth is always required, + and `enabled: false` disables the tool for every provider. + + The bundled `grok` web-search provider prefers xAI OAuth, then falls back @@ -210,13 +219,17 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 The bundled `xai` plugin registers video generation through the shared `video_generate` tool. - - Default video model: `xai/grok-imagine-video` - - Modes: text-to-video, image-to-video, reference-image generation, remote - video edit, and remote video extension - - Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3` - - Resolutions: `480P`, `720P` + - Default model: `xai/grok-imagine-video` + - Additional model: `xai/grok-imagine-video-1.5` + - Classic modes: text-to-video, image-to-video, reference-image generation, + remote video edit, and remote video extension + - Video 1.5 mode: image-to-video only, with exactly one first-frame image + - Aspect ratios: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `3:2`, `2:3`; + Video 1.5 inherits the source image ratio when omitted + - Resolutions: classic `480P`/`720P`; Video 1.5 supports `480P`, `720P`, + and `1080P`, and defaults to `480P` - Duration: 1-15 seconds for generation/image-to-video, 1-10 seconds when - using `reference_image` roles, 2-10 seconds for extension + using classic `reference_image` roles, 2-10 seconds for classic extension - Reference-image generation: set `imageRoles` to `reference_image` for every supplied image; xAI accepts up to 7 such images - Default operation timeout: 600 seconds unless `video_generate.timeoutMs` @@ -228,6 +241,10 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 OpenClaw encodes those as data URLs for xAI. + Video 1.5 also recognizes xAI's `grok-imagine-video-1.5-preview` and + `grok-imagine-video-1.5-2026-05-30` identifiers. OpenClaw forwards the + selected identifier unchanged, but applies the same image-only validation. + To use xAI as the default video provider: ```json5 @@ -421,15 +438,15 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 Config path: `plugins.entries.xai.config.xSearch` - | Key | Type | Default | Description | - | ----------------- | ------- | ------------------------------ | ------------------------------------- | - | `enabled` | boolean | `true` (if key available) | Enable or disable x_search | - | `model` | string | `grok-4.3` | Model used for x_search requests | - | `baseUrl` | string | - | xAI Responses base URL override | - | `inlineCitations` | boolean | - | Include inline citations in results | - | `maxTurns` | number | - | Maximum conversation turns | - | `timeoutSeconds` | number | `30` | Request timeout in seconds | - | `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes | + | Key | Type | Default | Description | + | ----------------- | ------- | ------------------------- | ------------------------------------------------ | + | `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider | + | `model` | string | `grok-4.3` | Model used for x_search requests | + | `baseUrl` | string | - | xAI Responses base URL override | + | `inlineCitations` | boolean | - | Include inline citations in results | + | `maxTurns` | number | - | Maximum conversation turns | + | `timeoutSeconds` | number | `30` | Request timeout in seconds | + | `cacheTtlMinutes` | number | `15` | Cache time-to-live in minutes | ```json5 { @@ -458,12 +475,12 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 Config path: `plugins.entries.xai.config.codeExecution` - | Key | Type | Default | Description | - | ---------------- | ------- | -------------------------- | ---------------------------------------- | - | `enabled` | boolean | `true` (if key available) | Enable or disable code execution | - | `model` | string | `grok-4.3` | Model used for code execution requests | - | `maxTurns` | number | - | Maximum conversation turns | - | `timeoutSeconds` | number | `30` | Request timeout in seconds | + | Key | Type | Default | Description | + | ---------------- | ------- | ------------------------ | ------------------------------------------------ | + | `enabled` | boolean | Automatic for xAI models | Disable, or opt in for a known non-xAI provider | + | `model` | string | `grok-4.3` | Model used for code execution requests | + | `maxTurns` | number | - | Maximum conversation turns | + | `timeoutSeconds` | number | `30` | Request timeout in seconds | This is remote xAI sandbox execution, not local [`exec`](/tools/exec). @@ -502,9 +519,6 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20 - xAI Realtime voice is not registered as an OpenClaw provider yet. It needs a different bidirectional voice session contract than batch STT or streaming transcription. - - `grok-imagine-video-1.5` is not exposed yet. Unlike the classic video - model, it is image-to-video only and needs model-specific mode and 1080p - validation in the shared provider contract. - xAI image `quality`, image `mask`, and extra native-only aspect ratios are not exposed until the shared `image_generate` tool has corresponding cross-provider controls. @@ -546,6 +560,7 @@ The xAI media paths are covered by unit tests and opt-in live suites. Export ```bash pnpm test extensions/xai OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/xai.live.test.ts +OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_XAI_VIDEO_15=1 pnpm test:live -- extensions/xai/xai.live.test.ts -t "Grok Imagine Video 1.5" OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 pnpm test:live -- extensions/xai/x-search.live.test.ts OPENCLAW_LIVE_GATEWAY_MODELS="xai/grok-4.5,xai/grok-build-0.1,xai/grok-4.3,xai/grok-4.20-0309-reasoning,xai/grok-4.20-0309-non-reasoning" OPENCLAW_LIVE_GATEWAY_MAX_MODELS=0 OPENCLAW_LIVE_GATEWAY_SMOKE=0 pnpm test:live -- src/gateway/gateway-models.profiles.live.test.ts OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_TEST_QUIET=1 OPENCLAW_LIVE_IMAGE_GENERATION_PROVIDERS=xai pnpm test:live -- test/image-generation.runtime.live.test.ts @@ -555,7 +570,9 @@ The provider-specific live file synthesizes normal TTS, telephony-friendly PCM TTS, transcribes audio through xAI batch STT, streams the same PCM through xAI realtime STT, generates text-to-image output, and edits a reference image. The shared image live file verifies the same xAI provider through OpenClaw's -runtime selection, fallback, normalization, and media attachment path. +runtime selection, fallback, normalization, and media attachment path. The +opt-in Video 1.5 case submits one generated first-frame image at 1080P and +verifies the completed video download. ## Related diff --git a/docs/tools/code-execution.md b/docs/tools/code-execution.md index c52d993f49d8..5d099322bd07 100644 --- a/docs/tools/code-execution.md +++ b/docs/tools/code-execution.md @@ -11,6 +11,11 @@ title: "Code execution" (`https://api.x.ai/v1/responses`, same endpoint `x_search` uses). It is registered by the bundled `xai` plugin under the `tools` contract. + + `code_execution` runs on xAI's servers. xAI bills $5 per 1,000 tool calls, + plus the model's input and output tokens. + + | Property | Value | | ------------------ | --------------------------------------------------------------------------------- | | Tool name | `code_execution` | @@ -77,9 +82,15 @@ For local execution, use [`exec`](/tools/exec) instead. - `code_execution` is available whenever xAI credentials resolve. Set - `plugins.entries.xai.config.codeExecution.enabled` to `false` to disable - it, or use the same block to override the model, turn cap, or timeout: + With `enabled` omitted, `code_execution` is exposed only when the active + model's provider is `xai` and xAI credentials resolve. For an active model + with a known non-xAI provider, set + `plugins.entries.xai.config.codeExecution.enabled` to `true` to opt in to + cross-provider use. If the active model provider is missing or unresolved, + the tool stays hidden. Set `enabled` to `false` to disable it for every + provider. xAI credentials are always required. + + Use the same block to override the model, turn cap, or timeout: ```json5 { @@ -88,7 +99,7 @@ For local execution, use [`exec`](/tools/exec) instead. xai: { config: { codeExecution: { - enabled: true, + enabled: true, // required for a known non-xAI model provider model: "grok-4.3", // override the default xAI code-execution model maxTurns: 2, // optional cap on internal tool turns timeoutSeconds: 30, // request timeout (default: 30) @@ -108,7 +119,7 @@ For local execution, use [`exec`](/tools/exec) instead. ``` `code_execution` appears in the agent's tool list once the xAI plugin - re-registers with `enabled: true`. + re-registers and the provider, enablement, and auth checks above pass. diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 5c6af0b0c564..b36a07e54002 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -89,12 +89,15 @@ The default approval socket follows the same root: `$OPENCLAW_STATE_DIR/exec-approvals.sock`, or `~/.openclaw/exec-approvals.sock` when the variable is unset. -Releases before 2026.6.11 always kept the file in `~/.openclaw`. If +Releases before 2026.6.6 always kept the file in `~/.openclaw`. If `OPENCLAW_STATE_DIR` points somewhere else and an approvals file still exists -in the default directory, run `openclaw doctor --fix` once to import it into -the state directory (the original is archived with a `.migrated` suffix). -OpenClaw never imports it automatically: a gateway pointed at a temporary or -staging state directory must not capture the default installation's approvals. +in the default directory, run `openclaw doctor --fix` directly once to import +it into the state directory (the original is archived with a `.migrated` +suffix). Interactive doctor can also preview and confirm the import. Automated +update and Gateway watch repair runs never import across state directories: a +temporary or staging state directory must not capture the default +installation's approvals. The same boundary applies to legacy +`plugin-binding-approvals.json` imports into shared SQLite state. Example schema: diff --git a/docs/tools/video-generation.md b/docs/tools/video-generation.md index d5d0c5832d16..6fe10a249ba4 100644 --- a/docs/tools/video-generation.md +++ b/docs/tools/video-generation.md @@ -119,7 +119,7 @@ openclaw tasks cancel | Runway | `gen4.5` | ✓ | 1 image | 1 video | `RUNWAYML_API_SECRET` | | Together | `Wan-AI/Wan2.2-T2V-A14B` | ✓ | `Wan-AI/Wan2.2-I2V-A14B` only | - | `TOGETHER_API_KEY` | | Vydra | `veo3` | ✓ | 1 image (`kling`) | - | `VYDRA_API_KEY` | -| xAI | `grok-imagine-video` | ✓ | 1 first-frame image or up to 7 `reference_image`s | 1 video | `XAI_API_KEY` | +| xAI | `grok-imagine-video` | ✓ | Classic: 1 first frame or 7 references; 1.5: 1 frame | Classic: 1 video | `XAI_API_KEY` | Some providers accept additional or alternate API key env vars. See individual [provider pages](#related) for details. @@ -147,7 +147,7 @@ the shared live sweep: | Runway | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` runs only when the selected model is `runway/gen4_aleph` | | Together | ✓ | ✓ | - | `generate`, `imageToVideo` | | Vydra | ✓ | ✓ | - | `generate`; shared `imageToVideo` skipped because bundled `veo3` is text-only and bundled `kling` requires a remote image URL | -| xAI | ✓ | ✓ | ✓ | `generate`, `imageToVideo`; `videoToVideo` skipped because this provider currently needs a remote MP4 URL | +| xAI | ✓ | ✓ | ✓ | Classic supports all modes; Video 1.5 is image-to-video only; remote MP4 input keeps `videoToVideo` out of the shared sweep | ## Tool parameters @@ -423,9 +423,16 @@ only the explicit `model`, `primary`, and `fallbacks` entries. a remote image URL. - Supports text-to-video, single first-frame image-to-video, up to 7 - `reference_image` inputs through xAI `reference_images`, and remote - video edit/extend flows. + The default `grok-imagine-video` model supports text-to-video, single + first-frame image-to-video, up to 7 `reference_image` inputs through xAI + `reference_images`, and remote video edit/extend flows. + + `grok-imagine-video-1.5` is image-to-video only: provide exactly one image. + It supports 1-15 seconds and `480P`, `720P`, or `1080P`, defaulting to + `480P`; omit `aspectRatio` to inherit the source image ratio. The preview + and dated 1.5 identifiers receive the same validation and are forwarded + unchanged. + diff --git a/docs/tools/web.md b/docs/tools/web.md index 2ddba95b9c33..03494450d8ef 100644 --- a/docs/tools/web.md +++ b/docs/tools/web.md @@ -418,6 +418,11 @@ optional structured filters. OpenClaw constructs the built-in xAI `x_search` tool per request rather than keeping it permanently registered, so it is only active for the turn that actually calls it. + + `x_search` runs on xAI's servers. xAI bills $5 per 1,000 tool calls, plus the + model's input and output tokens. + + xAI documents `x_search` as supporting keyword search, semantic search, user search, and thread fetch. For per-post engagement stats such as reposts, @@ -429,6 +434,13 @@ active for the turn that actually calls it. ### x_search config +With `enabled` omitted, `x_search` is exposed only when the active model's +provider is `xai` and xAI credentials resolve. For an active model with a known +non-xAI provider, set `plugins.entries.xai.config.xSearch.enabled` to `true` to +opt in to cross-provider use. If the active model provider is missing or +unresolved, the tool stays hidden. Set `enabled` to `false` to disable it for +every provider. xAI credentials are always required. + ```json5 { plugins: { @@ -436,7 +448,7 @@ active for the turn that actually calls it. xai: { config: { xSearch: { - enabled: true, + enabled: true, // required for a known non-xAI model provider model: "grok-4.3", baseUrl: "https://api.x.ai/v1", // optional, overrides webSearch.baseUrl inlineCitations: false, diff --git a/extensions/browser/src/browser-proxy-envelope.ts b/extensions/browser/src/browser-proxy-envelope.ts index a0a4999bbec9..7cc77d9bb785 100644 --- a/extensions/browser/src/browser-proxy-envelope.ts +++ b/extensions/browser/src/browser-proxy-envelope.ts @@ -6,12 +6,77 @@ import { parseBrowserErrorPayload, type BrowserNoDisplayErrorMetadata } from "./ /** Additive opt-in for structured browser route errors over node.invoke. */ export const BROWSER_PROXY_ERROR_ENVELOPE = "browser-v1" as const; +export const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; +// 16 MiB expands to about 21.4 MiB in base64, leaving JSON/result headroom +// below the Gateway's 25 MiB WebSocket frame limit. +export const BROWSER_PROXY_MAX_TOTAL_FILE_BYTES = 16 * 1024 * 1024; +export const BROWSER_PROXY_MAX_FILES = 256; + +/** Bound filesystem work even when one action emits many tiny downloads. */ +export function assertBrowserProxyFileCountWithinLimit(fileCount: number): void { + if (fileCount > BROWSER_PROXY_MAX_FILES) { + throw new Error("browser proxy response exceeds 256 file limit"); + } +} + +/** Enforce the shared per-file and raw aggregate Browser proxy limits. */ +export function assertBrowserProxyFileBytesWithinLimits( + fileBytes: number, + totalBytes: number, +): void { + if (fileBytes > BROWSER_PROXY_MAX_FILE_BYTES) { + throw new Error("browser proxy file exceeds 10 MiB limit"); + } + if (totalBytes > BROWSER_PROXY_MAX_TOTAL_FILE_BYTES) { + throw new Error("browser proxy files exceed 16 MiB aggregate limit"); + } +} + export type BrowserProxyFile = { path: string; base64: string; mimeType?: string; }; +/** Visit the route-owned file paths that may cross the Browser node boundary. */ +export function visitBrowserProxyFilePaths( + result: unknown, + visit: (filePath: string) => string | void, +): void { + if (!result || typeof result !== "object" || Array.isArray(result)) { + return; + } + const root = result as Record; + const visitPath = (owner: Record, key: "path" | "imagePath") => { + const filePath = owner[key]; + if (typeof filePath !== "string" || !filePath.trim()) { + return; + } + const replacement = visit(filePath); + if (typeof replacement === "string") { + owner[key] = replacement; + } + }; + + visitPath(root, "path"); + visitPath(root, "imagePath"); + + const download = root.download; + if (download && typeof download === "object" && !Array.isArray(download)) { + visitPath(download as Record, "path"); + } + + // Stay shallow: evaluate results contain page-controlled objects whose + // path-like fields must never become node filesystem reads. + if (Array.isArray(root.downloads)) { + for (const entry of root.downloads) { + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + visitPath(entry as Record, "path"); + } + } + } +} + export type BrowserProxyErrorBody = | { error: string } | ({ error: string } & BrowserNoDisplayErrorMetadata); diff --git a/extensions/browser/src/browser/proxy-files.test.ts b/extensions/browser/src/browser/proxy-files.test.ts index 15b9d3ef8ed0..2c1e06f83c93 100644 --- a/extensions/browser/src/browser/proxy-files.test.ts +++ b/extensions/browser/src/browser/proxy-files.test.ts @@ -1,9 +1,13 @@ // Browser tests cover proxy files plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; -import { MEDIA_MAX_BYTES } from "openclaw/plugin-sdk/media-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTempHomeEnv, type TempHomeEnv } from "../../test-support.js"; +import { + BROWSER_PROXY_MAX_FILE_BYTES, + BROWSER_PROXY_MAX_FILES, + BROWSER_PROXY_MAX_TOTAL_FILE_BYTES, +} from "../browser-proxy-envelope.js"; import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "./proxy-files.js"; describe("persistBrowserProxyFiles", () => { @@ -35,35 +39,135 @@ describe("persistBrowserProxyFiles", () => { await expect(fs.readFile(savedPath ?? "", "utf8")).resolves.toBe("hello from browser proxy"); }); - it("rejects browser proxy files that exceed the shared media size limit", async () => { - const oversized = Buffer.alloc(MEDIA_MAX_BYTES + 1, 0x41); + it("persists a file at the proxy limit above the shared media default", async () => { + const sourcePath = "/tmp/above-default.bin"; + const buffer = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41); + const mapping = await persistBrowserProxyFiles([ + { + path: sourcePath, + base64: buffer.toString("base64"), + mimeType: "application/octet-stream", + }, + ]); - await expect( - persistBrowserProxyFiles([ - { - path: "/tmp/oversized.bin", - base64: oversized.toString("base64"), - mimeType: "application/octet-stream", - }, - ]), - ).rejects.toThrow("Media exceeds 5MB limit"); + await expect(fs.stat(mapping.get(sourcePath) ?? "")).resolves.toMatchObject({ + size: buffer.byteLength, + }); + }); + + it("rejects an oversized aggregate before persisting any files", async () => { + const first = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES, 0x41); + const second = Buffer.alloc( + BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1, + 0x42, + ); + + const error = await persistBrowserProxyFiles([ + { + path: "/tmp/first.bin", + base64: first.toString("base64"), + mimeType: "application/octet-stream", + }, + { + path: "/tmp/second.bin", + base64: second.toString("base64"), + mimeType: "application/octet-stream", + }, + ]).then( + () => null, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("browser proxy files exceed 16 MiB aggregate limit"); await expect( fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), ).rejects.toHaveProperty("code", "ENOENT"); }); - it("rewrites nested download paths after node file persistence", () => { + it("rejects a file above the proxy per-file limit", async () => { + const oversized = Buffer.alloc(BROWSER_PROXY_MAX_FILE_BYTES + 1, 0x41); + const error = await persistBrowserProxyFiles([ + { + path: "/tmp/oversized.bin", + base64: oversized.toString("base64"), + mimeType: "application/octet-stream", + }, + ]).then( + () => null, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("browser proxy file exceeds 10 MiB limit"); + + await expect( + fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + + it("rejects too many files before persisting any", async () => { + const files = Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({ + path: `/tmp/file-${index}.bin`, + base64: "", + mimeType: "application/octet-stream", + })); + + await expect(persistBrowserProxyFiles(files)).rejects.toThrow( + "browser proxy response exceeds 256 file limit", + ); + await expect( + fs.stat(path.join(tempHome.home, ".openclaw", "media", "browser")), + ).rejects.toHaveProperty("code", "ENOENT"); + }); + + it("rewrites explicit proxy file paths without traversing nested page data", () => { const result = { ok: true, - download: { path: "/tmp/openclaw/downloads/report.pdf" }, + path: "/node/screenshot.png", + imagePath: "/node/snapshot.png", + download: { path: "/node/download.csv", suggestedFilename: "download.csv" }, + downloads: [ + { path: "/node/first.pdf", suggestedFilename: "first.pdf" }, + null, + { path: 42 }, + { path: "/node/second.pdf", suggestedFilename: "second.pdf" }, + { path: "/node/first.pdf", suggestedFilename: "first-copy.pdf" }, + ], + result: { + path: "/node/page-controlled.txt", + downloads: [{ path: "/node/page-controlled-download.txt" }], + }, }; applyBrowserProxyPaths( result, - new Map([["/tmp/openclaw/downloads/report.pdf", "/tmp/openclaw-media/report.pdf"]]), + new Map([ + ["/node/screenshot.png", "/gateway/screenshot.png"], + ["/node/snapshot.png", "/gateway/snapshot.png"], + ["/node/download.csv", "/gateway/download.csv"], + ["/node/first.pdf", "/gateway/first.pdf"], + ["/node/second.pdf", "/gateway/second.pdf"], + ["/node/page-controlled.txt", "/gateway/should-not-rewrite.txt"], + ["/node/page-controlled-download.txt", "/gateway/should-not-rewrite-download.txt"], + ]), ); - expect(result.download.path).toBe("/tmp/openclaw-media/report.pdf"); + expect(result).toEqual({ + ok: true, + path: "/gateway/screenshot.png", + imagePath: "/gateway/snapshot.png", + download: { path: "/gateway/download.csv", suggestedFilename: "download.csv" }, + downloads: [ + { path: "/gateway/first.pdf", suggestedFilename: "first.pdf" }, + null, + { path: 42 }, + { path: "/gateway/second.pdf", suggestedFilename: "second.pdf" }, + { path: "/gateway/first.pdf", suggestedFilename: "first-copy.pdf" }, + ], + result: { + path: "/node/page-controlled.txt", + downloads: [{ path: "/node/page-controlled-download.txt" }], + }, + }); }); }); diff --git a/extensions/browser/src/browser/proxy-files.ts b/extensions/browser/src/browser/proxy-files.ts index 101170d9edd3..3b0580f4faea 100644 --- a/extensions/browser/src/browser/proxy-files.ts +++ b/extensions/browser/src/browser/proxy-files.ts @@ -4,45 +4,44 @@ * Persists files returned by node-hosted browser proxy calls and rewrites * proxied result paths to local saved media paths. */ +import { + assertBrowserProxyFileCountWithinLimit, + assertBrowserProxyFileBytesWithinLimits, + BROWSER_PROXY_MAX_FILE_BYTES, + type BrowserProxyFile, + visitBrowserProxyFilePaths, +} from "../browser-proxy-envelope.js"; import { saveMediaBuffer } from "../media/store.js"; -type BrowserProxyFile = { - path: string; - base64: string; - mimeType?: string; -}; - /** Persist proxy-returned files and return a remote-path to local-path map. */ export async function persistBrowserProxyFiles(files: BrowserProxyFile[] | undefined) { if (!files || files.length === 0) { return new Map(); } - const mapping = new Map(); + assertBrowserProxyFileCountWithinLimit(files.length); + const decoded: Array<{ file: BrowserProxyFile; buffer: Buffer }> = []; + let totalBytes = 0; for (const file of files) { const buffer = Buffer.from(file.base64, "base64"); - const saved = await saveMediaBuffer(buffer, file.mimeType, "browser"); + totalBytes += buffer.byteLength; + assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes); + decoded.push({ file, buffer }); + } + + const mapping = new Map(); + for (const { file, buffer } of decoded) { + const saved = await saveMediaBuffer( + buffer, + file.mimeType, + "browser", + BROWSER_PROXY_MAX_FILE_BYTES, + ); mapping.set(file.path, saved.path); } return mapping; } -/** Rewrite result.path when it points at a persisted proxy file. */ +/** Rewrite every supported result path that points at a persisted proxy file. */ export function applyBrowserProxyPaths(result: unknown, mapping: Map) { - if (!result || typeof result !== "object") { - return; - } - const obj = result as Record; - if (typeof obj.path === "string" && mapping.has(obj.path)) { - obj.path = mapping.get(obj.path); - } - if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) { - obj.imagePath = mapping.get(obj.imagePath); - } - const download = obj.download; - if (download && typeof download === "object") { - const d = download as Record; - if (typeof d.path === "string" && mapping.has(d.path)) { - d.path = mapping.get(d.path); - } - } + visitBrowserProxyFilePaths(result, (filePath) => mapping.get(filePath)); } diff --git a/extensions/browser/src/node-host/invoke-browser.test.ts b/extensions/browser/src/node-host/invoke-browser.test.ts index 40bf1d7f3813..b3e7dacc1591 100644 --- a/extensions/browser/src/node-host/invoke-browser.test.ts +++ b/extensions/browser/src/node-host/invoke-browser.test.ts @@ -1,6 +1,14 @@ // Browser tests cover invoke browser plugin behavior. +import fs from "node:fs/promises"; +import os from "node:os"; +import nodePath from "node:path"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + BROWSER_PROXY_MAX_FILE_BYTES, + BROWSER_PROXY_MAX_FILES, + BROWSER_PROXY_MAX_TOTAL_FILE_BYTES, +} from "../browser-proxy-envelope.js"; const controlServiceMocks = vi.hoisted(() => ({ createBrowserControlContext: vi.fn(() => ({ control: true })), @@ -191,6 +199,117 @@ describe("runBrowserProxyCommand", () => { controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true); }); + it("serializes plural action downloads without reading nested page paths", async () => { + const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-action-")); + const firstPath = nodePath.join(tempDir, "first.txt"); + const secondPath = nodePath.join(tempDir, "second.txt"); + const nestedPagePath = nodePath.join(tempDir, "page-controlled.txt"); + const result = { + ok: true, + downloads: [ + { path: firstPath, suggestedFilename: "first.txt" }, + null, + { path: 42 }, + { path: secondPath, suggestedFilename: "second.txt" }, + { path: firstPath, suggestedFilename: "first-copy.txt" }, + ], + result: { + path: nestedPagePath, + downloads: [{ path: nestedPagePath }], + }, + }; + + try { + await Promise.all([ + fs.writeFile(firstPath, "first browser download", "utf8"), + fs.writeFile(secondPath, "second browser download", "utf8"), + fs.writeFile(nestedPagePath, "must stay on the node", "utf8"), + ]); + dispatcherMocks.dispatch.mockResolvedValueOnce({ status: 200, body: result }); + + const payload = JSON.parse( + await runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })), + ) as { + result: unknown; + files?: Array<{ path: string; base64: string; mimeType?: string }>; + }; + + expect(payload.result).toEqual(result); + expect( + payload.files?.map((file) => ({ + path: file.path, + contents: Buffer.from(file.base64, "base64").toString("utf8"), + mimeType: file.mimeType, + })), + ).toEqual([ + { path: firstPath, contents: "first browser download", mimeType: "image/png" }, + { path: secondPath, contents: "second browser download", mimeType: "image/png" }, + ]); + expect(payload.files?.some((file) => file.path === nestedPagePath)).toBe(false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects an aggregate above the proxy transport budget", async () => { + const tempDir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "openclaw-browser-proxy-limit-")); + const firstPath = nodePath.join(tempDir, "first.bin"); + const secondPath = nodePath.join(tempDir, "second.bin"); + try { + await Promise.all([fs.writeFile(firstPath, ""), fs.writeFile(secondPath, "")]); + await Promise.all([ + fs.truncate(firstPath, BROWSER_PROXY_MAX_FILE_BYTES), + fs.truncate( + secondPath, + BROWSER_PROXY_MAX_TOTAL_FILE_BYTES - BROWSER_PROXY_MAX_FILE_BYTES + 1, + ), + ]); + dispatcherMocks.dispatch.mockResolvedValueOnce({ + status: 200, + body: { downloads: [{ path: firstPath }, { path: secondPath }] }, + }); + + const error = await runBrowserProxyCommand( + JSON.stringify({ method: "POST", path: "/act" }), + ).then( + () => null, + (err: unknown) => err, + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + `browser proxy file read failed for ${secondPath}: Error: browser proxy files exceed 16 MiB aggregate limit`, + ); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("rejects too many unique files before reading them", async () => { + dispatcherMocks.dispatch.mockResolvedValueOnce({ + status: 200, + body: { + downloads: Array.from({ length: BROWSER_PROXY_MAX_FILES + 1 }, (_, index) => ({ + path: `/missing/browser-download-${index}.bin`, + })), + }, + }); + + await expect( + runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })), + ).rejects.toThrow("browser proxy response exceeds 256 file limit"); + }); + + it("rejects a result whose encoded node frame would exceed the transport limit", async () => { + dispatcherMocks.dispatch.mockResolvedValueOnce({ + status: 200, + body: { result: "\\".repeat(7 * 1024 * 1024) }, + }); + + await expect( + runBrowserProxyCommand(JSON.stringify({ method: "POST", path: "/act" })), + ).rejects.toThrow("browser proxy payload exceeds 24 MiB encoded limit"); + }); + it("adds profile and browser status details on ws-backed timeouts", async () => { vi.useFakeTimers(); dispatcherMocks.dispatch diff --git a/extensions/browser/src/node-host/invoke-browser.ts b/extensions/browser/src/node-host/invoke-browser.ts index 41fbfc3c01a3..9703e447eadf 100644 --- a/extensions/browser/src/node-host/invoke-browser.ts +++ b/extensions/browser/src/node-host/invoke-browser.ts @@ -6,10 +6,13 @@ import fsPromises from "node:fs/promises"; import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { + assertBrowserProxyFileCountWithinLimit, + assertBrowserProxyFileBytesWithinLimits, BROWSER_PROXY_ERROR_ENVELOPE, createBrowserProxyFailure, type BrowserProxyEnvelope, type BrowserProxyFile, + visitBrowserProxyFilePaths, } from "../browser-proxy-envelope.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { loadBrowserConfigForRuntimeRefresh } from "../browser/config-refresh-source.js"; @@ -37,9 +40,10 @@ type BrowserProxyParams = { errorEnvelope?: unknown; }; -const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000; const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750; +// Leave one MiB for the fixed node.invoke.result frame around payloadJSON. +const BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES = 24 * 1024 * 1024; function normalizeProfileAllowlist(raw?: string[]): string[] { return Array.isArray(raw) ? normalizeStringEntries(raw) : []; @@ -93,40 +97,36 @@ function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | function collectBrowserProxyPaths(payload: unknown): string[] { const paths = new Set(); - const obj = - typeof payload === "object" && payload !== null ? (payload as Record) : null; - if (!obj) { - return []; - } - if (typeof obj.path === "string" && obj.path.trim()) { - paths.add(obj.path.trim()); - } - if (typeof obj.imagePath === "string" && obj.imagePath.trim()) { - paths.add(obj.imagePath.trim()); - } - const download = obj.download; - if (download && typeof download === "object") { - const dlPath = (download as Record).path; - if (typeof dlPath === "string" && dlPath.trim()) { - paths.add(dlPath.trim()); - } - } + visitBrowserProxyFilePaths(payload, (filePath) => { + paths.add(filePath.trim()); + assertBrowserProxyFileCountWithinLimit(paths.size); + }); return [...paths]; } -async function readBrowserProxyFile(filePath: string): Promise { - const stat = await fsPromises.stat(filePath).catch(() => null); - if (!stat || !stat.isFile()) { - return null; +async function readBrowserProxyFiles(filePaths: string[]): Promise { + const files: BrowserProxyFile[] = []; + let totalBytes = 0; + for (const filePath of filePaths) { + try { + const stat = await fsPromises.stat(filePath).catch(() => null); + if (!stat || !stat.isFile()) { + throw new Error("file not found"); + } + assertBrowserProxyFileBytesWithinLimits(stat.size, totalBytes + stat.size); + + const buffer = await fsPromises.readFile(filePath); + assertBrowserProxyFileBytesWithinLimits(buffer.byteLength, totalBytes + buffer.byteLength); + totalBytes += buffer.byteLength; + const mimeType = await detectMime({ buffer, filePath }); + files.push({ path: filePath, base64: buffer.toString("base64"), mimeType }); + } catch (err) { + throw new Error(`browser proxy file read failed for ${filePath}: ${String(err)}`, { + cause: err, + }); + } } - if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) { - throw new Error( - `browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`, - ); - } - const buffer = await fsPromises.readFile(filePath); - const mimeType = await detectMime({ buffer, filePath }); - return { path: filePath, base64: buffer.toString("base64"), mimeType }; + return files; } // oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- CLI JSON params are typed by the invoked method. @@ -339,29 +339,14 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis }); } - let files: BrowserProxyFile[] | undefined; const paths = collectBrowserProxyPaths(result); - if (paths.length > 0) { - const loaded = await Promise.all( - paths.map(async (p) => { - try { - const file = await readBrowserProxyFile(p); - if (!file) { - throw new Error("file not found"); - } - return file; - } catch (err) { - throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`, { - cause: err, - }); - } - }), - ); - if (loaded.length > 0) { - files = loaded; - } - } + const files = paths.length > 0 ? await readBrowserProxyFiles(paths) : undefined; const payload: BrowserProxyEnvelope = files ? { result, files } : { result }; - return JSON.stringify(payload); + const serialized = JSON.stringify(payload); + // Node results carry this JSON as a string inside a second JSON frame. + if (Buffer.byteLength(JSON.stringify(serialized)) > BROWSER_PROXY_MAX_ENCODED_PAYLOAD_BYTES) { + throw new Error("browser proxy payload exceeds 24 MiB encoded limit"); + } + return serialized; } diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index 5d42803a316b..248e07250a74 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -10,6 +10,7 @@ const providerAuthRuntimeMocks = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks); import plugin from "./index.js"; +import { wrapClawRouterProviderStream } from "./stream.js"; const LIVE_CATALOG = { providers: [ @@ -101,12 +102,81 @@ describe("ClawRouter plugin", () => { expect(calls[0]?.headers).toEqual({ "X-Request-ID": "request-1", + "X-ClawRouter-Client": "openclaw", Authorization: "Bearer runtime-proxy-key", }); expect(calls[0]?.id).toBe("claude-sonnet-4-6"); expect(calls[0]?.params).toBeUndefined(); }); + it("attaches bounded attribution without overriding configured metadata", () => { + const calls: Array[0]> = []; + const baseStreamFn: StreamFn = (model) => { + calls.push(model); + return {} as ReturnType; + }; + const wrapped = wrapClawRouterProviderStream({ + provider: "clawrouter", + modelId: "openai/gpt-5.5", + agentId: "main", + streamFn: baseStreamFn, + } as never); + + void wrapped?.( + { + provider: "clawrouter", + api: "openai-responses", + id: "openai/gpt-5.5", + headers: { + "x-clawrouter-client": "managed-openclaw", + "X-ClawRouter-Project-Id": "fakeco", + }, + } as never, + {} as never, + { + apiKey: "runtime-proxy-key", + sessionId: `session-${"x".repeat(300)}`, + } as never, + ); + + expect(calls[0]?.headers).toMatchObject({ + "x-clawrouter-client": "managed-openclaw", + "X-ClawRouter-Agent-Id": "main", + "X-ClawRouter-Project-Id": "fakeco", + Authorization: "Bearer runtime-proxy-key", + }); + expect(calls[0]?.headers?.["X-ClawRouter-Session-Id"]).toHaveLength(256); + }); + + it("omits unsafe attribution header values", () => { + const calls: Array[0]> = []; + const baseStreamFn: StreamFn = (model) => { + calls.push(model); + return {} as ReturnType; + }; + const wrapped = wrapClawRouterProviderStream({ + provider: "clawrouter", + modelId: "openai/gpt-5.5", + agentId: "bad\nagent", + streamFn: baseStreamFn, + } as never); + + void wrapped?.( + { + provider: "clawrouter", + api: "openai-responses", + id: "openai/gpt-5.5", + } as never, + {} as never, + { apiKey: "runtime-proxy-key", sessionId: "bad\rsession" } as never, + ); + + expect(calls[0]?.headers).toEqual({ + "X-ClawRouter-Client": "openclaw", + Authorization: "Bearer runtime-proxy-key", + }); + }); + it("resolves managed secret refs before scoped discovery", async () => { providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({ apiKey: "resolved-proxy-key", diff --git a/extensions/clawrouter/stream.ts b/extensions/clawrouter/stream.ts index ddedad9167f7..3898097df1dd 100644 --- a/extensions/clawrouter/stream.ts +++ b/extensions/clawrouter/stream.ts @@ -3,35 +3,84 @@ import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-ent import { prepareClawRouterRequestModel } from "./provider-catalog.js"; const ENV_API_KEY_MARKER = "CLAWROUTER_API_KEY"; +const ATTRIBUTION_VALUE_MAX_LENGTH = 256; +const CLIENT_HEADER = "X-ClawRouter-Client"; +const AGENT_HEADER = "X-ClawRouter-Agent-Id"; +const SESSION_HEADER = "X-ClawRouter-Session-Id"; -function withBearerAuthorization( +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) { + return true; + } + } + return false; +} + +function sanitizeAttributionValue(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized || hasControlCharacter(normalized)) { + return undefined; + } + return normalized.slice(0, ATTRIBUTION_VALUE_MAX_LENGTH); +} + +function findHeader(headers: Record, target: string): string | undefined { + const normalizedTarget = target.toLowerCase(); + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() === normalizedTarget) { + return value; + } + } + return undefined; +} + +function setHeaderDefault( + headers: Record, + name: string, + value: string | undefined, +): void { + if (value !== undefined && findHeader(headers, name) === undefined) { + headers[name] = value; + } +} + +function withClawRouterHeaders( headers: Record | undefined, - apiKey: string, + params: { agentId?: string; apiKey?: string; sessionId?: string }, ): Record { const next: Record = {}; for (const [name, value] of Object.entries(headers ?? {})) { - if (name.toLowerCase() !== "authorization") { + if (name.toLowerCase() !== "authorization" || !params.apiKey) { next[name] = value; } } - next.Authorization = `Bearer ${apiKey}`; + setHeaderDefault(next, CLIENT_HEADER, "openclaw"); + setHeaderDefault(next, AGENT_HEADER, sanitizeAttributionValue(params.agentId)); + setHeaderDefault(next, SESSION_HEADER, sanitizeAttributionValue(params.sessionId)); + if (params.apiKey) { + next.Authorization = `Bearer ${params.apiKey}`; + } return next; } -function createClawRouterStreamWrapper(underlying: StreamFn | undefined): StreamFn | undefined { +function createClawRouterStreamWrapper(ctx: ProviderWrapStreamFnContext): StreamFn | undefined { + const underlying = ctx.streamFn; if (!underlying) { return undefined; } return (model, context, options) => { const apiKey = options?.apiKey?.trim(); const preparedModel = prepareClawRouterRequestModel(model); - if (!apiKey || apiKey === ENV_API_KEY_MARKER) { - return underlying(preparedModel, context, options); - } return underlying( { ...preparedModel, - headers: withBearerAuthorization(preparedModel.headers, apiKey), + headers: withClawRouterHeaders(preparedModel.headers, { + agentId: ctx.agentId, + apiKey: apiKey && apiKey !== ENV_API_KEY_MARKER ? apiKey : undefined, + sessionId: options?.sessionId, + }), }, context, options, @@ -42,5 +91,5 @@ function createClawRouterStreamWrapper(underlying: StreamFn | undefined): Stream export function wrapClawRouterProviderStream( ctx: ProviderWrapStreamFnContext, ): StreamFn | undefined { - return createClawRouterStreamWrapper(ctx.streamFn); + return createClawRouterStreamWrapper(ctx); } diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index 1bb350a46514..4c869d5a179f 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -60,6 +60,7 @@ export function createCodexAppServerAgentHarness(options: { deliveryDefaults: { sourceVisibleReplies: "message_tool", }, + authBootstrap: "harness", supports: (ctx) => { const provider = ctx.provider.trim().toLowerCase(); if (providerIds.has(provider)) { diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index 962e2f02611c..0af01c09c3b9 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -379,6 +379,14 @@ describe("codex plugin", () => { ); }); + it("owns auth bootstrap for forwarded profiles and native Codex sign-in", () => { + const harness = createCodexAppServerAgentHarness({ + bindingStore: testCodexAppServerBindingStore, + }); + + expect(harness.authBootstrap).toBe("harness"); + }); + it("passes live Codex plugin config into public Codex app-server attempts", async () => { const registerAgentHarness = vi.fn(); const liveConfig = { diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts index b20cdc6ea8bd..6d7144f2cafe 100644 --- a/extensions/codex/src/app-server/attempt-context.test.ts +++ b/extensions/codex/src/app-server/attempt-context.test.ts @@ -6,6 +6,10 @@ import { embeddedAgentLog, type EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + clearMemoryPluginState, + registerMemoryCapability, +} from "openclaw/plugin-sdk/memory-host-core"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildCodexWorkspaceBootstrapContext, @@ -20,6 +24,7 @@ import type { CodexAppServerContextEngineBinding } from "./session-binding.js"; afterEach(() => { vi.restoreAllMocks(); + clearMemoryPluginState(); }); describe("Codex app-server attempt context", () => { @@ -143,6 +148,54 @@ describe("Codex app-server attempt context", () => { expect(context.memoryToolRouted).toBe(false); }); + it("passes agent context to Codex memory collaboration guidance", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agent-memory-")); + let observedContext: + | { agentId?: string; agentSessionKey?: string; sandboxed?: boolean } + | undefined; + registerMemoryCapability("memory-core", { + promptBuilder: (context) => { + observedContext = context; + return [ + "## Agent Memory", + `agent=${context.agentId} session=${context.agentSessionKey}`, + "", + ]; + }, + }); + + try { + const context = await buildCodexWorkspaceBootstrapContext({ + params: { + sessionId: "session-1", + sessionKey: "agent:marketing-agent:session-1", + config: { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "marketing-agent", default: true, workspace: workspaceDir }], + }, + }, + } as EmbeddedRunAttemptParams, + resolvedWorkspace: workspaceDir, + effectiveWorkspace: workspaceDir, + sessionKey: "agent:marketing-agent:session-1", + sessionAgentId: "marketing-agent", + memoryToolNames: ["memory_search", "memory_get"], + }); + + expect(context.memoryToolRouted).toBe(true); + expect(observedContext).toMatchObject({ + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:session-1", + }); + expect(context.memoryCollaborationInstructions).toContain( + "agent=marketing-agent session=agent:marketing-agent:session-1", + ); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("remaps Codex bootstrap files under dot-prefixed workspace directories", () => { expect( remapCodexContextFilePath({ diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 296a8b7d547c..97d5ddf288b0 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -260,6 +260,8 @@ export async function buildCodexWorkspaceBootstrapContext(params: { toolNames: params.memoryToolNames, memoryToolRouted: memoryToolsAvailable, citationsMode: params.params.config?.memory?.citations, + agentId: params.params.agentId ?? params.sessionAgentId, + agentSessionKey: params.sessionKey, }) : undefined, heartbeatCollaborationInstructions: @@ -858,11 +860,15 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: { toolNames: readonly string[]; memoryToolRouted: boolean; citationsMode?: Parameters[0]["citationsMode"]; + agentId?: string; + agentSessionKey?: string; }): string | undefined { const memoryRecallInstructions = params.memoryToolRouted ? renderCodexMemoryRecallInstructions({ toolNames: params.toolNames, citationsMode: params.citationsMode, + agentId: params.agentId, + agentSessionKey: params.agentSessionKey, }) : undefined; const memoryReferenceInstructions = renderCodexWorkspaceMemoryReference({ @@ -876,11 +882,15 @@ function renderCodexWorkspaceMemoryCollaborationInstructions(params: { function renderCodexMemoryRecallInstructions(params: { toolNames: readonly string[]; citationsMode?: Parameters[0]["citationsMode"]; + agentId?: string; + agentSessionKey?: string; }): string | undefined { const availableTools = new Set(params.toolNames); const memoryPrompt = buildMemorySystemPromptAddition({ availableTools, citationsMode: params.citationsMode, + agentId: params.agentId, + agentSessionKey: params.agentSessionKey, }); if (!memoryPrompt) { // Memory recall policy belongs to the active memory plugin. diff --git a/extensions/memory-core/src/tools.citations.test.ts b/extensions/memory-core/src/tools.citations.test.ts index 6cd99432552e..9f7fcd171f58 100644 --- a/extensions/memory-core/src/tools.citations.test.ts +++ b/extensions/memory-core/src/tools.citations.test.ts @@ -20,7 +20,11 @@ import { } from "./memory-tool-manager.test-mocks.js"; import { testing as shortTermPromotionTesting } from "./short-term-promotion.js"; import { createMemoryCoreTestHarness } from "./test-helpers.js"; -import { testing as memoryToolsTesting } from "./tools.js"; +import { + createMemoryGetTool, + createMemorySearchTool, + testing as memoryToolsTesting, +} from "./tools.js"; import { asOpenClawConfig, createAutoCitationsMemorySearchTool, @@ -393,6 +397,51 @@ describe("memory tools", () => { expect(getMemorySearchManagerMockCalls()).toBe(0); }); + it.each(["wiki", "all"] as const)( + "forwards effective agent context to memory_search corpus=%s supplements", + async (corpus) => { + const search = vi.fn(async () => [ + { + corpus: "wiki" as const, + path: "entities/alpha.md", + score: 4, + snippet: "Alpha wiki entry", + }, + ]); + registerMemoryCorpusSupplement("memory-wiki", { + search, + get: async () => null, + }); + const config = asOpenClawConfig({ + agents: { list: [{ id: "marketing-agent", default: true }] }, + }); + const tool = createMemorySearchTool({ + config, + agentId: " Marketing Agent ", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }); + if (!tool) { + throw new Error("expected memory_search tool"); + } + + await tool.execute(`call_search_${corpus}`, { + query: "alpha", + maxResults: 3, + corpus, + }); + + expect(search).toHaveBeenCalledWith({ + query: "alpha", + maxResults: 3, + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + corpus, + }); + }, + ); + it("includes memory results in corpus=all even when wiki scores are numerically higher (#77337)", async () => { // Wiki uses integer point scores (up to ~100+); memory uses cosine similarity (0-1). // Raw-score sort would starve memory hits when maxResults <= number of wiki hits. @@ -630,6 +679,57 @@ describe("memory tools", () => { }); }); + it.each(["wiki", "all"] as const)( + "forwards effective agent context to memory_get corpus=%s supplements", + async (corpus) => { + if (corpus === "all") { + setMemoryReadFileImpl(async () => { + throw new Error("memory path missing"); + }); + } + const get = vi.fn(async () => ({ + corpus: "wiki" as const, + path: "entities/alpha.md", + content: "Alpha wiki entry", + fromLine: 2, + lineCount: 4, + })); + registerMemoryCorpusSupplement("memory-wiki", { + search: async () => [], + get, + }); + const config = asOpenClawConfig({ + agents: { list: [{ id: "marketing-agent", default: true }] }, + }); + const tool = createMemoryGetTool({ + config, + agentId: " Marketing Agent ", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }); + if (!tool) { + throw new Error("expected memory_get tool"); + } + + await tool.execute(`call_get_${corpus}`, { + path: "entities/alpha.md", + from: 2, + lines: 4, + corpus, + }); + + expect(get).toHaveBeenCalledWith({ + lookup: "entities/alpha.md", + fromLine: 2, + lineCount: 4, + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + corpus, + }); + }, + ); + it("falls back to a wiki corpus supplement when memory_get corpus=all misses memory without throwing", async () => { setMemoryReadFileImpl(async (params: MemoryReadParams) => ({ text: "", diff --git a/extensions/memory-core/src/tools.shared.ts b/extensions/memory-core/src/tools.shared.ts index d526a8c5bd54..dff3233adc9c 100644 --- a/extensions/memory-core/src/tools.shared.ts +++ b/extensions/memory-core/src/tools.shared.ts @@ -19,6 +19,7 @@ type MemoryToolOptions = { getConfig?: () => OpenClawConfig | undefined; agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; oneShotCliRun?: boolean; }; @@ -154,7 +155,9 @@ export function buildMemorySearchUnavailableResult( export async function searchMemoryCorpusSupplements(params: { query: string; maxResults?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; corpus?: "memory" | "wiki" | "all" | "sessions"; }): Promise { if (params.corpus === "memory" || params.corpus === "sessions") { @@ -183,7 +186,9 @@ export async function getMemoryCorpusSupplementResult(params: { lookup: string; fromLine?: number; lineCount?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; corpus?: "memory" | "wiki" | "all" | "sessions"; }) { if (params.corpus === "memory" || params.corpus === "sessions") { diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index 66d485f7d028..8bfd47c54cfa 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -319,14 +319,18 @@ async function getSupplementMemoryReadResult(params: { relPath: string; from?: number; lines?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; corpus?: "memory" | "wiki" | "all"; }) { const supplement = await getMemoryCorpusSupplementResult({ lookup: params.relPath, fromLine: params.from, lineCount: params.lines, + agentId: params.agentId, agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, corpus: params.corpus, }); if (!supplement) { @@ -345,7 +349,9 @@ async function resolveMemoryReadFailureResult(params: { relPath: string; from?: number; lines?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }) { if (params.requestedCorpus === "all") { try { @@ -353,7 +359,9 @@ async function resolveMemoryReadFailureResult(params: { relPath: params.relPath, from: params.from, lines: params.lines, + agentId: params.agentId, agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, corpus: params.requestedCorpus, }); if (supplement) { @@ -378,7 +386,9 @@ async function executeMemoryReadResult(params: { relPath: string; from?: number; lines?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }) { try { const result = await params.read(); @@ -387,7 +397,9 @@ async function executeMemoryReadResult(params: { relPath: params.relPath, from: params.from, lines: params.lines, + agentId: params.agentId, agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, corpus: params.requestedCorpus, }); if (supplement) { @@ -402,7 +414,9 @@ async function executeMemoryReadResult(params: { relPath: params.relPath, from: params.from, lines: params.lines, + agentId: params.agentId, agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, }); } } @@ -677,7 +691,9 @@ export function createMemorySearchTool(options: { await searchMemoryCorpusSupplements({ query, maxResults, + agentId, agentSessionKey: options.agentSessionKey, + sandboxed: options.sandboxed, corpus: requestedCorpus, }), ) @@ -733,6 +749,7 @@ export function createMemoryGetTool(options: { getConfig?: () => OpenClawConfig | undefined; agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }) { return createMemoryTool({ options, @@ -759,7 +776,9 @@ export function createMemoryGetTool(options: { relPath, from: from ?? undefined, lines: lines ?? undefined, + agentId, agentSessionKey: options.agentSessionKey, + sandboxed: options.sandboxed, corpus: requestedCorpus, }); return jsonResult( @@ -786,7 +805,9 @@ export function createMemoryGetTool(options: { relPath, from: from ?? undefined, lines: lines ?? undefined, + agentId, agentSessionKey: options.agentSessionKey, + sandboxed: options.sandboxed, }); } const memory = await getMemoryManagerContextWithPurpose({ @@ -808,7 +829,9 @@ export function createMemoryGetTool(options: { relPath, from: from ?? undefined, lines: lines ?? undefined, + agentId, agentSessionKey: options.agentSessionKey, + sandboxed: options.sandboxed, }); }, }); diff --git a/extensions/memory-wiki/README.md b/extensions/memory-wiki/README.md index ca0da20669be..8dacb84be386 100644 --- a/extensions/memory-wiki/README.md +++ b/extensions/memory-wiki/README.md @@ -14,6 +14,10 @@ When the active memory plugin exposes shared recall, agents can use `memory_sear Default mode is `isolated`. +`vaultMode` controls the wiki's inputs. `vault.scope` separately controls +whether agents share one vault (`global`, the default) or resolve separate +vaults (`agent`). + ## Config Put config under `plugins.entries.memory-wiki.config`: @@ -23,6 +27,7 @@ Put config under `plugins.entries.memory-wiki.config`: vaultMode: "isolated", vault: { + scope: "global", // or "agent" path: "~/.openclaw/wiki/main", renderMode: "obsidian", // or "native" }, @@ -71,6 +76,51 @@ Put config under `plugins.entries.memory-wiki.config`: } ``` +### Per-agent vaults + +In agent scope, `vault.path` is a parent directory. OpenClaw appends the +normalized agent id: + +```json5 +{ + vaultMode: "bridge", + vault: { + scope: "agent", + path: "~/.openclaw/wiki", + }, + bridge: { + enabled: true, + readMemoryArtifacts: true, + }, + obsidian: { + useOfficialCli: false, + }, +} +``` + +This resolves agents such as `support` and `marketing` to +`~/.openclaw/wiki/support` and `~/.openclaw/wiki/marketing`. With no explicit +path, the parent defaults to `~/.openclaw/wiki`; the default `main` agent +therefore keeps the existing `~/.openclaw/wiki/main` path. In global scope, +`vault.path` remains the exact shared vault path. + +Wiki tools and compiled prompt/corpus supplements resolve the active runtime +agent on each call. In bridge mode, an agent vault imports only public memory +artifacts whose `agentIds` includes that agent; unowned and other-agent +artifacts are skipped. CLI and Gateway operations require an explicit agent in +multi-agent setups; use `openclaw wiki --agent ...` or pass `agentId` +to the `wiki.*` RPC request. A single configured agent may remain implicit. + +Configuration validation rejects agent scope with either +`vaultMode: "unsafe-local"` or `obsidian.useOfficialCli: true`. Obsidian-friendly +Markdown rendering still works with agent vaults when official CLI actions are +disabled. + +Changing scope does not copy or split existing pages. Back up the vault and +move or import content deliberately. Per-agent paths are a same-process +knowledge boundary, not an operating-system security boundary; unsandboxed +plugins and tools can still access another agent's host files. + ## Vault shape The plugin initializes a vault like this: @@ -130,6 +180,10 @@ openclaw wiki obsidian search "alpha" openclaw wiki obsidian open syntheses/alpha-summary.md openclaw wiki obsidian command workspace:quick-switcher openclaw wiki obsidian daily + +# Agent-scoped vault +openclaw wiki --agent support status +openclaw wiki --agent support search "refund policy" ``` ## Agent tools @@ -170,10 +224,14 @@ Write methods: - `wiki.obsidian.command` - `wiki.obsidian.daily` +For agent-scoped vaults, pass `agentId` to vault-backed RPC methods. Missing or +unknown ids fail in multi-agent setups. + ## Notes - `unsafe-local` is intentionally experimental and non-portable. - Bridge mode reads the active memory plugin through public seams only. +- Agent scope is incompatible with `unsafe-local` and official Obsidian CLI actions. - Wiki pages are compiled artifacts, not the ultimate source of truth. Keep provenance attached to raw sources, memory artifacts, and daily notes. - The compiled agent digests in `.openclaw-wiki/cache/agent-digest.json` and `.openclaw-wiki/cache/claims.jsonl` are the stable machine-facing view of the wiki. - Obsidian CLI support requires the official `obsidian` CLI to be installed and available on `PATH`. diff --git a/extensions/memory-wiki/cli-metadata.test.ts b/extensions/memory-wiki/cli-metadata.test.ts index 15d02f5df92e..aac28f3bc370 100644 --- a/extensions/memory-wiki/cli-metadata.test.ts +++ b/extensions/memory-wiki/cli-metadata.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ registerWikiCli: vi.fn(), + resolveMemoryWikiAgentConfig: vi.fn(), resolveMemoryWikiConfig: vi.fn(), })); @@ -13,6 +14,7 @@ vi.mock("./src/cli.js", () => ({ })); vi.mock("./src/config.js", () => ({ + resolveMemoryWikiAgentConfig: mocks.resolveMemoryWikiAgentConfig, resolveMemoryWikiConfig: mocks.resolveMemoryWikiConfig, })); @@ -73,6 +75,13 @@ describe("memory-wiki cli metadata entry", () => { expect(mocks.resolveMemoryWikiConfig).toHaveBeenCalledWith( appConfig.plugins.entries["memory-wiki"].config, ); - expect(mocks.registerWikiCli).toHaveBeenCalledWith(program, resolvedConfig, appConfig); + expect(mocks.registerWikiCli).toHaveBeenCalledWith( + program, + expect.objectContaining({ + config: resolvedConfig, + getAppConfig: expect.any(Function), + resolveConfig: expect.any(Function), + }), + ); }); }); diff --git a/extensions/memory-wiki/cli-metadata.ts b/extensions/memory-wiki/cli-metadata.ts index 518ad6d500a9..48809940b549 100644 --- a/extensions/memory-wiki/cli-metadata.ts +++ b/extensions/memory-wiki/cli-metadata.ts @@ -8,12 +8,20 @@ export default definePluginEntry({ register(api) { api.registerCli( async ({ program, config: appConfig }) => { - const [{ registerWikiCli }, { resolveMemoryWikiConfig }] = await Promise.all([ - import("./src/cli.js"), - import("./src/config.js"), - ]); + const [{ registerWikiCli }, { resolveMemoryWikiAgentConfig, resolveMemoryWikiConfig }] = + await Promise.all([import("./src/cli.js"), import("./src/config.js")]); const pluginConfig = appConfig.plugins?.entries?.["memory-wiki"]?.config; - registerWikiCli(program, resolveMemoryWikiConfig(pluginConfig), appConfig); + const config = resolveMemoryWikiConfig(pluginConfig); + registerWikiCli(program, { + config, + getAppConfig: () => appConfig, + resolveConfig: (agentId, currentAppConfig) => + resolveMemoryWikiAgentConfig({ + config, + appConfig: currentAppConfig ?? appConfig, + ...(agentId ? { agentId } : {}), + }), + }); }, { descriptors: [ diff --git a/extensions/memory-wiki/doctor-contract-api.test.ts b/extensions/memory-wiki/doctor-contract-api.test.ts index d16413b8219b..08b07daf7734 100644 --- a/extensions/memory-wiki/doctor-contract-api.test.ts +++ b/extensions/memory-wiki/doctor-contract-api.test.ts @@ -31,15 +31,19 @@ function resolveLegacyImportRunRecordPath(vaultRoot: string, runId: string): str return path.join(vaultRoot, ".openclaw-wiki", "import-runs", `${runId}.json`); } -function migrationParams(params: { stateDir: string; vaultRoot: string }) { +function migrationParams(params: { stateDir: string; vaultRoot: string; agentIds?: string[] }) { const env = { ...process.env, HOME: params.stateDir, OPENCLAW_STATE_DIR: params.stateDir }; return { config: { + ...(params.agentIds ? { agents: { list: params.agentIds.map((id) => ({ id })) } } : {}), plugins: { entries: { "memory-wiki": { config: { - vault: { path: params.vaultRoot }, + vault: { + path: params.vaultRoot, + ...(params.agentIds ? { scope: "agent" as const } : {}), + }, }, }, }, @@ -267,4 +271,48 @@ describe("memory-wiki doctor source sync migration", () => { }); await expect(fs.stat(legacyPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("migrates legacy state from every configured agent vault", async () => { + const stateDir = await makeTempDir(); + const vaultRoot = path.join(stateDir, "vaults"); + const agentIds = ["support", "marketing"]; + for (const agentId of agentIds) { + const legacyPath = resolveMemoryWikiSourceSyncStatePath(path.join(vaultRoot, agentId)); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await fs.writeFile( + legacyPath, + `${JSON.stringify({ + version: 1, + entries: { + [agentId]: { + group: "bridge", + pagePath: `sources/${agentId}.md`, + sourcePath: `/tmp/${agentId}.md`, + sourceUpdatedAtMs: 100, + sourceSize: 200, + renderFingerprint: agentId, + }, + }, + })}\n`, + ); + } + + const params = migrationParams({ stateDir, vaultRoot, agentIds }); + await expect(stateMigrations[0].detectLegacyState(params)).resolves.toEqual({ + preview: [ + expect.stringContaining(path.join(vaultRoot, "support")), + expect.stringContaining(path.join(vaultRoot, "marketing")), + ], + }); + await expect(stateMigrations[0].migrateLegacyState(params)).resolves.toMatchObject({ + warnings: [], + }); + + const store = createMemoryWikiSourceSyncStateStore(params.context.openPluginStateKeyedStore); + for (const agentId of agentIds) { + await expect( + readMemoryWikiSourceSyncState(path.join(vaultRoot, agentId), store), + ).resolves.toMatchObject({ entries: { [agentId]: { renderFingerprint: agentId } } }); + } + }); }); diff --git a/extensions/memory-wiki/doctor-contract-api.ts b/extensions/memory-wiki/doctor-contract-api.ts index 0252672cb72b..058eedeca20d 100644 --- a/extensions/memory-wiki/doctor-contract-api.ts +++ b/extensions/memory-wiki/doctor-contract-api.ts @@ -7,7 +7,12 @@ import { legacyStateFileExists, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor"; -import { resolveMemoryWikiConfig, type MemoryWikiPluginConfig } from "./src/config.js"; +import { + resolveMemoryWikiAgentConfig, + resolveMemoryWikiConfig, + resolveMemoryWikiConfiguredAgentIds, + type MemoryWikiPluginConfig, +} from "./src/config.js"; export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { @@ -50,7 +55,17 @@ function resolveConfiguredVaultRoots(params: { const resolved = resolveMemoryWikiConfig(readConfiguredPluginConfig(params.config), { homedir: homeDir, }); - return [resolved.vault.path]; + if (resolved.vault.scope === "global") { + return [resolved.vault.path]; + } + return resolveMemoryWikiConfiguredAgentIds(params.config).map( + (agentId) => + resolveMemoryWikiAgentConfig({ + config: resolved, + appConfig: params.config, + agentId, + }).vault.path, + ); } async function archiveLegacyImportRunRecords(params: { diff --git a/extensions/memory-wiki/index.test.ts b/extensions/memory-wiki/index.test.ts index b3a892a774cb..5cf274732b9d 100644 --- a/extensions/memory-wiki/index.test.ts +++ b/extensions/memory-wiki/index.test.ts @@ -1,9 +1,29 @@ // Memory Wiki tests cover index plugin behavior. -import { describe, expect, it } from "vitest"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "./api.js"; import plugin from "./index.js"; import { createMemoryWikiTestHarness } from "./src/test-helpers.js"; -const { createPluginApi } = createMemoryWikiTestHarness(); +const toolMocks = vi.hoisted(() => { + const createTool = (name: string) => + vi.fn((config: unknown, _appConfig?: unknown, memoryContext?: unknown) => ({ + name, + testConfig: config, + testMemoryContext: memoryContext, + })); + return { + createWikiApplyTool: createTool("wiki_apply"), + createWikiGetTool: createTool("wiki_get"), + createWikiLintTool: createTool("wiki_lint"), + createWikiSearchTool: createTool("wiki_search"), + createWikiStatusTool: createTool("wiki_status"), + }; +}); + +vi.mock("./src/tool.js", () => toolMocks); + +const { createPluginApi, createTempDir } = createMemoryWikiTestHarness(); describe("memory-wiki plugin", () => { it("registers prompt supplement, gateway methods, tools, and wiki cli surface", () => { @@ -49,6 +69,13 @@ describe("memory-wiki plugin", () => { "wiki_search", "wiki_get", ]); + expect(registerTool.mock.calls.map((call) => typeof call[0])).toEqual([ + "function", + "function", + "function", + "function", + "function", + ]); expect(registerCli).toHaveBeenCalledTimes(1); expect(registerCli.mock.calls[0]?.[1]).toStrictEqual({ descriptors: [ @@ -60,4 +87,49 @@ describe("memory-wiki plugin", () => { ], }); }); + + it("resolves every tool factory from the invocation agent", async () => { + const rootDir = await createTempDir("memory-wiki-index-agents-"); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + } as OpenClawConfig; + const { api, registerTool } = createPluginApi(); + api.config = appConfig; + api.pluginConfig = { + vault: { scope: "agent", path: rootDir }, + }; + Object.assign(api.runtime, { + config: { + current: () => appConfig, + }, + }); + + plugin.register(api); + + for (const [factory, registration] of registerTool.mock.calls) { + expect(factory).toEqual(expect.any(Function)); + expect(factory({})).toBeNull(); + const supportTool = factory({ agentId: "support" }); + const marketingTool = factory({ agentId: "marketing" }); + expect(supportTool).toMatchObject({ + name: registration.name, + testConfig: { + agentId: "support", + vault: { scope: "agent", path: path.join(rootDir, "support") }, + }, + }); + expect(marketingTool).toMatchObject({ + name: registration.name, + testConfig: { + agentId: "marketing", + vault: { scope: "agent", path: path.join(rootDir, "marketing") }, + }, + }); + if (registration.name === "wiki_status") { + expect(supportTool).toMatchObject({ testMemoryContext: { agentId: "support" } }); + expect(marketingTool).toMatchObject({ testMemoryContext: { agentId: "marketing" } }); + } + expect(() => factory({ agentId: "finance" })).toThrow("Unknown memory-wiki agentId: finance"); + } + }); }); diff --git a/extensions/memory-wiki/index.ts b/extensions/memory-wiki/index.ts index ea6bd4667cc4..df021546ff25 100644 --- a/extensions/memory-wiki/index.ts +++ b/extensions/memory-wiki/index.ts @@ -1,7 +1,13 @@ // Memory Wiki plugin entrypoint registers its OpenClaw integration. -import { definePluginEntry } from "./api.js"; +import { definePluginEntry, type OpenClawConfig } from "./api.js"; import { registerWikiCli } from "./src/cli.js"; -import { memoryWikiConfigSchema, resolveMemoryWikiConfig } from "./src/config.js"; +import { + memoryWikiConfigSchema, + resolveMemoryWikiAgentConfig, + resolveMemoryWikiConfig, + resolveMemoryWikiConfiguredAgentIds, + type MemoryWikiConfigResolver, +} from "./src/config.js"; import { createWikiCorpusSupplement } from "./src/corpus-supplement.js"; import { registerMemoryWikiGatewayMethods } from "./src/gateway.js"; import { @@ -28,6 +34,22 @@ export default definePluginEntry({ configSchema: memoryWikiConfigSchema, register(api) { const config = resolveMemoryWikiConfig(api.pluginConfig); + const getAppConfig = () => + (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig | undefined; + const resolveConfig: MemoryWikiConfigResolver = (agentId, appConfig = getAppConfig()) => + resolveMemoryWikiAgentConfig({ config, appConfig, agentId }); + const resolveToolContext = (agentId?: string) => { + const appConfig = getAppConfig(); + if ( + config.vault.scope === "agent" && + !agentId && + resolveMemoryWikiConfiguredAgentIds(appConfig).length > 1 + ) { + // Context-free tool discovery cannot safely choose one agent's vault. + return null; + } + return { appConfig, config: resolveConfig(agentId, appConfig) }; + }; configureMemoryWikiSourceSyncStateStore( createMemoryWikiSourceSyncStateStore(api.runtime.state.openKeyedStore), ); @@ -35,35 +57,71 @@ export default definePluginEntry({ createMemoryWikiImportRunStateStore(api.runtime.state.openKeyedStore), ); - api.registerMemoryPromptSupplement(createWikiPromptSectionBuilder(config)); - api.registerMemoryCorpusSupplement( - createWikiCorpusSupplement({ config, appConfig: api.config }), - ); - registerMemoryWikiGatewayMethods({ api, config, appConfig: api.config }); - api.registerTool(createWikiStatusTool(config, api.config), { name: "wiki_status" }); - api.registerTool(createWikiLintTool(config, api.config), { name: "wiki_lint" }); - api.registerTool(createWikiApplyTool(config, api.config), { name: "wiki_apply" }); + api.registerMemoryPromptSupplement(createWikiPromptSectionBuilder({ config, resolveConfig })); + api.registerMemoryCorpusSupplement(createWikiCorpusSupplement({ resolveConfig, getAppConfig })); + registerMemoryWikiGatewayMethods({ + api, + config, + appConfig: api.config, + getAppConfig, + resolveConfig, + }); api.registerTool( - (ctx) => - createWikiSearchTool(config, api.config, { - agentId: ctx.agentId, + (ctx) => { + const resolved = resolveToolContext(ctx.agentId); + return resolved + ? createWikiStatusTool(resolved.config, resolved.appConfig, { + agentId: resolved.config.agentId ?? ctx.agentId, + }) + : null; + }, + { name: "wiki_status" }, + ); + api.registerTool( + (ctx) => { + const resolved = resolveToolContext(ctx.agentId); + return resolved ? createWikiLintTool(resolved.config, resolved.appConfig) : null; + }, + { name: "wiki_lint" }, + ); + api.registerTool( + (ctx) => { + const resolved = resolveToolContext(ctx.agentId); + return resolved ? createWikiApplyTool(resolved.config, resolved.appConfig) : null; + }, + { name: "wiki_apply" }, + ); + api.registerTool( + (ctx) => { + const resolved = resolveToolContext(ctx.agentId); + if (!resolved) { + return null; + } + return createWikiSearchTool(resolved.config, resolved.appConfig, { + agentId: resolved.config.agentId ?? ctx.agentId, agentSessionKey: ctx.sessionKey, sandboxed: ctx.sandboxed, - }), + }); + }, { name: "wiki_search" }, ); api.registerTool( - (ctx) => - createWikiGetTool(config, api.config, { - agentId: ctx.agentId, + (ctx) => { + const resolved = resolveToolContext(ctx.agentId); + if (!resolved) { + return null; + } + return createWikiGetTool(resolved.config, resolved.appConfig, { + agentId: resolved.config.agentId ?? ctx.agentId, agentSessionKey: ctx.sessionKey, sandboxed: ctx.sandboxed, - }), + }); + }, { name: "wiki_get" }, ); api.registerCli( ({ program }) => { - registerWikiCli(program, config, api.config); + registerWikiCli(program, { config, resolveConfig, getAppConfig }); }, { descriptors: [ diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 6f394d58b314..ac2a678a4aa2 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -16,7 +16,11 @@ }, "vault.path": { "label": "Vault Path", - "help": "Filesystem path for the wiki vault root." + "help": "Exact vault path in global scope, or the parent directory for per-agent vaults." + }, + "vault.scope": { + "label": "Vault Scope", + "help": "Use one global vault or a separate child vault for each agent." }, "vault.renderMode": { "label": "Render Mode", @@ -46,6 +50,49 @@ "configSchema": { "type": "object", "additionalProperties": false, + "allOf": [ + { + "not": { + "required": ["vaultMode", "vault"], + "properties": { + "vaultMode": { + "const": "unsafe-local" + }, + "vault": { + "required": ["scope"], + "properties": { + "scope": { + "const": "agent" + } + } + } + } + } + }, + { + "not": { + "required": ["vault", "obsidian"], + "properties": { + "vault": { + "required": ["scope"], + "properties": { + "scope": { + "const": "agent" + } + } + }, + "obsidian": { + "required": ["useOfficialCli"], + "properties": { + "useOfficialCli": { + "const": true + } + } + } + } + } + } + ], "properties": { "vaultMode": { "type": "string", @@ -55,6 +102,10 @@ "type": "object", "additionalProperties": false, "properties": { + "scope": { + "type": "string", + "enum": ["global", "agent"] + }, "path": { "type": "string" }, diff --git a/extensions/memory-wiki/src/agent-vault-isolation.test.ts b/extensions/memory-wiki/src/agent-vault-isolation.test.ts new file mode 100644 index 000000000000..c5c70bec852f --- /dev/null +++ b/extensions/memory-wiki/src/agent-vault-isolation.test.ts @@ -0,0 +1,265 @@ +// Memory Wiki tests cover agent-scoped vault isolation through the public tools. +import fs from "node:fs/promises"; +import path from "node:path"; +import { + clearMemoryPluginState, + registerMemoryCorpusSupplement, +} from "openclaw/plugin-sdk/memory-host-core"; +import type { AnyAgentTool, OpenClawPluginToolFactory } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; +import { describe, expect, it } from "vitest"; +import memoryCorePlugin from "../../memory-core/index.js"; +import type { OpenClawConfig } from "../api.js"; +import { + resolveMemoryWikiAgentConfig, + resolveMemoryWikiConfig, + type ResolvedMemoryWikiConfig, +} from "./config.js"; +import { createWikiCorpusSupplement } from "./corpus-supplement.js"; +import { createMemoryWikiTestHarness } from "./test-helpers.js"; +import { createWikiApplyTool, createWikiGetTool, createWikiSearchTool } from "./tool.js"; + +const { createTempDir } = createMemoryWikiTestHarness(); + +function asRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected tool details object"); + } + return value as Record; +} + +function textContent(result: { content: Array<{ type: string; text?: string }> }): string { + return result.content.find((part) => part.type === "text")?.text ?? ""; +} + +function registerMemoryCoreToolFactories( + appConfig: OpenClawConfig, +): Map { + const factories = new Map(); + memoryCorePlugin.register( + createTestPluginApi({ + id: "memory-core", + config: appConfig, + registerTool(tool, options) { + if (typeof tool !== "function") { + return; + } + for (const name of options?.names ?? []) { + factories.set(name, tool); + } + }, + }), + ); + return factories; +} + +function createMemoryCoreTool(params: { + factories: Map; + name: "memory_search" | "memory_get"; + appConfig: OpenClawConfig; + agentId: string; +}): AnyAgentTool { + const factory = params.factories.get(params.name); + if (!factory) { + throw new Error(`Expected memory-core to register ${params.name}`); + } + const tool = factory({ + config: params.appConfig, + runtimeConfig: params.appConfig, + getRuntimeConfig: () => params.appConfig, + agentId: params.agentId, + sessionKey: `agent:${params.agentId}:main`, + }); + if (!tool || Array.isArray(tool)) { + throw new Error(`Expected one ${params.name} tool`); + } + return tool; +} + +describe("agent-scoped memory-wiki tools", () => { + it("keeps apply, search, and get behavior isolated by configured agent", async () => { + const vaultParent = await createTempDir("memory-wiki-agent-vaults-"); + const appConfig = { + agents: { + list: [{ id: "support", default: true }, { id: "marketing" }], + }, + } as OpenClawConfig; + const baseConfig = resolveMemoryWikiConfig({ + vault: { scope: "agent", path: vaultParent }, + search: { backend: "local", corpus: "wiki" }, + }); + + const agents: Array<{ + id: string; + title: string; + sentinel: string; + config: ResolvedMemoryWikiConfig; + pagePath?: string; + }> = [ + { + id: "support", + title: "Support Private Synthesis", + sentinel: "SUPPORT_ONLY_7f3c21", + config: resolveMemoryWikiAgentConfig({ + config: baseConfig, + appConfig, + agentId: "support", + }), + }, + { + id: "marketing", + title: "Marketing Private Synthesis", + sentinel: "MARKETING_ONLY_8e4d62", + config: resolveMemoryWikiAgentConfig({ + config: baseConfig, + appConfig, + agentId: "marketing", + }), + }, + ]; + + for (const agent of agents) { + const result = await createWikiApplyTool(agent.config, appConfig).execute( + `apply-${agent.id}`, + { + op: "create_synthesis", + title: agent.title, + body: `Private synthesis marker: ${agent.sentinel}`, + sourceIds: [`source.${agent.id}`], + }, + ); + const pagePath = asRecord(result.details).pagePath; + if (typeof pagePath !== "string") { + throw new Error("Expected wiki_apply to return pagePath"); + } + agent.pagePath = pagePath; + } + + expect(agents[0]?.config.vault.path).toBe(path.join(vaultParent, "support")); + expect(agents[1]?.config.vault.path).toBe(path.join(vaultParent, "marketing")); + expect(agents[0]?.config.vault.path).not.toBe(agents[1]?.config.vault.path); + + for (const agent of agents) { + const foreignAgent = agents.find((candidate) => candidate.id !== agent.id); + if (!agent.pagePath || !foreignAgent?.pagePath) { + throw new Error("Expected both agent synthesis paths"); + } + + expect((await fs.stat(agent.config.vault.path)).isDirectory()).toBe(true); + await expect( + fs.readFile(path.join(agent.config.vault.path, agent.pagePath), "utf8"), + ).resolves.toContain(agent.sentinel); + await expect( + fs.access(path.join(agent.config.vault.path, foreignAgent.pagePath)), + ).rejects.toThrow(); + + const searchTool = createWikiSearchTool(agent.config, appConfig, { + agentId: agent.id, + }); + const ownSearch = await searchTool.execute(`search-own-${agent.id}`, { + query: agent.sentinel, + }); + expect(textContent(ownSearch)).toContain(agent.sentinel); + expect(asRecord(ownSearch.details).results).toEqual([ + expect.objectContaining({ path: agent.pagePath }), + ]); + + const foreignSearch = await searchTool.execute(`search-foreign-${agent.id}`, { + query: foreignAgent.sentinel, + }); + expect(textContent(foreignSearch)).toBe("No wiki or memory results."); + expect(asRecord(foreignSearch.details).results).toEqual([]); + + const getTool = createWikiGetTool(agent.config, appConfig, { agentId: agent.id }); + const ownGet = await getTool.execute(`get-own-${agent.id}`, { lookup: agent.pagePath }); + expect(textContent(ownGet)).toContain(agent.sentinel); + expect(asRecord(ownGet.details).found).toBe(true); + + const foreignGet = await getTool.execute(`get-foreign-${agent.id}`, { + lookup: foreignAgent.pagePath, + }); + expect(textContent(foreignGet)).toBe(`Wiki page not found: ${foreignAgent.pagePath}`); + expect(asRecord(foreignGet.details).found).toBe(false); + } + + clearMemoryPluginState(); + try { + registerMemoryCorpusSupplement( + "memory-wiki", + createWikiCorpusSupplement({ + resolveConfig: (agentId, currentAppConfig) => + resolveMemoryWikiAgentConfig({ + config: baseConfig, + appConfig: currentAppConfig, + agentId, + }), + getAppConfig: () => appConfig, + }), + ); + const memoryCoreFactories = registerMemoryCoreToolFactories(appConfig); + + for (const agent of agents) { + const foreignAgent = agents.find((candidate) => candidate.id !== agent.id); + if (!agent.pagePath || !foreignAgent?.pagePath) { + throw new Error("Expected both agent synthesis paths"); + } + + const memorySearch = createMemoryCoreTool({ + factories: memoryCoreFactories, + name: "memory_search", + appConfig, + agentId: agent.id, + }); + const ownMemorySearch = await memorySearch.execute(`memory-search-own-${agent.id}`, { + query: agent.sentinel, + corpus: "wiki", + }); + expect(asRecord(ownMemorySearch.details).results).toEqual([ + expect.objectContaining({ + corpus: "wiki", + path: agent.pagePath, + snippet: expect.stringContaining(agent.sentinel), + }), + ]); + + const foreignMemorySearch = await memorySearch.execute( + `memory-search-foreign-${agent.id}`, + { + query: foreignAgent.sentinel, + corpus: "wiki", + }, + ); + expect(asRecord(foreignMemorySearch.details).results).toEqual([]); + + const memoryGet = createMemoryCoreTool({ + factories: memoryCoreFactories, + name: "memory_get", + appConfig, + agentId: agent.id, + }); + const ownMemoryGet = await memoryGet.execute(`memory-get-own-${agent.id}`, { + path: agent.pagePath, + corpus: "wiki", + }); + expect(asRecord(ownMemoryGet.details)).toMatchObject({ + corpus: "wiki", + path: agent.pagePath, + text: expect.stringContaining(agent.sentinel), + }); + + const foreignMemoryGet = await memoryGet.execute(`memory-get-foreign-${agent.id}`, { + path: foreignAgent.pagePath, + corpus: "wiki", + }); + expect(asRecord(foreignMemoryGet.details)).toMatchObject({ + path: foreignAgent.pagePath, + text: "", + disabled: true, + error: "wiki corpus result not found", + }); + } + } finally { + clearMemoryPluginState(); + } + }); +}); diff --git a/extensions/memory-wiki/src/bridge.test.ts b/extensions/memory-wiki/src/bridge.test.ts index 7e055fb9d6ac..e893ee56baa7 100644 --- a/extensions/memory-wiki/src/bridge.test.ts +++ b/extensions/memory-wiki/src/bridge.test.ts @@ -262,6 +262,120 @@ describe("syncMemoryWikiBridgeSources", () => { expect(page).toContain("- Agents: unknown"); }); + it("isolates agent-scoped bridge artifacts while preserving shared ownership", async () => { + const supportWorkspace = await createBridgeWorkspace("support-workspace"); + const marketingWorkspace = await createBridgeWorkspace("marketing-workspace"); + const sharedWorkspace = await createBridgeWorkspace("shared-workspace"); + const unknownWorkspace = await createBridgeWorkspace("unknown-workspace"); + const supportMemory = path.join(supportWorkspace, "MEMORY.md"); + const marketingMemory = path.join(marketingWorkspace, "MEMORY.md"); + const sharedMemory = path.join(sharedWorkspace, "MEMORY.md"); + const unknownMemory = path.join(unknownWorkspace, "MEMORY.md"); + await fs.writeFile(supportMemory, "# Support Sentinel\n", "utf8"); + await fs.writeFile(marketingMemory, "# Marketing Sentinel\n", "utf8"); + await fs.writeFile(sharedMemory, "# Shared Sentinel\n", "utf8"); + await fs.writeFile(unknownMemory, "# Unknown Sentinel\n", "utf8"); + + registerBridgeArtifacts([ + { + kind: "memory-root", + workspaceDir: supportWorkspace, + relativePath: "MEMORY.md", + absolutePath: supportMemory, + agentIds: [" SUPPORT "], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: marketingWorkspace, + relativePath: "MEMORY.md", + absolutePath: marketingMemory, + agentIds: ["marketing"], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: sharedWorkspace, + relativePath: "MEMORY.md", + absolutePath: sharedMemory, + agentIds: ["support", "MARKETING"], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: unknownWorkspace, + relativePath: "MEMORY.md", + absolutePath: unknownMemory, + contentType: "markdown", + } as Omit as MemoryPluginPublicArtifact, + ]); + + const { rootDir: supportVault, config: unresolvedSupportConfig } = await createVault({ + rootDir: nextCaseRoot("support-vault"), + config: { + vaultMode: "bridge", + vault: { scope: "agent" }, + bridge: { enabled: true, indexMemoryRoot: true }, + }, + }); + const { rootDir: marketingVault, config: unresolvedMarketingConfig } = await createVault({ + rootDir: nextCaseRoot("marketing-vault"), + config: { + vaultMode: "bridge", + vault: { scope: "agent" }, + bridge: { enabled: true, indexMemoryRoot: true }, + }, + }); + const supportConfig = { ...unresolvedSupportConfig, agentId: "support" }; + const marketingConfig = { ...unresolvedMarketingConfig, agentId: "marketing" }; + const appConfig: OpenClawConfig = { + agents: { + list: [ + { id: "support", default: true, workspace: supportWorkspace }, + { id: "marketing", workspace: marketingWorkspace }, + ], + }, + }; + + const supportResult = await syncMemoryWikiBridgeSources({ config: supportConfig, appConfig }); + const marketingResult = await syncMemoryWikiBridgeSources({ + config: marketingConfig, + appConfig, + }); + + expect(supportResult).toMatchObject({ artifactCount: 2, importedCount: 2, workspaces: 2 }); + expect(marketingResult).toMatchObject({ artifactCount: 2, importedCount: 2, workspaces: 2 }); + const supportPages = await Promise.all( + supportResult.pagePaths.map((pagePath) => + fs.readFile(path.join(supportVault, pagePath), "utf8"), + ), + ); + const marketingPages = await Promise.all( + marketingResult.pagePaths.map((pagePath) => + fs.readFile(path.join(marketingVault, pagePath), "utf8"), + ), + ); + expect(supportPages.join("\n")).toContain("Support Sentinel"); + expect(supportPages.join("\n")).toContain("Shared Sentinel"); + expect(supportPages.join("\n")).not.toContain("Marketing Sentinel"); + expect(supportPages.join("\n")).not.toContain("Unknown Sentinel"); + expect(marketingPages.join("\n")).toContain("Marketing Sentinel"); + expect(marketingPages.join("\n")).toContain("Shared Sentinel"); + expect(marketingPages.join("\n")).not.toContain("Support Sentinel"); + expect(marketingPages.join("\n")).not.toContain("Unknown Sentinel"); + }); + + it("rejects an unresolved agent-scoped bridge config", async () => { + const { config } = await createVault({ + rootDir: nextCaseRoot("unresolved-agent-vault"), + config: { vault: { scope: "agent" } }, + }); + + await expect(syncMemoryWikiBridgeSources({ config })).rejects.toThrow( + "Memory Wiki agent-scoped vault requires a resolved agent id", + ); + }); + it("returns a no-op result outside bridge mode", async () => { const { config } = await createVault({ rootDir: nextCaseRoot("isolated") }); diff --git a/extensions/memory-wiki/src/bridge.ts b/extensions/memory-wiki/src/bridge.ts index 0c8e0a45a78e..70dbee07a2af 100644 --- a/extensions/memory-wiki/src/bridge.ts +++ b/extensions/memory-wiki/src/bridge.ts @@ -7,6 +7,7 @@ import { listActiveMemoryPublicArtifacts, type MemoryPluginPublicArtifact, } from "openclaw/plugin-sdk/memory-host-core"; +import { normalizeAgentId } from "openclaw/plugin-sdk/routing"; import type { OpenClawConfig } from "../api.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; import { appendMemoryWikiLog } from "./log.js"; @@ -44,6 +45,45 @@ export type BridgeMemoryWikiResult = { pagePaths: string[]; }; +export function resolveMemoryWikiVaultAgentId( + config: Pick, +): string | null { + if (config.vault.scope === "global") { + return null; + } + const agentId = config.agentId?.trim(); + if (!agentId) { + throw new Error("Memory Wiki agent-scoped vault requires a resolved agent id"); + } + return normalizeAgentId(agentId); +} + +export function filterMemoryWikiBridgeArtifacts(params: { + config: Pick; + artifacts: MemoryPluginPublicArtifact[]; + callerAgentId?: string; +}): MemoryPluginPublicArtifact[] { + const vaultAgentId = resolveMemoryWikiVaultAgentId(params.config); + const callerAgentId = params.callerAgentId?.trim(); + // Agent-scoped vault ownership is authoritative. Global vaults remain shared, + // but agent tools still scope diagnostic metadata to their calling agent. + const agentId = vaultAgentId ?? (callerAgentId ? normalizeAgentId(callerAgentId) : null); + if (!agentId) { + return params.artifacts; + } + // Ownership metadata is mandatory only in agent scope. Global scope keeps + // accepting legacy providers that omit agentIds. + return params.artifacts.filter((artifact) => { + const artifactAgentIds = Array.isArray(artifact.agentIds) ? artifact.agentIds : []; + return artifactAgentIds.some( + (artifactAgentId) => + typeof artifactAgentId === "string" && + artifactAgentId.trim().length > 0 && + normalizeAgentId(artifactAgentId) === agentId, + ); + }); +} + function shouldImportArtifact( artifact: MemoryPluginPublicArtifact, bridgeConfig: ResolvedMemoryWikiConfig["bridge"], @@ -219,6 +259,7 @@ export async function syncMemoryWikiBridgeSources(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; }): Promise { + resolveMemoryWikiVaultAgentId(params.config); await initializeMemoryWikiVault(params.config); if ( params.config.vaultMode !== "bridge" || @@ -237,7 +278,12 @@ export async function syncMemoryWikiBridgeSources(params: { }; } - const publicArtifacts = await listActiveMemoryPublicArtifacts({ cfg: params.appConfig }); + // Filter before building active keys so each vault's pruning state tracks + // only artifacts that are visible to its resolved agent. + const publicArtifacts = filterMemoryWikiBridgeArtifacts({ + config: params.config, + artifacts: await listActiveMemoryPublicArtifacts({ cfg: params.appConfig }), + }); const results: Array<{ pagePath: string; changed: boolean; created: boolean }> = []; const activeKeys = new Set(); const artifacts = await collectBridgeArtifacts( diff --git a/extensions/memory-wiki/src/cli.test.ts b/extensions/memory-wiki/src/cli.test.ts index f167982a3c11..8597b044c2b0 100644 --- a/extensions/memory-wiki/src/cli.test.ts +++ b/extensions/memory-wiki/src/cli.test.ts @@ -112,10 +112,13 @@ describe("memory-wiki cli", () => { } function createGatewayStatus(config: { - vault: { path: string }; + agentId?: string; + vault: { path: string; scope?: MemoryWikiStatus["vaultScope"] }; bridge: MemoryWikiStatus["bridge"]; }): MemoryWikiStatus { return { + vaultScope: config.vault.scope ?? "global", + agentId: config.agentId ?? null, vaultMode: "bridge", renderMode: "native", vaultPath: config.vault.path, @@ -154,7 +157,7 @@ describe("memory-wiki cli", () => { const { rootDir, config } = await createCliVault(); const program = new Command(); program.name("test"); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await program.parseAsync( [ @@ -180,6 +183,92 @@ describe("memory-wiki cli", () => { ); }); + it("resolves --agent for local commands and requires it with multiple agent vaults", async () => { + const { rootDir, config } = await createCliVault({ + config: { vault: { scope: "agent" } }, + }); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + const program = new Command(); + program.name("test"); + program.exitOverride(); + registerWikiCli(program, { config, getAppConfig: () => appConfig }); + + await program.parseAsync(["wiki", "--agent", "marketing", "init", "--json"], { + from: "user", + }); + + await expect(fs.stat(path.join(rootDir, "marketing", "index.md"))).resolves.toBeDefined(); + + const missingAgentProgram = new Command(); + missingAgentProgram.name("test"); + missingAgentProgram.exitOverride(); + registerWikiCli(missingAgentProgram, { config, getAppConfig: () => appConfig }); + await expect( + missingAgentProgram.parseAsync(["wiki", "status", "--json"], { from: "user" }), + ).rejects.toThrow("agentId is required for memory-wiki when vault.scope=agent."); + }); + + it("forwards --agent through every bridge Gateway call", async () => { + const { config } = await createCliVault({ + config: { + vaultMode: "bridge", + vault: { scope: "agent" }, + bridge: { enabled: true, readMemoryArtifacts: true }, + }, + }); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + const status = createGatewayStatus(config); + const report: MemoryWikiDoctorReport = { + healthy: true, + warningCount: 0, + status, + fixes: [], + }; + callGatewayFromCliMock + .mockResolvedValueOnce(status) + .mockResolvedValueOnce(report) + .mockResolvedValueOnce({ + importedCount: 0, + updatedCount: 0, + skippedCount: 0, + removedCount: 0, + artifactCount: 0, + workspaces: 0, + pagePaths: [], + indexesRefreshed: false, + indexUpdatedFiles: [], + indexRefreshReason: "no-import-changes", + }); + const register = () => { + const program = new Command(); + program.name("test"); + registerWikiCli(program, { config, getAppConfig: () => appConfig }); + return program; + }; + + await register().parseAsync(["wiki", "--agent", "marketing", "status", "--json"], { + from: "user", + }); + await register().parseAsync(["wiki", "--agent", "marketing", "doctor", "--json"], { + from: "user", + }); + await register().parseAsync(["wiki", "--agent", "marketing", "bridge", "import", "--json"], { + from: "user", + }); + + expect(callGatewayFromCliMock.mock.calls.map(([method, , params]) => [method, params])).toEqual( + [ + ["wiki.status", { agentId: "marketing" }], + ["wiki.doctor", { agentId: "marketing" }], + ["wiki.bridge.import", { agentId: "marketing" }], + ], + ); + }); + it("registers OKF import and searches imported concepts", async () => { const { rootDir, config } = await createCliVault(); const bundlePath = path.join(rootDir, "okf-bundle"); @@ -211,7 +300,7 @@ Orders join to [customers](/tables/customers.md). const program = new Command(); program.name("test"); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await program.parseAsync(["wiki", "okf", "import", bundlePath, "--json"], { from: "user" }); @@ -248,7 +337,7 @@ Orders join to [customers](/tables/customers.md). writeErr: () => {}, writeOut: () => {}, }); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await expect( program.parseAsync( @@ -290,7 +379,7 @@ Orders join to [customers](/tables/customers.md). writeErr: () => {}, writeOut: () => {}, }); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await expect( program.parseAsync(["wiki", "search", "alpha", "--max-results", "0x10"], { @@ -312,7 +401,7 @@ Orders join to [customers](/tables/customers.md). await fs.writeFile(targetPath, "# CLI Lines\n\nfirst\nsecond\n", "utf8"); const program = new Command(); program.name("test"); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await program.parseAsync( ["wiki", "get", "syntheses/cli-lines.md", "--from", "+01", "--lines", "02"], @@ -349,7 +438,7 @@ cli note const program = new Command(); program.name("test"); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await program.parseAsync( [ @@ -389,7 +478,7 @@ cli note }); const program = new Command(); program.name("test"); - registerWikiCli(program, config); + registerWikiCli(program, { config }); await fs.rm(rootDir, { recursive: true, force: true }); await program.parseAsync(["wiki", "doctor", "--json"], { from: "user" }); diff --git a/extensions/memory-wiki/src/cli.ts b/extensions/memory-wiki/src/cli.ts index d43d949b34fd..6be8a65f8d23 100644 --- a/extensions/memory-wiki/src/cli.ts +++ b/extensions/memory-wiki/src/cli.ts @@ -18,10 +18,10 @@ import { } from "./chatgpt-import.js"; import { compileMemoryWikiVault } from "./compile.js"; import { - resolveMemoryWikiConfig, + resolveMemoryWikiAgentConfig, WIKI_SEARCH_BACKENDS, WIKI_SEARCH_CORPORA, - type MemoryWikiPluginConfig, + type MemoryWikiConfigResolver, type ResolvedMemoryWikiConfig, } from "./config.js"; import { ingestMemoryWikiSource } from "./ingest.js"; @@ -164,18 +164,15 @@ type WikiObsidianDailyCommandOptions = { json?: boolean; }; -function isResolvedMemoryWikiConfig( - config: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig | undefined, -): config is ResolvedMemoryWikiConfig { - return Boolean( - config && - "vaultMode" in config && - "vault" in config && - "bridge" in config && - "obsidian" in config && - "unsafeLocal" in config, - ); -} +type WikiCommandOptions = { + agent?: string; +}; + +export type MemoryWikiCliRegistration = { + config: ResolvedMemoryWikiConfig; + resolveConfig?: MemoryWikiConfigResolver; + getAppConfig?: () => OpenClawConfig | undefined; +}; function sanitizeGatewayStringForTerminal(value: string): string { const truncated = @@ -253,6 +250,9 @@ function isMemoryWikiStatus(value: unknown): value is MemoryWikiStatus { const pageCounts = value.pageCounts; const sourceCounts = value.sourceCounts; return ( + isBoundedGatewayString(value.vaultScope, GATEWAY_RESPONSE_MAX_CODE_CHARS) && + (isBoundedGatewayString(value.agentId, GATEWAY_RESPONSE_MAX_CODE_CHARS) || + value.agentId === null) && isBoundedGatewayString(value.vaultMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(value.renderMode, GATEWAY_RESPONSE_MAX_CODE_CHARS) && isBoundedGatewayString(value.vaultPath) && @@ -328,15 +328,25 @@ function validateWikiGatewayResult( throw new Error(`Invalid Gateway response for ${method}.`); } -async function callWikiGateway(method: "wiki.status"): Promise; -async function callWikiGateway(method: "wiki.doctor"): Promise; +async function callWikiGateway(method: "wiki.status", agentId?: string): Promise; +async function callWikiGateway( + method: "wiki.doctor", + agentId?: string, +): Promise; async function callWikiGateway( method: "wiki.bridge.import", + agentId?: string, ): Promise; -async function callWikiGateway(method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import") { - const result = await callGatewayFromCli(method, { timeout: WIKI_GATEWAY_TIMEOUT_MS }, undefined, { - progress: false, - }); +async function callWikiGateway( + method: "wiki.status" | "wiki.doctor" | "wiki.bridge.import", + agentId?: string, +) { + const result = await callGatewayFromCli( + method, + { timeout: WIKI_GATEWAY_TIMEOUT_MS }, + agentId ? { agentId } : undefined, + { progress: false }, + ); return validateWikiGatewayResult(method, result); } @@ -476,12 +486,13 @@ function addWikiApplyMutationOptions(command: T): T { export async function runWikiStatus(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + agentId?: string; json?: boolean; stdout?: Pick; }) { const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config); const status = routeThroughGateway - ? await callWikiGateway("wiki.status") + ? await callWikiGateway("wiki.status", params.agentId) : await (async () => { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); return await resolveMemoryWikiStatus(params.config, { @@ -500,12 +511,13 @@ export async function runWikiStatus(params: { export async function runWikiDoctor(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + agentId?: string; json?: boolean; stdout?: Pick; }) { const routeThroughGateway = shouldRouteBridgeRuntimeThroughGateway(params.config); const report = routeThroughGateway - ? await callWikiGateway("wiki.doctor") + ? await callWikiGateway("wiki.doctor", params.agentId) : await (async () => { await syncMemoryWikiImportedSources({ config: params.config, appConfig: params.appConfig }); return buildMemoryWikiDoctorReport( @@ -616,6 +628,7 @@ export async function runWikiOkfImport(params: { async function runWikiSearch(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + agentId?: string; query: string; maxResults?: number; searchBackend?: ResolvedMemoryWikiConfig["search"]["backend"]; @@ -631,6 +644,7 @@ async function runWikiSearch(params: { const results = await searchMemoryWiki({ config: params.config, appConfig: params.appConfig, + ...(params.agentId ? { agentId: params.agentId } : {}), query: params.query, maxResults: params.maxResults, searchBackend: params.searchBackend, @@ -654,6 +668,7 @@ async function runWikiSearch(params: { async function runWikiGet(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + agentId?: string; lookup: string; fromLine?: number; lineCount?: number; @@ -666,6 +681,7 @@ async function runWikiGet(params: { const result = await getMemoryWikiPage({ config: params.config, appConfig: params.appConfig, + ...(params.agentId ? { agentId: params.agentId } : {}), lookup: params.lookup, fromLine: params.fromLine, lineCount: params.lineCount, @@ -763,13 +779,14 @@ async function runWikiApplyMetadata(params: { export async function runWikiBridgeImport(params: { config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + agentId?: string; json?: boolean; stdout?: Pick; }) { const render = (value: MemoryWikiImportedSourceSyncResult) => `Bridge import synced ${value.artifactCount} artifacts across ${value.workspaces} workspaces (${value.importedCount} new, ${value.updatedCount} updated, ${value.skippedCount} unchanged, ${value.removedCount} removed). Indexes ${value.indexesRefreshed ? `refreshed (${value.indexUpdatedFiles.length} files)` : `not refreshed (${value.indexRefreshReason})`}.`; if (shouldRouteBridgeRuntimeThroughGateway(params.config)) { - const result = await callWikiGateway("wiki.bridge.import"); + const result = await callWikiGateway("wiki.bridge.import", params.agentId); writeOutput(formatGatewayJsonOrText(result, params.json, render), params.stdout); return result; } @@ -791,6 +808,9 @@ async function runWikiUnsafeLocalImport(params: { json?: boolean; stdout?: Pick; }) { + if (params.config.vault.scope === "agent") { + throw new Error("Unsafe-local import does not support memory-wiki vault.scope=agent."); + } return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, @@ -820,12 +840,19 @@ async function runWikiObsidianStatus(params: { }); } +function assertOfficialObsidianCliSupported(config: ResolvedMemoryWikiConfig) { + if (config.vault.scope === "agent") { + throw new Error("Official Obsidian CLI actions do not support memory-wiki vault.scope=agent."); + } +} + async function runWikiObsidianSearch(params: { config: ResolvedMemoryWikiConfig; query: string; json?: boolean; stdout?: Pick; }) { + assertOfficialObsidianCliSupported(params.config); return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, @@ -840,6 +867,7 @@ async function runWikiObsidianOpenCli(params: { json?: boolean; stdout?: Pick; }) { + assertOfficialObsidianCliSupported(params.config); return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, @@ -854,6 +882,7 @@ async function runWikiObsidianCommandCli(params: { json?: boolean; stdout?: Pick; }) { + assertOfficialObsidianCliSupported(params.config); return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, @@ -867,6 +896,7 @@ async function runWikiObsidianDailyCli(params: { json?: boolean; stdout?: Pick; }) { + assertOfficialObsidianCliSupported(params.config); return runWikiCommandWithSummary({ json: params.json, stdout: params.stdout, @@ -928,22 +958,47 @@ export async function runWikiChatGptRollback(params: { }); } -export function registerWikiCli( - program: Command, - pluginConfig?: MemoryWikiPluginConfig | ResolvedMemoryWikiConfig, - appConfig?: OpenClawConfig, -) { - const config = isResolvedMemoryWikiConfig(pluginConfig) - ? pluginConfig - : resolveMemoryWikiConfig(pluginConfig); - const wiki = program.command("wiki").description("Inspect and initialize the memory wiki vault"); +export function registerWikiCli(program: Command, registration: MemoryWikiCliRegistration) { + const resolveConfig: MemoryWikiConfigResolver = + registration.resolveConfig ?? + ((agentId, currentAppConfig) => + resolveMemoryWikiAgentConfig({ + config: registration.config, + appConfig: currentAppConfig, + ...(agentId ? { agentId } : {}), + })); + let commandContext: + | { agentId?: string; appConfig?: OpenClawConfig; config: ResolvedMemoryWikiConfig } + | undefined; + const requireCommandContext = () => { + if (!commandContext) { + throw new Error("Memory Wiki CLI agent context was not resolved."); + } + return commandContext; + }; + const wiki = program + .command("wiki") + .description("Inspect and initialize the memory wiki vault") + .option("--agent ", "Agent id for agent-scoped wiki vaults"); + wiki.hook("preAction", () => { + const requestedAgentId = wiki.opts().agent?.trim() || undefined; + const currentAppConfig = registration.getAppConfig?.(); + const config = resolveConfig(requestedAgentId, currentAppConfig); + const agentId = config.agentId ?? requestedAgentId; + commandContext = { + config, + ...(currentAppConfig ? { appConfig: currentAppConfig } : {}), + ...(agentId ? { agentId } : {}), + }; + }); wiki .command("status") .description("Show wiki vault status") .option("--json", "Print JSON") .action(async (opts: WikiStatusCommandOptions) => { - await runWikiStatus({ config, appConfig, json: opts.json }); + const { agentId, appConfig, config } = requireCommandContext(); + await runWikiStatus({ config, appConfig, agentId, json: opts.json }); }); wiki @@ -951,7 +1006,8 @@ export function registerWikiCli( .description("Audit wiki vault setup and report actionable fixes") .option("--json", "Print JSON") .action(async (opts: WikiDoctorCommandOptions) => { - await runWikiDoctor({ config, appConfig, json: opts.json }); + const { agentId, appConfig, config } = requireCommandContext(); + await runWikiDoctor({ config, appConfig, agentId, json: opts.json }); }); wiki @@ -959,6 +1015,7 @@ export function registerWikiCli( .description("Initialize the wiki vault layout") .option("--json", "Print JSON") .action(async (opts: WikiInitCommandOptions) => { + const { config } = requireCommandContext(); await runWikiInit({ config, json: opts.json }); }); @@ -967,6 +1024,7 @@ export function registerWikiCli( .description("Refresh generated wiki indexes") .option("--json", "Print JSON") .action(async (opts: WikiCompileCommandOptions) => { + const { appConfig, config } = requireCommandContext(); await runWikiCompile({ config, appConfig, json: opts.json }); }); @@ -975,6 +1033,7 @@ export function registerWikiCli( .description("Lint the wiki vault and write a report") .option("--json", "Print JSON") .action(async (opts: WikiLintCommandOptions) => { + const { appConfig, config } = requireCommandContext(); await runWikiLint({ config, appConfig, json: opts.json }); }); @@ -985,6 +1044,7 @@ export function registerWikiCli( .option("--title ", "Override the source title") .option("--json", "Print JSON") .action(async (inputPath: string, opts: WikiIngestCommandOptions) => { + const { config } = requireCommandContext(); await runWikiIngest({ config, inputPath, title: opts.title, json: opts.json }); }); @@ -995,6 +1055,7 @@ export function registerWikiCli( .argument("<path>", "OKF bundle directory") .option("--json", "Print JSON") .action(async (bundlePath: string, opts: WikiOkfImportCommandOptions) => { + const { config } = requireCommandContext(); await runWikiOkfImport({ config, bundlePath, json: opts.json }); }); @@ -1010,9 +1071,11 @@ export function registerWikiCli( ) .option("--json", "Print JSON") .action(async (query: string, opts: WikiSearchCommandOptions) => { + const { agentId, appConfig, config } = requireCommandContext(); await runWikiSearch({ config, appConfig, + agentId, query, maxResults: opts.maxResults, searchBackend: opts.backend, @@ -1036,9 +1099,11 @@ export function registerWikiCli( ) .option("--json", "Print JSON") .action(async (lookup: string, opts: WikiGetCommandOptions) => { + const { agentId, appConfig, config } = requireCommandContext(); await runWikiGet({ config, appConfig, + agentId, lookup, fromLine: opts.from, lineCount: opts.lines, @@ -1059,6 +1124,7 @@ export function registerWikiCli( ) .option("--json", "Print JSON") .action(async (title: string, opts: WikiApplySynthesisCommandOptions) => { + const { appConfig, config } = requireCommandContext(); await runWikiApplySynthesis({ config, appConfig, @@ -1082,6 +1148,7 @@ export function registerWikiCli( .option("--clear-confidence", "Remove any stored confidence value") .option("--json", "Print JSON") .action(async (lookup: string, opts: WikiApplyMetadataCommandOptions) => { + const { appConfig, config } = requireCommandContext(); await runWikiApplyMetadata({ config, appConfig, @@ -1104,7 +1171,8 @@ export function registerWikiCli( .description("Sync bridge-backed memory artifacts into wiki source pages") .option("--json", "Print JSON") .action(async (opts: WikiBridgeImportCommandOptions) => { - await runWikiBridgeImport({ config, appConfig, json: opts.json }); + const { agentId, appConfig, config } = requireCommandContext(); + await runWikiBridgeImport({ config, appConfig, agentId, json: opts.json }); }); const unsafeLocal = wiki @@ -1115,6 +1183,7 @@ export function registerWikiCli( .description("Sync unsafe-local configured paths into wiki source pages") .option("--json", "Print JSON") .action(async (opts: WikiUnsafeLocalImportCommandOptions) => { + const { appConfig, config } = requireCommandContext(); await runWikiUnsafeLocalImport({ config, appConfig, json: opts.json }); }); @@ -1128,6 +1197,7 @@ export function registerWikiCli( .option("--dry-run", "Preview changes without writing", false) .option("--json", "Print JSON") .action(async (opts: WikiChatGptImportCommandOptions) => { + const { config } = requireCommandContext(); await runWikiChatGptImport({ config, exportPath: opts.export!, @@ -1141,6 +1211,7 @@ export function registerWikiCli( .argument("<run-id>", "Import run id") .option("--json", "Print JSON") .action(async (runId: string, opts: WikiChatGptRollbackCommandOptions) => { + const { config } = requireCommandContext(); await runWikiChatGptRollback({ config, runId, @@ -1154,6 +1225,7 @@ export function registerWikiCli( .description("Probe the Obsidian CLI") .option("--json", "Print JSON") .action(async (opts: WikiStatusCommandOptions) => { + const { config } = requireCommandContext(); await runWikiObsidianStatus({ config, json: opts.json }); }); obsidian @@ -1162,6 +1234,7 @@ export function registerWikiCli( .argument("<query>", "Search query") .option("--json", "Print JSON") .action(async (query: string, opts: WikiObsidianSearchCommandOptions) => { + const { config } = requireCommandContext(); await runWikiObsidianSearch({ config, query, json: opts.json }); }); obsidian @@ -1170,6 +1243,7 @@ export function registerWikiCli( .argument("<path>", "Vault-relative path") .option("--json", "Print JSON") .action(async (vaultPath: string, opts: WikiObsidianOpenCommandOptions) => { + const { config } = requireCommandContext(); await runWikiObsidianOpenCli({ config, vaultPath, json: opts.json }); }); obsidian @@ -1178,6 +1252,7 @@ export function registerWikiCli( .argument("<id>", "Obsidian command id") .option("--json", "Print JSON") .action(async (id: string, opts: WikiObsidianCommandCommandOptions) => { + const { config } = requireCommandContext(); await runWikiObsidianCommandCli({ config, id, json: opts.json }); }); obsidian @@ -1185,6 +1260,7 @@ export function registerWikiCli( .description("Open today's daily note in Obsidian") .option("--json", "Print JSON") .action(async (opts: WikiObsidianDailyCommandOptions) => { + const { config } = requireCommandContext(); await runWikiObsidianDailyCli({ config, json: opts.json }); }); } diff --git a/extensions/memory-wiki/src/config.test.ts b/extensions/memory-wiki/src/config.test.ts index 06872e6607ef..e2a78782dcc6 100644 --- a/extensions/memory-wiki/src/config.test.ts +++ b/extensions/memory-wiki/src/config.test.ts @@ -6,12 +6,17 @@ import { type JsonSchemaObject, } from "openclaw/plugin-sdk/json-schema-runtime"; import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../api.js"; import { DEFAULT_WIKI_RENDER_MODE, DEFAULT_WIKI_SEARCH_BACKEND, DEFAULT_WIKI_SEARCH_CORPUS, DEFAULT_WIKI_VAULT_MODE, + DEFAULT_WIKI_VAULT_SCOPE, + memoryWikiConfigSchema, resolveDefaultMemoryWikiVaultPath, + resolveDefaultMemoryWikiVaultRoot, + resolveMemoryWikiAgentConfig, resolveMemoryWikiConfig, } from "./config.js"; @@ -33,6 +38,7 @@ describe("resolveMemoryWikiConfig", () => { const config = resolveMemoryWikiConfig(undefined, { homedir: "/Users/tester" }); expect(config.vaultMode).toBe(DEFAULT_WIKI_VAULT_MODE); + expect(config.vault.scope).toBe(DEFAULT_WIKI_VAULT_SCOPE); expect(config.vault.renderMode).toBe(DEFAULT_WIKI_RENDER_MODE); expect(config.vault.path).toBe(resolveDefaultMemoryWikiVaultPath("/Users/tester")); expect(config.search.backend).toBe(DEFAULT_WIKI_SEARCH_BACKEND); @@ -66,6 +72,118 @@ describe("resolveMemoryWikiConfig", () => { expect(canonical.bridge.readMemoryArtifacts).toBe(false); }); + + it("resolves normalized agent ids to distinct vault roots", () => { + const base = resolveMemoryWikiConfig( + { + vault: { + scope: "agent", + path: "~/vaults/wiki", + }, + }, + { homedir: "/Users/tester" }, + ); + const appConfig = { + agents: { + list: [{ id: "Support Team", default: true }, { id: "Marketing" }], + }, + } as OpenClawConfig; + + const support = resolveMemoryWikiAgentConfig({ + config: base, + appConfig, + agentId: " SUPPORT TEAM ", + }); + const marketing = resolveMemoryWikiAgentConfig({ + config: base, + appConfig, + agentId: "MARKETING", + }); + + expect(base.vault.path).toBe(path.join("/Users/tester", "vaults", "wiki")); + expect(support).toMatchObject({ + agentId: "support-team", + vault: { scope: "agent", path: path.join(base.vault.path, "support-team") }, + }); + expect(marketing).toMatchObject({ + agentId: "marketing", + vault: { scope: "agent", path: path.join(base.vault.path, "marketing") }, + }); + expect(support.vault.path).not.toBe(marketing.vault.path); + }); + + it("uses the wiki root before appending the single configured agent", () => { + const base = resolveMemoryWikiConfig( + { vault: { scope: "agent" } }, + { homedir: "/Users/tester" }, + ); + + const resolved = resolveMemoryWikiAgentConfig({ + config: base, + appConfig: { agents: { list: [{ id: "support", default: true }] } }, + }); + + expect(base.vault.path).toBe(resolveDefaultMemoryWikiVaultRoot("/Users/tester")); + expect(resolved.vault.path).toBe( + path.join(resolveDefaultMemoryWikiVaultRoot("/Users/tester"), "support"), + ); + }); + + it("fails closed when a multi-agent scoped vault has no agent context", () => { + const config = resolveMemoryWikiConfig({ vault: { scope: "agent" } }); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + } as OpenClawConfig; + + expect(() => resolveMemoryWikiAgentConfig({ config, appConfig })).toThrow( + "agentId is required", + ); + }); + + it("fails closed for unknown scoped agents", () => { + const config = resolveMemoryWikiConfig({ vault: { scope: "agent" } }); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + } as OpenClawConfig; + + expect(() => resolveMemoryWikiAgentConfig({ config, appConfig, agentId: "finance" })).toThrow( + "Unknown memory-wiki agentId: finance", + ); + }); + + it("rejects unsafe-local access for agent-scoped vaults", () => { + const parsed = memoryWikiConfigSchema.safeParse?.({ + vaultMode: "unsafe-local", + vault: { scope: "agent" }, + }); + + expect(parsed?.success).toBe(false); + if (parsed?.success === false) { + expect(parsed.error?.issues).toContainEqual( + expect.objectContaining({ + path: ["vaultMode"], + message: "vaultMode=unsafe-local cannot be combined with vault.scope=agent", + }), + ); + } + }); + + it("rejects the global Obsidian CLI selector for agent-scoped vaults", () => { + const parsed = memoryWikiConfigSchema.safeParse?.({ + vault: { scope: "agent" }, + obsidian: { useOfficialCli: true }, + }); + + expect(parsed?.success).toBe(false); + if (parsed?.success === false) { + expect(parsed.error?.issues).toContainEqual( + expect.objectContaining({ + path: ["obsidian", "useOfficialCli"], + message: "obsidian.useOfficialCli cannot be enabled with vault.scope=agent", + }), + ); + } + }); }); describe("memory-wiki manifest config schema", () => { @@ -101,4 +219,26 @@ describe("memory-wiki manifest config schema", () => { expect(validate(config)).toBe(true); }); + + it("rejects unsafe-local access for agent-scoped vaults", () => { + const validate = compileManifestConfigSchema(); + + expect( + validate({ + vaultMode: "unsafe-local", + vault: { scope: "agent" }, + }), + ).toBe(false); + }); + + it("rejects the global Obsidian CLI selector for agent-scoped vaults", () => { + const validate = compileManifestConfigSchema(); + + expect( + validate({ + vault: { scope: "agent" }, + obsidian: { useOfficialCli: true }, + }), + ).toBe(false); + }); }); diff --git a/extensions/memory-wiki/src/config.ts b/extensions/memory-wiki/src/config.ts index e58a6f6cc454..af2ebfa90ea5 100644 --- a/extensions/memory-wiki/src/config.ts +++ b/extensions/memory-wiki/src/config.ts @@ -2,14 +2,18 @@ import os from "node:os"; import path from "node:path"; import { mapPluginConfigIssues } from "openclaw/plugin-sdk/extension-shared"; +import { resolveDefaultAgentId, resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import { buildPluginConfigSchema, z, type OpenClawPluginConfigSchema } from "../api.js"; +import type { OpenClawConfig } from "../api.js"; const WIKI_VAULT_MODES = ["isolated", "bridge", "unsafe-local"] as const; +const WIKI_VAULT_SCOPES = ["global", "agent"] as const; const WIKI_RENDER_MODES = ["native", "obsidian"] as const; export const WIKI_SEARCH_BACKENDS = ["shared", "local"] as const; export const WIKI_SEARCH_CORPORA = ["wiki", "memory", "all"] as const; type WikiVaultMode = (typeof WIKI_VAULT_MODES)[number]; +export type WikiVaultScope = (typeof WIKI_VAULT_SCOPES)[number]; type WikiRenderMode = (typeof WIKI_RENDER_MODES)[number]; export type WikiSearchBackend = (typeof WIKI_SEARCH_BACKENDS)[number]; export type WikiSearchCorpus = (typeof WIKI_SEARCH_CORPORA)[number]; @@ -17,6 +21,7 @@ export type WikiSearchCorpus = (typeof WIKI_SEARCH_CORPORA)[number]; export type MemoryWikiPluginConfig = { vaultMode?: WikiVaultMode; vault?: { + scope?: WikiVaultScope; path?: string; renderMode?: WikiRenderMode; }; @@ -58,8 +63,10 @@ export type MemoryWikiPluginConfig = { }; export type ResolvedMemoryWikiConfig = { + agentId?: string; vaultMode: WikiVaultMode; vault: { + scope: WikiVaultScope; path: string; renderMode: WikiRenderMode; }; @@ -100,69 +107,93 @@ export type ResolvedMemoryWikiConfig = { }; }; +export type MemoryWikiConfigResolver = ( + agentId?: string, + appConfig?: OpenClawConfig, +) => ResolvedMemoryWikiConfig; + export const DEFAULT_WIKI_VAULT_MODE: WikiVaultMode = "isolated"; +export const DEFAULT_WIKI_VAULT_SCOPE: WikiVaultScope = "global"; export const DEFAULT_WIKI_RENDER_MODE: WikiRenderMode = "native"; export const DEFAULT_WIKI_SEARCH_BACKEND: WikiSearchBackend = "shared"; export const DEFAULT_WIKI_SEARCH_CORPUS: WikiSearchCorpus = "wiki"; -const MemoryWikiConfigSource = z.strictObject({ - vaultMode: z.enum(WIKI_VAULT_MODES).optional(), - vault: z - .strictObject({ - path: z.string().optional(), - renderMode: z.enum(WIKI_RENDER_MODES).optional(), - }) - .optional(), - obsidian: z - .strictObject({ - enabled: z.boolean().optional(), - useOfficialCli: z.boolean().optional(), - vaultName: z.string().optional(), - openAfterWrites: z.boolean().optional(), - }) - .optional(), - bridge: z - .strictObject({ - enabled: z.boolean().optional(), - readMemoryArtifacts: z.boolean().optional(), - indexDreamReports: z.boolean().optional(), - indexDailyNotes: z.boolean().optional(), - indexMemoryRoot: z.boolean().optional(), - followMemoryEvents: z.boolean().optional(), - }) - .optional(), - unsafeLocal: z - .strictObject({ - allowPrivateMemoryCoreAccess: z.boolean().optional(), - paths: z.array(z.string()).optional(), - }) - .optional(), - ingest: z - .strictObject({ - autoCompile: z.boolean().optional(), - maxConcurrentJobs: z.number().int().min(1).optional(), - allowUrlIngest: z.boolean().optional(), - }) - .optional(), - search: z - .strictObject({ - backend: z.enum(WIKI_SEARCH_BACKENDS).optional(), - corpus: z.enum(WIKI_SEARCH_CORPORA).optional(), - }) - .optional(), - context: z - .strictObject({ - includeCompiledDigestPrompt: z.boolean().optional(), - }) - .optional(), - render: z - .strictObject({ - preserveHumanBlocks: z.boolean().optional(), - createBacklinks: z.boolean().optional(), - createDashboards: z.boolean().optional(), - }) - .optional(), -}); +const MemoryWikiConfigSource = z + .strictObject({ + vaultMode: z.enum(WIKI_VAULT_MODES).optional(), + vault: z + .strictObject({ + scope: z.enum(WIKI_VAULT_SCOPES).optional(), + path: z.string().optional(), + renderMode: z.enum(WIKI_RENDER_MODES).optional(), + }) + .optional(), + obsidian: z + .strictObject({ + enabled: z.boolean().optional(), + useOfficialCli: z.boolean().optional(), + vaultName: z.string().optional(), + openAfterWrites: z.boolean().optional(), + }) + .optional(), + bridge: z + .strictObject({ + enabled: z.boolean().optional(), + readMemoryArtifacts: z.boolean().optional(), + indexDreamReports: z.boolean().optional(), + indexDailyNotes: z.boolean().optional(), + indexMemoryRoot: z.boolean().optional(), + followMemoryEvents: z.boolean().optional(), + }) + .optional(), + unsafeLocal: z + .strictObject({ + allowPrivateMemoryCoreAccess: z.boolean().optional(), + paths: z.array(z.string()).optional(), + }) + .optional(), + ingest: z + .strictObject({ + autoCompile: z.boolean().optional(), + maxConcurrentJobs: z.number().int().min(1).optional(), + allowUrlIngest: z.boolean().optional(), + }) + .optional(), + search: z + .strictObject({ + backend: z.enum(WIKI_SEARCH_BACKENDS).optional(), + corpus: z.enum(WIKI_SEARCH_CORPORA).optional(), + }) + .optional(), + context: z + .strictObject({ + includeCompiledDigestPrompt: z.boolean().optional(), + }) + .optional(), + render: z + .strictObject({ + preserveHumanBlocks: z.boolean().optional(), + createBacklinks: z.boolean().optional(), + createDashboards: z.boolean().optional(), + }) + .optional(), + }) + .superRefine((value, ctx) => { + if (value.vault?.scope === "agent" && value.vaultMode === "unsafe-local") { + ctx.addIssue({ + code: "custom", + path: ["vaultMode"], + message: "vaultMode=unsafe-local cannot be combined with vault.scope=agent", + }); + } + if (value.vault?.scope === "agent" && value.obsidian?.useOfficialCli === true) { + ctx.addIssue({ + code: "custom", + path: ["obsidian", "useOfficialCli"], + message: "obsidian.useOfficialCli cannot be enabled with vault.scope=agent", + }); + } + }); const memoryWikiConfigSchemaBase = buildPluginConfigSchema(MemoryWikiConfigSource, { safeParse(value: unknown) { @@ -198,6 +229,10 @@ export function resolveDefaultMemoryWikiVaultPath(homedir = os.homedir()): strin return path.join(homedir, ".openclaw", "wiki", "main"); } +export function resolveDefaultMemoryWikiVaultRoot(homedir = os.homedir()): string { + return path.join(homedir, ".openclaw", "wiki"); +} + export function resolveMemoryWikiConfig( config: MemoryWikiPluginConfig | undefined, options?: { homedir?: string }, @@ -205,12 +240,17 @@ export function resolveMemoryWikiConfig( const homedir = options?.homedir ?? os.homedir(); const parsed = config ? MemoryWikiConfigSource.safeParse(config) : null; const safeConfig = parsed?.success ? parsed.data : (config ?? {}); + const vaultScope = safeConfig.vault?.scope ?? DEFAULT_WIKI_VAULT_SCOPE; return { vaultMode: safeConfig.vaultMode ?? DEFAULT_WIKI_VAULT_MODE, vault: { + scope: vaultScope, path: expandHomePath( - safeConfig.vault?.path ?? resolveDefaultMemoryWikiVaultPath(homedir), + safeConfig.vault?.path ?? + (vaultScope === "agent" + ? resolveDefaultMemoryWikiVaultRoot(homedir) + : resolveDefaultMemoryWikiVaultPath(homedir)), homedir, ), renderMode: safeConfig.vault?.renderMode ?? DEFAULT_WIKI_RENDER_MODE, @@ -252,3 +292,53 @@ export function resolveMemoryWikiConfig( }, }; } + +export function resolveMemoryWikiConfiguredAgentIds( + appConfig: OpenClawConfig | undefined, +): string[] { + const configured = appConfig?.agents?.list ?? []; + const ids = configured.flatMap((entry) => { + const rawId = entry?.id?.trim(); + if (!rawId) { + return []; + } + return [resolveSessionAgentId({ config: appConfig, agentId: rawId })]; + }); + return [...new Set(ids.length > 0 ? ids : [resolveDefaultAgentId(appConfig ?? {})])]; +} + +/** Resolve the exact vault for one trusted runtime agent context. */ +export function resolveMemoryWikiAgentConfig(params: { + config: ResolvedMemoryWikiConfig; + appConfig?: OpenClawConfig; + agentId?: string; +}): ResolvedMemoryWikiConfig { + if (params.config.vault.scope === "global") { + return params.config; + } + if (params.config.vaultMode === "unsafe-local") { + throw new Error("memory-wiki vault.scope=agent does not support vaultMode=unsafe-local."); + } + + const configuredAgentIds = resolveMemoryWikiConfiguredAgentIds(params.appConfig); + const requestedAgentId = params.agentId?.trim(); + if (!requestedAgentId && configuredAgentIds.length > 1) { + throw new Error("agentId is required for memory-wiki when vault.scope=agent."); + } + const agentId = resolveSessionAgentId({ + config: params.appConfig, + agentId: requestedAgentId ?? resolveDefaultAgentId(params.appConfig ?? {}), + }); + if (!configuredAgentIds.includes(agentId)) { + throw new Error(`Unknown memory-wiki agentId: ${requestedAgentId ?? agentId}.`); + } + + return { + ...params.config, + agentId, + vault: { + ...params.config.vault, + path: path.join(params.config.vault.path, agentId), + }, + }; +} diff --git a/extensions/memory-wiki/src/corpus-supplement.test.ts b/extensions/memory-wiki/src/corpus-supplement.test.ts new file mode 100644 index 000000000000..7ad174eacf24 --- /dev/null +++ b/extensions/memory-wiki/src/corpus-supplement.test.ts @@ -0,0 +1,107 @@ +// Memory Wiki tests cover corpus supplement agent routing. +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../api.js"; +import { + resolveMemoryWikiAgentConfig, + resolveMemoryWikiConfig, + type MemoryWikiConfigResolver, +} from "./config.js"; +import { createWikiCorpusSupplement } from "./corpus-supplement.js"; + +const queryMocks = vi.hoisted(() => ({ + getMemoryWikiPage: vi.fn(), + searchMemoryWiki: vi.fn(), +})); + +vi.mock("./query.js", async (importOriginal) => ({ + ...(await importOriginal<typeof import("./query.js")>()), + ...queryMocks, +})); + +describe("memory-wiki corpus supplement", () => { + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + } as OpenClawConfig; + const config = resolveMemoryWikiConfig({ + vault: { scope: "agent", path: "/tmp/memory-wiki-agents" }, + }); + + beforeEach(() => { + queryMocks.searchMemoryWiki.mockReset().mockResolvedValue([]); + queryMocks.getMemoryWikiPage.mockReset().mockResolvedValue(null); + }); + + it("resolves search and get from each invocation's agent context", async () => { + const resolveConfig = vi.fn<MemoryWikiConfigResolver>((agentId, currentAppConfig) => + resolveMemoryWikiAgentConfig({ config, appConfig: currentAppConfig, agentId }), + ); + const getAppConfig = vi.fn(() => appConfig); + const supplement = createWikiCorpusSupplement({ resolveConfig, getAppConfig }); + + await supplement.search({ + query: "support handbook", + maxResults: 4, + agentId: "support", + agentSessionKey: "agent:support:main", + sandboxed: true, + }); + await supplement.get({ + lookup: "marketing-plan", + fromLine: 3, + lineCount: 8, + agentId: "marketing", + agentSessionKey: "agent:marketing:main", + sandboxed: false, + }); + + expect(resolveConfig).toHaveBeenNthCalledWith(1, "support", appConfig); + expect(resolveConfig).toHaveBeenNthCalledWith(2, "marketing", appConfig); + expect(queryMocks.searchMemoryWiki).toHaveBeenCalledWith({ + config: expect.objectContaining({ + agentId: "support", + vault: expect.objectContaining({ + path: path.join(config.vault.path, "support"), + }), + }), + appConfig, + agentId: "support", + agentSessionKey: "agent:support:main", + sandboxed: true, + query: "support handbook", + maxResults: 4, + searchBackend: "local", + searchCorpus: "wiki", + }); + expect(queryMocks.getMemoryWikiPage).toHaveBeenCalledWith({ + config: expect.objectContaining({ + agentId: "marketing", + vault: expect.objectContaining({ + path: path.join(config.vault.path, "marketing"), + }), + }), + appConfig, + agentId: "marketing", + agentSessionKey: "agent:marketing:main", + sandboxed: false, + lookup: "marketing-plan", + fromLine: 3, + lineCount: 8, + searchBackend: "local", + searchCorpus: "wiki", + }); + }); + + it("fails closed before querying when multi-agent context is missing", async () => { + const supplement = createWikiCorpusSupplement({ + resolveConfig: (agentId, currentAppConfig) => + resolveMemoryWikiAgentConfig({ config, appConfig: currentAppConfig, agentId }), + getAppConfig: () => appConfig, + }); + + await expect(supplement.search({ query: "shared data" })).rejects.toThrow( + "agentId is required", + ); + expect(queryMocks.searchMemoryWiki).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/memory-wiki/src/corpus-supplement.ts b/extensions/memory-wiki/src/corpus-supplement.ts index eeade4ffc4ac..12303e894dc7 100644 --- a/extensions/memory-wiki/src/corpus-supplement.ts +++ b/extensions/memory-wiki/src/corpus-supplement.ts @@ -1,38 +1,56 @@ // Memory Wiki plugin module implements corpus supplement behavior. import type { OpenClawConfig } from "../api.js"; -import type { ResolvedMemoryWikiConfig } from "./config.js"; +import type { MemoryWikiConfigResolver } from "./config.js"; import { getMemoryWikiPage, searchMemoryWiki } from "./query.js"; export function createWikiCorpusSupplement(params: { - config: ResolvedMemoryWikiConfig; - appConfig?: OpenClawConfig; + resolveConfig: MemoryWikiConfigResolver; + getAppConfig: () => OpenClawConfig | undefined; }) { return { - search: async (input: { query: string; maxResults?: number; agentSessionKey?: string }) => - await searchMemoryWiki({ - config: params.config, - appConfig: params.appConfig, + search: async (input: { + query: string; + maxResults?: number; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; + }) => { + const appConfig = params.getAppConfig(); + const config = params.resolveConfig(input.agentId, appConfig); + return await searchMemoryWiki({ + config, + appConfig, + agentId: config.agentId ?? input.agentId, agentSessionKey: input.agentSessionKey, + sandboxed: input.sandboxed, query: input.query, maxResults: input.maxResults, searchBackend: "local", searchCorpus: "wiki", - }), + }); + }, get: async (input: { lookup: string; fromLine?: number; lineCount?: number; + agentId?: string; agentSessionKey?: string; - }) => - await getMemoryWikiPage({ - config: params.config, - appConfig: params.appConfig, + sandboxed?: boolean; + }) => { + const appConfig = params.getAppConfig(); + const config = params.resolveConfig(input.agentId, appConfig); + return await getMemoryWikiPage({ + config, + appConfig, + agentId: config.agentId ?? input.agentId, agentSessionKey: input.agentSessionKey, + sandboxed: input.sandboxed, lookup: input.lookup, fromLine: input.fromLine, lineCount: input.lineCount, searchBackend: "local", searchCorpus: "wiki", - }), + }); + }, }; } diff --git a/extensions/memory-wiki/src/gateway.test.ts b/extensions/memory-wiki/src/gateway.test.ts index 55753a1a7f31..f5c1bd4f26f3 100644 --- a/extensions/memory-wiki/src/gateway.test.ts +++ b/extensions/memory-wiki/src/gateway.test.ts @@ -1,4 +1,5 @@ // Memory Wiki tests cover gateway plugin behavior. +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { applyMemoryWikiMutation, @@ -105,6 +106,27 @@ function readRespondError(respond: { mock: { calls: Array<Array<unknown>> } }): return call?.[2]; } +const VAULT_BACKED_GATEWAY_CASES = [ + ["wiki.status", {}], + ["wiki.importRuns", {}], + ["wiki.importInsights", {}], + ["wiki.palace", {}], + ["wiki.init", {}], + ["wiki.doctor", {}], + ["wiki.compile", {}], + ["wiki.ingest", { inputPath: "/tmp/alpha-notes.txt" }], + ["wiki.lint", {}], + ["wiki.bridge.import", {}], + ["wiki.unsafeLocal.import", {}], + ["wiki.search", { query: "alpha" }], + ["wiki.apply", { op: "create_synthesis" }], + ["wiki.get", { lookup: "alpha" }], + ["wiki.obsidian.search", { query: "alpha" }], + ["wiki.obsidian.open", { path: "syntheses/alpha.md" }], + ["wiki.obsidian.command", { id: "workspace:save-file" }], + ["wiki.obsidian.daily", {}], +] as const satisfies ReadonlyArray<readonly [string, Record<string, unknown>]>; + describe("memory-wiki gateway methods", () => { beforeEach(() => { vi.clearAllMocks(); @@ -120,10 +142,15 @@ describe("memory-wiki gateway methods", () => { indexUpdatedFiles: [], indexRefreshReason: "no-import-changes", }); - vi.mocked(resolveMemoryWikiStatus).mockResolvedValue({ - vaultMode: "isolated", - vaultExists: true, - } as never); + vi.mocked(resolveMemoryWikiStatus).mockImplementation( + async (config) => + ({ + vaultScope: config.vault.scope, + agentId: config.agentId ?? null, + vaultMode: "isolated", + vaultExists: true, + }) as never, + ); vi.mocked(ingestMemoryWikiSource).mockResolvedValue({ pagePath: "sources/alpha-notes.md", } as never); @@ -183,6 +210,84 @@ describe("memory-wiki gateway methods", () => { }); }); + it.each(VAULT_BACKED_GATEWAY_CASES)( + "%s resolves its request agent exactly once", + async (method, methodParams) => { + const { config, rootDir } = await createVault({ + prefix: "memory-wiki-gateway-agent-", + config: { vault: { scope: "agent" } }, + }); + const { api, registerGatewayMethod } = createPluginApi(); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + const agentConfig = { + ...config, + agentId: "marketing", + vault: { ...config.vault, path: path.join(rootDir, "marketing") }, + }; + const resolveConfig = vi.fn(() => agentConfig); + + registerMemoryWikiGatewayMethods({ api, config, appConfig, resolveConfig }); + const handler = findGatewayHandler(registerGatewayMethod, method); + if (!handler) { + throw new Error(`${method} handler missing`); + } + + await handler({ + params: { ...methodParams, agentId: "marketing" }, + respond: vi.fn(), + }); + + expect(resolveConfig).toHaveBeenCalledOnce(); + expect(resolveConfig).toHaveBeenCalledWith("marketing", appConfig); + }, + ); + + it("keeps only the Obsidian executable probe outside vault resolution", async () => { + const { config } = await createVault({ prefix: "memory-wiki-gateway-" }); + const { api, registerGatewayMethod } = createPluginApi(); + const resolveConfig = vi.fn(() => config); + + registerMemoryWikiGatewayMethods({ api, config, resolveConfig }); + const handler = findGatewayHandler(registerGatewayMethod, "wiki.obsidian.status"); + if (!handler) { + throw new Error("wiki.obsidian.status handler missing"); + } + + await handler({ params: { agentId: "marketing" }, respond: vi.fn() }); + + expect(resolveConfig).not.toHaveBeenCalled(); + expect( + registerGatewayMethod.mock.calls + .map(([method]) => method) + .filter((method) => method !== "wiki.obsidian.status"), + ).toEqual(VAULT_BACKED_GATEWAY_CASES.map(([method]) => method)); + }); + + it("rejects official Obsidian CLI actions for agent-scoped vaults", async () => { + const { config } = await createVault({ + prefix: "memory-wiki-gateway-agent-", + config: { vault: { scope: "agent" } }, + }); + const { api, registerGatewayMethod } = createPluginApi(); + const appConfig = { agents: { list: [{ id: "support", default: true }] } }; + + registerMemoryWikiGatewayMethods({ api, config, appConfig }); + const handler = findGatewayHandler(registerGatewayMethod, "wiki.obsidian.search"); + if (!handler) { + throw new Error("wiki.obsidian.search handler missing"); + } + const respond = vi.fn(); + + await handler({ params: { agentId: "support", query: "alpha" }, respond }); + + expect(readRespondError(respond)).toEqual({ + code: "internal_error", + message: "Official Obsidian CLI actions do not support memory-wiki vault.scope=agent.", + }); + }); + it("returns wiki status over the gateway", async () => { const { config } = await createVault({ prefix: "memory-wiki-gateway-" }); const { api, registerGatewayMethod } = createPluginApi(); @@ -204,11 +309,96 @@ describe("memory-wiki gateway methods", () => { appConfig: undefined, }); expect(readRespondPayload(respond)).toEqual({ + vaultScope: "global", + agentId: null, vaultMode: "isolated", vaultExists: true, }); }); + it("keeps global vault requests on the shared base config", async () => { + const { config } = await createVault({ prefix: "memory-wiki-gateway-" }); + const { api, registerGatewayMethod } = createPluginApi(); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + + registerMemoryWikiGatewayMethods({ api, config, appConfig }); + const handler = findGatewayHandler(registerGatewayMethod, "wiki.status"); + if (!handler) { + throw new Error("wiki.status handler missing"); + } + + await handler({ params: { agentId: "marketing" }, respond: vi.fn() }); + + expect(syncMemoryWikiImportedSources).toHaveBeenCalledWith({ config, appConfig }); + expect(resolveMemoryWikiStatus).toHaveBeenCalledWith(config, { appConfig }); + }); + + it("resolves an agent-scoped vault once from each request and live app config", async () => { + const { config, rootDir } = await createVault({ + prefix: "memory-wiki-gateway-agent-", + config: { vault: { scope: "agent" } }, + }); + const { api, registerGatewayMethod } = createPluginApi(); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + const getAppConfig = vi.fn(() => appConfig); + + registerMemoryWikiGatewayMethods({ api, config, getAppConfig }); + const handler = findGatewayHandler(registerGatewayMethod, "wiki.status"); + if (!handler) { + throw new Error("wiki.status handler missing"); + } + const respond = vi.fn(); + + await handler({ params: { agentId: "marketing" }, respond }); + + const resolvedConfig = expect.objectContaining({ + agentId: "marketing", + vault: expect.objectContaining({ path: path.join(rootDir, "marketing") }), + }); + expect(getAppConfig).toHaveBeenCalledTimes(1); + expect(syncMemoryWikiImportedSources).toHaveBeenCalledWith({ + config: resolvedConfig, + appConfig, + }); + expect(resolveMemoryWikiStatus).toHaveBeenCalledWith(resolvedConfig, { appConfig }); + expect(readRespondPayload(respond)).toEqual({ + vaultScope: "agent", + agentId: "marketing", + vaultMode: "isolated", + vaultExists: true, + }); + }); + + it.each([ + [{}, "agentId is required for memory-wiki when vault.scope=agent."], + [{ agentId: "unknown" }, "Unknown memory-wiki agentId: unknown."], + ])("fails closed for invalid agent-scoped requests", async (requestParams, message) => { + const { config } = await createVault({ + prefix: "memory-wiki-gateway-agent-", + config: { vault: { scope: "agent" } }, + }); + const { api, registerGatewayMethod } = createPluginApi(); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + }; + + registerMemoryWikiGatewayMethods({ api, config, appConfig }); + const handler = findGatewayHandler(registerGatewayMethod, "wiki.status"); + if (!handler) { + throw new Error("wiki.status handler missing"); + } + const respond = vi.fn(); + + await handler({ params: requestParams, respond }); + + expect(syncMemoryWikiImportedSources).not.toHaveBeenCalled(); + expect(readRespondError(respond)).toEqual({ code: "internal_error", message }); + }); + it("returns recent import runs over the gateway", async () => { const { config } = await createVault({ prefix: "memory-wiki-gateway-" }); const { api, registerGatewayMethod } = createPluginApi(); diff --git a/extensions/memory-wiki/src/gateway.ts b/extensions/memory-wiki/src/gateway.ts index afec885d5010..d47e99bae02f 100644 --- a/extensions/memory-wiki/src/gateway.ts +++ b/extensions/memory-wiki/src/gateway.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig, OpenClawPluginApi } from "../api.js"; import { applyMemoryWikiMutation, normalizeMemoryWikiMutationInput } from "./apply.js"; import { compileMemoryWikiVault } from "./compile.js"; import { + resolveMemoryWikiAgentConfig, WIKI_SEARCH_BACKENDS, WIKI_SEARCH_CORPORA, type ResolvedMemoryWikiConfig, @@ -77,16 +78,6 @@ function respondError(respond: GatewayRespond, error: unknown) { respond(false, undefined, { code: "internal_error", message }); } -function resolveGatewayAgentId( - requestParams: Record<string, unknown>, - appConfig: OpenClawConfig | undefined, -): string | undefined { - return ( - readStringParam(requestParams, "agentId") ?? - (appConfig ? resolveDefaultAgentId(appConfig) : undefined) - ); -} - async function syncImportedSourcesIfNeeded( config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig, @@ -98,13 +89,50 @@ export function registerMemoryWikiGatewayMethods(params: { api: OpenClawPluginApi; config: ResolvedMemoryWikiConfig; appConfig?: OpenClawConfig; + getAppConfig?: () => OpenClawConfig | undefined; + resolveConfig?: (agentId?: string, appConfig?: OpenClawConfig) => ResolvedMemoryWikiConfig; }) { - const { api, config, appConfig } = params; + const { api, config: baseConfig } = params; + + const getAppConfig = () => { + if (params.getAppConfig) { + return params.getAppConfig(); + } + if (typeof api.runtime.config?.current === "function") { + return api.runtime.config.current() as OpenClawConfig; + } + return params.appConfig; + }; + const resolveRequestContext = (requestParams: Record<string, unknown>) => { + const appConfig = getAppConfig(); + const requestedAgentId = readStringParam(requestParams, "agentId"); + const config = params.resolveConfig + ? params.resolveConfig(requestedAgentId, appConfig) + : resolveMemoryWikiAgentConfig({ + config: baseConfig, + appConfig, + ...(requestedAgentId ? { agentId: requestedAgentId } : {}), + }); + const agentId = + config.agentId ?? + requestedAgentId ?? + (appConfig ? resolveDefaultAgentId(appConfig) : undefined); + return { agentId, appConfig, config }; + }; + + const assertOfficialObsidianCliSupported = (config: ResolvedMemoryWikiConfig) => { + if (config.vault.scope === "agent") { + throw new Error( + "Official Obsidian CLI actions do not support memory-wiki vault.scope=agent.", + ); + } + }; api.registerGatewayMethod( "wiki.status", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond( true, @@ -123,6 +151,7 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.importRuns", async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); const limit = readPositiveIntegerParam(requestParams, "limit"); respond(true, await listMemoryWikiImportRuns(config, limit !== undefined ? { limit } : {})); } catch (error) { @@ -134,8 +163,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.importInsights", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond(true, await listMemoryWikiImportInsights(config)); } catch (error) { @@ -147,8 +177,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.palace", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond(true, await listMemoryWikiPalace(config)); } catch (error) { @@ -160,8 +191,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.init", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); respond(true, await initializeMemoryWikiVault(config)); } catch (error) { respondError(respond, error); @@ -172,8 +204,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.doctor", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); const status = await resolveMemoryWikiStatus(config, { appConfig, @@ -188,8 +221,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.compile", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond(true, await compileMemoryWikiVault(config)); } catch (error) { @@ -203,6 +237,7 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.ingest", async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); const inputPath = readStringParam(requestParams, "inputPath", { required: true }); const title = readStringParam(requestParams, "title"); respond( @@ -222,8 +257,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.lint", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond(true, await lintMemoryWikiVault(config)); } catch (error) { @@ -235,8 +271,9 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.bridge.import", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); respond( true, await syncMemoryWikiImportedSources({ @@ -253,8 +290,12 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.unsafeLocal.import", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); + if (config.vault.scope === "agent") { + throw new Error("Unsafe-local import does not support memory-wiki vault.scope=agent."); + } respond( true, await syncMemoryWikiImportedSources({ @@ -273,13 +314,13 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.search", async ({ params: requestParams, respond }) => { try { + const { agentId, appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); const query = readStringParam(requestParams, "query", { required: true }); const maxResults = readPositiveIntegerParam(requestParams, "maxResults"); const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS); const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA); const mode = readEnumParam(requestParams, "mode", WIKI_SEARCH_MODES); - const agentId = resolveGatewayAgentId(requestParams, appConfig); respond( true, await searchMemoryWiki({ @@ -304,6 +345,7 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.apply", async ({ params: requestParams, respond }) => { try { + const { appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); respond( true, @@ -323,13 +365,13 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.get", async ({ params: requestParams, respond }) => { try { + const { agentId, appConfig, config } = resolveRequestContext(requestParams); await syncImportedSourcesIfNeeded(config, appConfig); const lookup = readStringParam(requestParams, "lookup", { required: true }); const fromLine = readPositiveIntegerParam(requestParams, "fromLine"); const lineCount = readPositiveIntegerParam(requestParams, "lineCount"); const searchBackend = readEnumParam(requestParams, "backend", WIKI_SEARCH_BACKENDS); const searchCorpus = readEnumParam(requestParams, "corpus", WIKI_SEARCH_CORPORA); - const agentId = resolveGatewayAgentId(requestParams, appConfig); respond( true, await getMemoryWikiPage({ @@ -366,6 +408,8 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.obsidian.search", async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); + assertOfficialObsidianCliSupported(config); const query = readStringParam(requestParams, "query", { required: true }); respond(true, await runObsidianSearch({ config, query })); } catch (error) { @@ -379,6 +423,8 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.obsidian.open", async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); + assertOfficialObsidianCliSupported(config); const vaultPath = readStringParam(requestParams, "path", { required: true }); respond(true, await runObsidianOpen({ config, vaultPath })); } catch (error) { @@ -392,6 +438,8 @@ export function registerMemoryWikiGatewayMethods(params: { "wiki.obsidian.command", async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); + assertOfficialObsidianCliSupported(config); const id = readStringParam(requestParams, "id", { required: true }); respond(true, await runObsidianCommand({ config, id })); } catch (error) { @@ -403,8 +451,10 @@ export function registerMemoryWikiGatewayMethods(params: { api.registerGatewayMethod( "wiki.obsidian.daily", - async ({ respond }) => { + async ({ params: requestParams, respond }) => { try { + const { config } = resolveRequestContext(requestParams); + assertOfficialObsidianCliSupported(config); respond(true, await runObsidianDaily({ config })); } catch (error) { respondError(respond, error); diff --git a/extensions/memory-wiki/src/prompt-section.test.ts b/extensions/memory-wiki/src/prompt-section.test.ts index 4a59d2016a91..ea131ab9c98a 100644 --- a/extensions/memory-wiki/src/prompt-section.test.ts +++ b/extensions/memory-wiki/src/prompt-section.test.ts @@ -3,7 +3,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { resolveMemoryWikiConfig } from "./config.js"; +import type { OpenClawConfig } from "../api.js"; +import { + resolveMemoryWikiAgentConfig, + resolveMemoryWikiConfig, + type ResolvedMemoryWikiConfig, +} from "./config.js"; import { createWikiPromptSectionBuilder } from "./prompt-section.js"; let suiteRoot = ""; @@ -18,7 +23,11 @@ afterAll(async () => { } }); -const buildDefaultWikiPromptSection = createWikiPromptSectionBuilder( +function createStaticWikiPromptSectionBuilder(config: ResolvedMemoryWikiConfig) { + return createWikiPromptSectionBuilder({ config, resolveConfig: () => config }); +} + +const buildDefaultWikiPromptSection = createStaticWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: "" }, context: { includeCompiledDigestPrompt: false }, @@ -74,7 +83,7 @@ describe("default wiki prompt section", () => { ), "utf8", ); - const builder = createWikiPromptSectionBuilder( + const builder = createStaticWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: rootDir }, context: { includeCompiledDigestPrompt: true }, @@ -101,7 +110,7 @@ describe("default wiki prompt section", () => { }), "utf8", ); - const builder = createWikiPromptSectionBuilder( + const builder = createStaticWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: rootDir }, }), @@ -115,7 +124,7 @@ describe("default wiki prompt section", () => { const digestPath = path.join(rootDir, ".openclaw-wiki", "cache", "agent-digest.json"); await fs.mkdir(path.dirname(digestPath), { recursive: true }); - const builder = createWikiPromptSectionBuilder( + const builder = createStaticWikiPromptSectionBuilder( resolveMemoryWikiConfig({ vault: { path: rootDir }, context: { includeCompiledDigestPrompt: true }, @@ -186,4 +195,59 @@ describe("default wiki prompt section", () => { "Alpha was renamed in 2026. (confidence 0.42, freshness aging)", ); }); + + it("reads only the invoking agent's compiled digest", async () => { + const rootDir = path.join(suiteRoot, "agent-digests"); + const appConfig = { + agents: { list: [{ id: "support", default: true }, { id: "marketing" }] }, + } as OpenClawConfig; + const config = resolveMemoryWikiConfig({ + vault: { scope: "agent", path: rootDir }, + context: { includeCompiledDigestPrompt: true }, + }); + for (const [agentId, marker] of [ + ["support", "SUPPORT_SENTINEL"], + ["marketing", "MARKETING_SENTINEL"], + ] as const) { + const digestPath = path.join( + rootDir, + agentId, + ".openclaw-wiki", + "cache", + "agent-digest.json", + ); + await fs.mkdir(path.dirname(digestPath), { recursive: true }); + await fs.writeFile( + digestPath, + JSON.stringify({ + claimCount: 1, + pages: [ + { + title: agentId, + kind: "entity", + claimCount: 1, + topClaims: [{ text: marker }], + }, + ], + }), + "utf8", + ); + } + const builder = createWikiPromptSectionBuilder({ + config, + resolveConfig: (agentId) => resolveMemoryWikiAgentConfig({ config, appConfig, agentId }), + }); + + const support = builder({ availableTools: new Set(["web_search"]), agentId: "support" }); + const marketing = builder({ + availableTools: new Set(["web_search"]), + agentId: "marketing", + }); + + expect(support.join("\n")).toContain("SUPPORT_SENTINEL"); + expect(support.join("\n")).not.toContain("MARKETING_SENTINEL"); + expect(marketing.join("\n")).toContain("MARKETING_SENTINEL"); + expect(marketing.join("\n")).not.toContain("SUPPORT_SENTINEL"); + expect(builder({ availableTools: new Set(["web_search"]) })).toStrictEqual([]); + }); }); diff --git a/extensions/memory-wiki/src/prompt-section.ts b/extensions/memory-wiki/src/prompt-section.ts index eb555dc0e591..824eaf1e250b 100644 --- a/extensions/memory-wiki/src/prompt-section.ts +++ b/extensions/memory-wiki/src/prompt-section.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { MemoryPromptSectionBuilder } from "openclaw/plugin-sdk/memory-host-core"; -import type { ResolvedMemoryWikiConfig } from "./config.js"; +import type { MemoryWikiConfigResolver, ResolvedMemoryWikiConfig } from "./config.js"; const AGENT_DIGEST_PATH = ".openclaw-wiki/cache/agent-digest.json"; const DIGEST_MAX_PAGES = 4; @@ -214,11 +214,16 @@ function buildWikiToolGuidance(availableTools: Set<string>): string[] { return lines; } -export function createWikiPromptSectionBuilder( - config: ResolvedMemoryWikiConfig, -): MemoryPromptSectionBuilder { - return ({ availableTools }) => { - const digestLines = buildDigestPromptSection(config); +export function createWikiPromptSectionBuilder(params: { + config: ResolvedMemoryWikiConfig; + resolveConfig: MemoryWikiConfigResolver; +}): MemoryPromptSectionBuilder { + return ({ availableTools, agentId }) => { + // Prompt contexts without an agent must not fall back to another agent's digest. + const digestLines = + params.config.vault.scope === "agent" && !agentId + ? [] + : buildDigestPromptSection(params.resolveConfig(agentId)); const toolGuidance = buildWikiToolGuidance(availableTools); if (digestLines.length === 0 && toolGuidance.length === 0) { return []; diff --git a/extensions/memory-wiki/src/status.test.ts b/extensions/memory-wiki/src/status.test.ts index 8089cb757e45..9f78c1222346 100644 --- a/extensions/memory-wiki/src/status.test.ts +++ b/extensions/memory-wiki/src/status.test.ts @@ -1,6 +1,7 @@ // Memory Wiki tests cover status plugin behavior. import fs from "node:fs/promises"; import path from "node:path"; +import type { MemoryPluginPublicArtifact } from "openclaw/plugin-sdk/memory-host-core"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../api.js"; import { resolveMemoryWikiConfig } from "./config.js"; @@ -55,6 +56,8 @@ describe("resolveMemoryWikiStatus", () => { }); expect(status.vaultExists).toBe(false); + expect(status.vaultScope).toBe("global"); + expect(status.agentId).toBeNull(); expect(status.obsidianCli.requested).toBe(true); expect(status.warnings.map((warning) => warning.code)).toEqual([ "vault-missing", @@ -126,6 +129,140 @@ describe("resolveMemoryWikiStatus", () => { ); }); + it("counts only artifacts owned by the resolved agent in agent scope", async () => { + const unresolvedConfig = resolveMemoryWikiConfig( + { + vaultMode: "bridge", + vault: { scope: "agent", path: "/tmp/wiki/support" }, + bridge: { enabled: true, readMemoryArtifacts: true }, + }, + { homedir: "/Users/tester" }, + ); + const config = { ...unresolvedConfig, agentId: "support" }; + const artifacts: MemoryPluginPublicArtifact[] = [ + { + kind: "memory-root", + workspaceDir: "/tmp/support", + relativePath: "MEMORY.md", + absolutePath: "/tmp/support/MEMORY.md", + agentIds: [" SUPPORT "], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: "/tmp/marketing", + relativePath: "MEMORY.md", + absolutePath: "/tmp/marketing/MEMORY.md", + agentIds: ["marketing"], + contentType: "markdown", + }, + { + kind: "daily-note", + workspaceDir: "/tmp/shared", + relativePath: "memory/2026-07-09.md", + absolutePath: "/tmp/shared/memory/2026-07-09.md", + agentIds: ["support", "marketing"], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: "/tmp/unknown", + relativePath: "MEMORY.md", + absolutePath: "/tmp/unknown/MEMORY.md", + agentIds: [], + contentType: "markdown", + }, + ]; + + const status = await resolveMemoryWikiStatus(config, { + appConfig: { + agents: { list: [{ id: "support", default: true, workspace: "/tmp/support" }] }, + }, + listPublicArtifacts: async () => artifacts, + pathExists: async () => true, + resolveCommand: async () => null, + }); + + expect(status.vaultScope).toBe("agent"); + expect(status.agentId).toBe("support"); + expect(status.bridgePublicArtifactCount).toBe(2); + }); + + it("scopes global-vault status metadata when called by an agent tool", async () => { + const config = resolveMemoryWikiConfig( + { + vaultMode: "bridge", + bridge: { enabled: true, readMemoryArtifacts: true }, + }, + { homedir: "/Users/tester" }, + ); + const artifacts: MemoryPluginPublicArtifact[] = [ + { + kind: "memory-root", + workspaceDir: "/tmp/support", + relativePath: "MEMORY.md", + absolutePath: "/tmp/support/MEMORY.md", + agentIds: ["support"], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: "/tmp/marketing", + relativePath: "MEMORY.md", + absolutePath: "/tmp/marketing/MEMORY.md", + agentIds: ["marketing"], + contentType: "markdown", + }, + { + kind: "daily-note", + workspaceDir: "/tmp/shared", + relativePath: "memory/2026-07-09.md", + absolutePath: "/tmp/shared/memory/2026-07-09.md", + agentIds: ["support", "marketing"], + contentType: "markdown", + }, + { + kind: "memory-root", + workspaceDir: "/tmp/legacy", + relativePath: "MEMORY.md", + absolutePath: "/tmp/legacy/MEMORY.md", + agentIds: [], + contentType: "markdown", + }, + ]; + const deps = { + appConfig: {} as OpenClawConfig, + listPublicArtifacts: async () => artifacts, + pathExists: async () => true, + resolveCommand: async () => null, + }; + + const agentStatus = await resolveMemoryWikiStatus(config, { + ...deps, + callerAgentId: " SUPPORT ", + }); + const operatorStatus = await resolveMemoryWikiStatus(config, deps); + + expect(agentStatus.vaultScope).toBe("global"); + expect(agentStatus.agentId).toBeNull(); + expect(agentStatus.bridgePublicArtifactCount).toBe(2); + expect(operatorStatus.bridgePublicArtifactCount).toBe(4); + }); + + it("rejects status for an unresolved agent-scoped config", async () => { + const config = resolveMemoryWikiConfig( + { vault: { scope: "agent", path: "/tmp/wiki/support" } }, + { homedir: "/Users/tester" }, + ); + + await expect( + resolveMemoryWikiStatus(config, { + pathExists: async () => true, + resolveCommand: async () => null, + }), + ).rejects.toThrow("Memory Wiki agent-scoped vault requires a resolved agent id"); + }); + it("discovers pages in nested subdirectories", async () => { const { rootDir, config } = await createVault({ prefix: "memory-wiki-nested-", @@ -269,6 +406,8 @@ describe("resolveMemoryWikiStatus", () => { describe("renderMemoryWikiStatus", () => { it("includes warnings in the text output", () => { const rendered = renderMemoryWikiStatus({ + vaultScope: "global", + agentId: null, vaultMode: "isolated", renderMode: "native", vaultPath: "/tmp/wiki", @@ -310,6 +449,7 @@ describe("renderMemoryWikiStatus", () => { }); expect(rendered).toContain("Wiki vault mode: isolated"); + expect(rendered).toContain("Vault scope: global"); expect(rendered).toContain("Pages: 0 sources, 0 entities, 0 concepts, 0 syntheses, 0 reports"); expect(rendered).toContain( "Source provenance: 0 native, 0 bridge, 0 bridge-events, 0 unsafe-local, 0 other", diff --git a/extensions/memory-wiki/src/status.ts b/extensions/memory-wiki/src/status.ts index ef511eb6c209..fd23983f9de7 100644 --- a/extensions/memory-wiki/src/status.ts +++ b/extensions/memory-wiki/src/status.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { listActiveMemoryPublicArtifacts } from "openclaw/plugin-sdk/memory-host-core"; import { pathExists } from "openclaw/plugin-sdk/security-runtime"; import type { OpenClawConfig } from "../api.js"; +import { filterMemoryWikiBridgeArtifacts, resolveMemoryWikiVaultAgentId } from "./bridge.js"; import type { ResolvedMemoryWikiConfig } from "./config.js"; import { toWikiPageSummary, type WikiPageKind } from "./markdown.js"; import { probeObsidianCli } from "./obsidian.js"; @@ -21,6 +22,8 @@ type MemoryWikiStatusWarning = { }; export type MemoryWikiStatus = { + vaultScope: ResolvedMemoryWikiConfig["vault"]["scope"]; + agentId: string | null; vaultMode: ResolvedMemoryWikiConfig["vaultMode"]; renderMode: ResolvedMemoryWikiConfig["vault"]["renderMode"]; vaultPath: string; @@ -62,6 +65,7 @@ export type MemoryWikiDoctorReport = { type ResolveMemoryWikiStatusDeps = { appConfig?: OpenClawConfig; + callerAgentId?: string; pathExists?: (inputPath: string) => Promise<boolean>; listPublicArtifacts?: typeof listActiveMemoryPublicArtifacts; resolveCommand?: (command: string) => Promise<string | null>; @@ -208,6 +212,7 @@ export async function resolveMemoryWikiStatus( config: ResolvedMemoryWikiConfig, deps?: ResolveMemoryWikiStatusDeps, ): Promise<MemoryWikiStatus> { + const agentId = resolveMemoryWikiVaultAgentId(config); const exists = deps?.pathExists ?? pathExists; const vaultExists = await exists(config.vault.path); const bridgePublicArtifactCount = @@ -215,11 +220,13 @@ export async function resolveMemoryWikiStatus( config.vaultMode === "bridge" && config.bridge.enabled && config.bridge.readMemoryArtifacts - ? ( - await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({ + ? filterMemoryWikiBridgeArtifacts({ + config, + callerAgentId: deps.callerAgentId, + artifacts: await (deps.listPublicArtifacts ?? listActiveMemoryPublicArtifacts)({ cfg: deps.appConfig, - }) - ).length + }), + }).length : null; const obsidianProbe = await probeObsidianCli({ resolveCommand: deps?.resolveCommand }); const counts = vaultExists @@ -242,6 +249,8 @@ export async function resolveMemoryWikiStatus( }; return { + vaultScope: config.vault.scope, + agentId, vaultMode: config.vaultMode, renderMode: config.vault.renderMode, vaultPath: config.vault.path, @@ -298,6 +307,7 @@ export function buildMemoryWikiDoctorReport(status: MemoryWikiStatus): MemoryWik export function renderMemoryWikiStatus(status: MemoryWikiStatus): string { const lines = [ `Wiki vault mode: ${status.vaultMode}`, + `Vault scope: ${status.vaultScope}${status.agentId ? ` (${status.agentId})` : ""}`, `Vault: ${status.vaultExists ? "ready" : "missing"} (${status.vaultPath})`, `Render mode: ${status.renderMode}`, `Obsidian CLI: ${status.obsidianCli.available ? "available" : "missing"}${status.obsidianCli.requested ? " (requested)" : ""}`, diff --git a/extensions/memory-wiki/src/tool.ts b/extensions/memory-wiki/src/tool.ts index 869954d3b36d..164280ed24a4 100644 --- a/extensions/memory-wiki/src/tool.ts +++ b/extensions/memory-wiki/src/tool.ts @@ -117,6 +117,7 @@ type WikiToolMemoryContext = { export function createWikiStatusTool( config: ResolvedMemoryWikiConfig, appConfig?: OpenClawConfig, + memoryContext: WikiToolMemoryContext = {}, ): AnyAgentTool { return { name: "wiki_status", @@ -128,6 +129,7 @@ export function createWikiStatusTool( await syncImportedSourcesIfNeeded(config, appConfig); const status = await resolveMemoryWikiStatus(config, { appConfig, + callerAgentId: memoryContext.agentId, }); return { content: [{ type: "text", text: renderMemoryWikiStatus(status) }], diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index c78c582360bd..2853f8b3e0dc 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -73,14 +73,58 @@ function requireEntry<T extends { id?: string }>(entries: T[], id: string): T { return entry; } +type XaiBilledToolName = "code_execution" | "x_search"; + +function registerXaiBilledToolFactories() { + const tools = new Map<string, Parameters<OpenClawPluginApi["registerTool"]>[0]>(); + plugin.register( + createTestPluginApi({ + registerTool(tool, opts) { + if (opts?.name) { + tools.set(opts.name, tool); + } + }, + }), + ); + + function requireFactory(name: XaiBilledToolName) { + const factory = tools.get(name); + if (typeof factory !== "function") { + throw new Error(`Expected ${name} to register a tool factory`); + } + return factory; + } + + return { + code_execution: requireFactory("code_execution"), + x_search: requireFactory("x_search"), + }; +} + +function createXaiBilledToolConfig(name: XaiBilledToolName, enabled?: boolean) { + const toolConfig = enabled === undefined ? {} : { enabled }; + return { + plugins: { + entries: { + xai: { + config: + name === "code_execution" ? { codeExecution: toolConfig } : { xSearch: toolConfig }, + }, + }, + }, + }; +} + describe("xai provider plugin", () => { beforeEach(() => { clearLiveCatalogCacheForTests(); providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset(); + vi.stubEnv("XAI_API_KEY", ""); }); afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); it("exposes xAI OAuth and preserves the explicit device-code alias", async () => { @@ -450,6 +494,117 @@ describe("xai provider plugin", () => { expect(realtimeProvider.aliases).toContain("xai-realtime"); }); + describe.each(["code_execution", "x_search"] as const)("%s exposure", (toolName) => { + it.each([ + { + label: "exposes by default for an xAI model with auth", + provider: "xai", + hasAuth: true, + expected: true, + }, + { + label: "exposes when explicitly enabled for an xAI model with auth", + provider: "xai", + enabled: true, + hasAuth: true, + expected: true, + }, + { + label: "hides when explicitly disabled for an xAI model", + provider: "xai", + enabled: false, + hasAuth: true, + expected: false, + }, + { + label: "hides by default for a known non-xAI model", + provider: "openai", + hasAuth: true, + expected: false, + }, + { + label: "hides when explicitly disabled for a known non-xAI model", + provider: "openai", + enabled: false, + hasAuth: true, + expected: false, + }, + { + label: "exposes when explicitly enabled for a known non-xAI model with auth", + provider: "openai", + enabled: true, + hasAuth: true, + expected: true, + }, + { + label: "hides when the active provider is missing", + enabled: true, + hasAuth: true, + expected: false, + }, + { + label: "hides when the active provider is blank", + provider: " ", + enabled: true, + hasAuth: true, + expected: false, + }, + { + label: "hides an xAI model without auth", + provider: "xai", + hasAuth: false, + expected: false, + }, + { + label: "hides an explicit non-xAI opt-in without auth", + provider: "openai", + enabled: true, + hasAuth: false, + expected: false, + }, + ])("$label", ({ provider, enabled, hasAuth, expected }) => { + const factory = registerXaiBilledToolFactories()[toolName]; + const tool = factory({ + config: createXaiBilledToolConfig(toolName, enabled), + activeModel: provider === undefined ? {} : { provider }, + hasAuthForProvider: (providerId) => hasAuth && providerId === "xai", + resolveApiKeyForProvider: async (providerId) => + hasAuth && providerId === "xai" ? "xai-test-key" : undefined, + }); + + expect(tool).toEqual(expected ? expect.objectContaining({ name: toolName }) : null); + }); + + it.each([ + { + label: "runtime false overrides source true", + provider: "xai", + sourceEnabled: true, + runtimeEnabled: false, + expected: false, + }, + { + label: "runtime true overrides source false for a known non-xAI provider", + provider: "openai", + sourceEnabled: false, + runtimeEnabled: true, + expected: true, + }, + ])("$label", ({ provider, sourceEnabled, runtimeEnabled, expected }) => { + const factory = registerXaiBilledToolFactories()[toolName]; + const tool = factory({ + config: createXaiBilledToolConfig(toolName, sourceEnabled), + runtimeConfig: createXaiBilledToolConfig(toolName, runtimeEnabled), + activeModel: { provider }, + hasAuthForProvider: (providerId) => providerId === "xai", + resolveApiKeyForProvider: async (providerId) => + providerId === "xai" ? "xai-test-key" : undefined, + }); + + expect(tool).toEqual(expected ? expect.objectContaining({ name: toolName }) : null); + }); + }); + it("declares setup auto-enable reasons for plugin-owned tool config", () => { const probe = registerXaiAutoEnableProbe(); diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index e4849f7214b2..29919a40d692 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -1,5 +1,6 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Xai plugin entrypoint registers its OpenClaw integration. +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { OPENAI_COMPATIBLE_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; import { defaultToolStreamExtraParams } from "openclaw/plugin-sdk/provider-stream-shared"; @@ -92,13 +93,30 @@ function isXSearchEnabled(config: unknown, auth?: XaiToolAuthContext): boolean { return hasResolvableXaiApiKey(config, auth); } -function createLazyCodeExecutionTool(ctx: { - config?: Record<string, unknown>; - runtimeConfig?: Record<string, unknown>; - hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"]; - resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"]; -}) { +function shouldExposeXaiBilledTool(params: { + activeProvider?: string; + enabled?: unknown; +}): boolean { + const activeProvider = params.activeProvider?.trim(); + if (!activeProvider || params.enabled === false) { + return false; + } + // Cross-provider billing requires explicit consent; xAI models retain the + // credential-backed default. Unknown providers fail closed. + return activeProvider === PROVIDER_ID || params.enabled === true; +} + +function createLazyCodeExecutionTool(ctx: OpenClawPluginToolContext) { const effectiveConfig = ctx.runtimeConfig ?? ctx.config; + const codeExecutionConfig = readPluginCodeExecutionConfig(effectiveConfig); + if ( + !shouldExposeXaiBilledTool({ + activeProvider: ctx.activeModel?.provider, + enabled: codeExecutionConfig?.enabled, + }) + ) { + return null; + } if (!isCodeExecutionEnabled(effectiveConfig, ctx)) { return null; } @@ -119,13 +137,17 @@ function createLazyCodeExecutionTool(ctx: { ); } -function createLazyXSearchTool(ctx: { - config?: Record<string, unknown>; - runtimeConfig?: Record<string, unknown>; - hasAuthForProvider?: XaiToolAuthContext["hasAuthForProvider"]; - resolveApiKeyForProvider?: XaiToolAuthContext["resolveApiKeyForProvider"]; -}) { +function createLazyXSearchTool(ctx: OpenClawPluginToolContext) { const effectiveConfig = ctx.runtimeConfig ?? ctx.config; + const xSearchConfig = resolveEffectiveXSearchConfig(effectiveConfig); + if ( + !shouldExposeXaiBilledTool({ + activeProvider: ctx.activeModel?.provider, + enabled: xSearchConfig?.enabled, + }) + ) { + return null; + } if (!isXSearchEnabled(effectiveConfig, ctx)) { return null; } diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index a1d05edaf42a..164f1735dfaf 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -140,11 +140,11 @@ }, "codeExecution.enabled": { "label": "Enable Code Execution", - "help": "Enable the code_execution tool for remote xAI sandbox analysis." + "help": "Expose code_execution on active xAI models; true opts in when the active provider is known non-xAI, false disables, and missing provider fails closed. Requires xAI auth; xAI bills $5/1,000 calls plus model tokens." }, "xSearch.enabled": { "label": "Enable X Search", - "help": "Enable the x_search tool for searching X posts with xAI." + "help": "Expose x_search on active xAI models; true opts in when the active provider is known non-xAI, false disables, and missing provider fails closed. Requires xAI auth; xAI bills $5/1,000 calls plus model tokens." }, "xSearch.model": { "label": "X Search Model", diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts index 80d6bcc234e4..b07b016964ec 100644 --- a/extensions/xai/video-generation-provider.test.ts +++ b/extensions/xai/video-generation-provider.test.ts @@ -4,10 +4,15 @@ import { installProviderHttpMockCleanup, } from "openclaw/plugin-sdk/provider-http-test-mocks"; import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; +import type { VideoGenerationRequest } from "openclaw/plugin-sdk/video-generation"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const { postJsonRequestMock, fetchWithTimeoutMock, readProviderJsonResponseMock } = - getProviderHttpMocks(); +const { + postJsonRequestMock, + fetchWithTimeoutMock, + readProviderJsonResponseMock, + resolveApiKeyForProviderMock, +} = getProviderHttpMocks(); let buildXaiVideoGenerationProvider: typeof import("./video-generation-provider.js").buildXaiVideoGenerationProvider; @@ -161,6 +166,142 @@ describe("xai video generation provider", () => { expectExplicitVideoGenerationCapabilities(buildXaiVideoGenerationProvider()); }); + it("advertises canonical 1.5 and resolves capabilities for all API aliases", async () => { + const provider = buildXaiVideoGenerationProvider(); + + expect(provider.defaultModel).toBe("grok-imagine-video"); + expect(provider.models).toEqual(["grok-imagine-video", "grok-imagine-video-1.5"]); + expect(provider.catalogByModel?.["grok-imagine-video-1.5"]).toMatchObject({ + modes: ["imageToVideo"], + capabilities: { + imageToVideo: { + enabled: true, + maxInputImages: 1, + resolutions: ["480P", "720P", "1080P"], + }, + videoToVideo: { enabled: false }, + }, + }); + + for (const model of [ + "grok-imagine-video-1.5", + "grok-imagine-video-1.5-preview", + "grok-imagine-video-1.5-2026-05-30", + ]) { + const capabilities = await provider.resolveModelCapabilities?.({ + provider: "xai", + model, + cfg: {}, + }); + expect(capabilities?.imageToVideo).toMatchObject({ + enabled: true, + maxInputImages: 1, + maxDurationSeconds: 15, + resolutions: ["480P", "720P", "1080P"], + }); + expect(capabilities?.videoToVideo?.enabled).toBe(false); + } + }); + + it("uses the 1.5 default while preserving aliases, 1080p, and source aspect ratio", async () => { + const models = [ + "grok-imagine-video-1.5", + "grok-imagine-video-1.5-preview", + "grok-imagine-video-1.5-2026-05-30", + ]; + const provider = buildXaiVideoGenerationProvider(); + + for (const [index, model] of models.entries()) { + const requestId = `req_15_${index}`; + postJsonRequestMock.mockResolvedValueOnce({ + response: { json: async () => ({ request_id: requestId }) }, + release: vi.fn(async () => {}), + }); + fetchWithTimeoutMock + .mockResolvedValueOnce({ + json: async () => ({ + request_id: requestId, + status: "done", + video: { url: `https://cdn.x.ai/${requestId}.mp4` }, + }), + }) + .mockResolvedValueOnce({ + headers: new Headers({ "content-type": "video/mp4" }), + arrayBuffer: async () => Buffer.from("video-bytes"), + }); + + const result = await provider.generateVideo({ + provider: "xai", + model, + prompt: "Animate this still image", + cfg: {}, + durationSeconds: 20, + resolution: index === 0 ? undefined : "1080P", + inputImages: [ + { + url: "https://example.com/first-frame.png", + ...(index === 0 ? {} : { role: "first_frame" as const }), + }, + ], + }); + + const body = requirePostJsonCall(index).body ?? {}; + expect(body.model).toBe(model); + expect(body.image).toEqual({ url: "https://example.com/first-frame.png" }); + expect(body.duration).toBe(15); + expect(body.resolution).toBe(index === 0 ? "480p" : "1080p"); + expect(body).not.toHaveProperty("aspect_ratio"); + expect(result.model).toBe(model); + } + }); + + it("rejects unsupported 1.5 modes before submitting a request", async () => { + const provider = buildXaiVideoGenerationProvider(); + const cases: Array< + Pick<VideoGenerationRequest, "model" | "inputImages" | "inputVideos"> & { error: string } + > = [ + { + model: "grok-imagine-video-1.5", + inputImages: undefined, + error: "xAI grok-imagine-video-1.5 requires exactly one first-frame image.", + }, + { + model: "grok-imagine-video-1.5-preview", + inputImages: [{ url: "https://example.com/reference.png", role: "reference_image" }], + error: "xAI grok-imagine-video-1.5 supports only an ordinary or first_frame image.", + }, + { + model: "grok-imagine-video-1.5-2026-05-30", + inputImages: [ + { url: "https://example.com/first.png" }, + { url: "https://example.com/second.png", role: "first_frame" }, + ], + error: "xAI grok-imagine-video-1.5 requires exactly one first-frame image.", + }, + { + model: "grok-imagine-video-1.5", + inputImages: undefined, + inputVideos: [{ url: "https://example.com/input.mp4" }], + error: "xAI grok-imagine-video-1.5 does not support video inputs.", + }, + ]; + + for (const testCase of cases) { + await expect( + provider.generateVideo({ + provider: "xai", + model: testCase.model, + prompt: "Unsupported 1.5 request", + cfg: {}, + inputImages: testCase.inputImages, + inputVideos: testCase.inputVideos, + }), + ).rejects.toThrow(testCase.error); + } + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled(); + expect(postJsonRequestMock).not.toHaveBeenCalled(); + }); + it("creates, polls, and downloads a generated video", async () => { postJsonRequestMock.mockResolvedValue({ response: { diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts index 790c0f46e7b6..fa64954b5286 100644 --- a/extensions/xai/video-generation-provider.ts +++ b/extensions/xai/video-generation-provider.ts @@ -21,15 +21,37 @@ import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-co import type { GeneratedVideoAsset, VideoGenerationProvider, + VideoGenerationProviderCapabilities, VideoGenerationRequest, } from "openclaw/plugin-sdk/video-generation"; const DEFAULT_XAI_VIDEO_BASE_URL = "https://api.x.ai/v1"; const DEFAULT_XAI_VIDEO_MODEL = "grok-imagine-video"; +const XAI_VIDEO_15_MODEL = "grok-imagine-video-1.5"; +const XAI_VIDEO_15_MODEL_IDS = new Set([ + XAI_VIDEO_15_MODEL, + "grok-imagine-video-1.5-preview", + "grok-imagine-video-1.5-2026-05-30", +]); const DEFAULT_TIMEOUT_MS = 600_000; const POLL_INTERVAL_MS = 5_000; const MAX_POLL_ATTEMPTS = 120; const XAI_VIDEO_ASPECT_RATIOS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"]); +const XAI_VIDEO_15_CAPABILITIES = { + imageToVideo: { + enabled: true, + maxVideos: 1, + maxInputImages: 1, + maxDurationSeconds: 15, + aspectRatios: [...XAI_VIDEO_ASPECT_RATIOS], + resolutions: ["480P", "720P", "1080P"], + supportsAspectRatio: true, + supportsResolution: true, + }, + videoToVideo: { + enabled: false, + }, +} satisfies VideoGenerationProviderCapabilities; const XAI_VIDEO_MALFORMED_RESPONSE = "xAI video generation response malformed"; // xAI documents these as the only meaningful values; everything else (queued, // processing, submitted, pending, in_progress, ...) means "keep polling". @@ -37,6 +59,7 @@ const XAI_VIDEO_TERMINAL_FAILURE_STATUSES = new Set(["failed", "error", "expired const XAI_VIDEO_DEFAULT_DURATION_SECONDS = 8; const XAI_VIDEO_DEFAULT_ASPECT_RATIO = "16:9"; const XAI_VIDEO_DEFAULT_RESOLUTION = "720p"; +const XAI_VIDEO_15_DEFAULT_RESOLUTION = "480p"; const DEFAULT_GENERATED_VIDEO_MAX_BYTES = 16 * 1024 * 1024; type XaiVideoCreateResponse = { @@ -155,6 +178,32 @@ function isReferenceImage(input: VideoGenerationSourceInput): boolean { return normalizeOptionalString(input.role)?.toLowerCase() === "reference_image"; } +function isXaiVideo15Model(model: string | undefined): boolean { + const normalized = normalizeOptionalString(model); + return normalized ? XAI_VIDEO_15_MODEL_IDS.has(normalized) : false; +} + +function isFirstFrameImage(input: VideoGenerationSourceInput): boolean { + const role = normalizeOptionalString(input.role)?.toLowerCase(); + return role === undefined || role === "first_frame"; +} + +function validateXaiVideo15Request(req: VideoGenerationRequest): void { + if (!isXaiVideo15Model(req.model)) { + return; + } + if ((req.inputVideos?.length ?? 0) > 0) { + throw new Error("xAI grok-imagine-video-1.5 does not support video inputs."); + } + const inputImages = req.inputImages ?? []; + if (inputImages.length !== 1) { + throw new Error("xAI grok-imagine-video-1.5 requires exactly one first-frame image."); + } + if (!isFirstFrameImage(inputImages[0])) { + throw new Error("xAI grok-imagine-video-1.5 supports only an ordinary or first_frame image."); + } +} + function resolveInputVideoUrl(input: VideoGenerationSourceInput | undefined): string | undefined { if (!input) { return undefined; @@ -189,7 +238,10 @@ function resolveAspectRatio(value: string | undefined): string | undefined { return trimmed; } -function resolveResolution(value: string | undefined): "480p" | "720p" | undefined { +function resolveResolution( + value: string | undefined, + options?: { allow1080p?: boolean }, +): "480p" | "720p" | "1080p" | undefined { if (typeof value !== "string") { return undefined; } @@ -197,9 +249,12 @@ function resolveResolution(value: string | undefined): "480p" | "720p" | undefin if (normalized === "480p") { return "480p"; } - if (normalized === "720p" || normalized === "1080p") { + if (normalized === "720p") { return "720p"; } + if (normalized === "1080p") { + return options?.allow1080p ? "1080p" : "720p"; + } return undefined; } @@ -223,6 +278,7 @@ function resolveXaiVideoMode( } function buildCreateBody(req: VideoGenerationRequest): Record<string, unknown> { + validateXaiVideo15Request(req); const inputImages = req.inputImages ?? []; const hasReferenceImages = inputImages.some(isReferenceImage); if (hasReferenceImages && !inputImages.every(isReferenceImage)) { @@ -245,11 +301,14 @@ function buildCreateBody(req: VideoGenerationRequest): Record<string, unknown> { const mode = resolveXaiVideoMode(req); const body: Record<string, unknown> = { + // Aliases are API-owned routing choices. Preserve the selected identifier + // instead of silently pinning it to the canonical 1.5 model. model: normalizeOptionalString(req.model) ?? DEFAULT_XAI_VIDEO_MODEL, prompt: req.prompt, }; if (mode === "generate") { + const isVideo15 = isXaiVideo15Model(req.model); const imageUrl = resolveImageUrl(req.inputImages?.[0]); if (imageUrl) { body.image = { url: imageUrl }; @@ -260,8 +319,16 @@ function buildCreateBody(req: VideoGenerationRequest): Record<string, unknown> { min: 1, max: 15, }) ?? XAI_VIDEO_DEFAULT_DURATION_SECONDS; - body.aspect_ratio = resolveAspectRatio(req.aspectRatio) ?? XAI_VIDEO_DEFAULT_ASPECT_RATIO; - body.resolution = resolveResolution(req.resolution) ?? XAI_VIDEO_DEFAULT_RESOLUTION; + const aspectRatio = resolveAspectRatio(req.aspectRatio); + // 1.5 inherits the source image ratio when callers do not choose one. + if (aspectRatio || !isVideo15) { + body.aspect_ratio = aspectRatio ?? XAI_VIDEO_DEFAULT_ASPECT_RATIO; + } + const defaultResolution = isVideo15 + ? XAI_VIDEO_15_DEFAULT_RESOLUTION + : XAI_VIDEO_DEFAULT_RESOLUTION; + body.resolution = + resolveResolution(req.resolution, { allow1080p: isVideo15 }) ?? defaultResolution; return body; } @@ -380,7 +447,13 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { label: "xAI", defaultModel: DEFAULT_XAI_VIDEO_MODEL, defaultTimeoutMs: DEFAULT_TIMEOUT_MS, - models: [DEFAULT_XAI_VIDEO_MODEL], + models: [DEFAULT_XAI_VIDEO_MODEL, XAI_VIDEO_15_MODEL], + catalogByModel: { + [XAI_VIDEO_15_MODEL]: { + capabilities: XAI_VIDEO_15_CAPABILITIES, + modes: ["imageToVideo"], + }, + }, isConfigured: ({ agentDir }) => isProviderApiKeyConfigured({ provider: "xai", @@ -414,7 +487,17 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { supportsResolution: true, }, }, + resolveModelCapabilities: ({ model }): VideoGenerationProviderCapabilities | undefined => { + if (!isXaiVideo15Model(model)) { + return undefined; + } + return XAI_VIDEO_15_CAPABILITIES; + }, async generateVideo(req) { + // Validate provider/model mode constraints before auth or HTTP setup so + // unsupported 1.5 requests cannot be submitted and billed accidentally. + const createBody = buildCreateBody(req); + const createEndpoint = resolveCreateEndpoint(req); const auth = await resolveApiKeyForProvider({ provider: "xai", cfg: req.cfg, @@ -448,9 +531,9 @@ export function buildXaiVideoGenerationProvider(): VideoGenerationProvider { const submitHeaders = new Headers(headers); submitHeaders.set("x-idempotency-key", crypto.randomUUID()); const { response, release } = await postJsonRequest({ - url: `${baseUrl}${resolveCreateEndpoint(req)}`, + url: `${baseUrl}${createEndpoint}`, headers: submitHeaders, - body: buildCreateBody(req), + body: createBody, timeoutMs: resolveProviderOperationTimeoutMs({ deadline, defaultTimeoutMs: DEFAULT_TIMEOUT_MS, diff --git a/extensions/xai/xai.live.test.ts b/extensions/xai/xai.live.test.ts index a342551f4508..62955f0cccf2 100644 --- a/extensions/xai/xai.live.test.ts +++ b/extensions/xai/xai.live.test.ts @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { encodePngRgba, fillPixel } from "openclaw/plugin-sdk/media-runtime"; +import type { OpenClawPluginToolFactory } from "openclaw/plugin-sdk/plugin-entry"; +import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { registerProviderPlugin, requireRegisteredProvider, @@ -21,6 +23,7 @@ import { XAI_DEFAULT_STT_MODEL } from "./stt.js"; const XAI_API_KEY = process.env.XAI_API_KEY ?? ""; const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_XAI_IMAGE_MODEL?.trim() || "grok-imagine-image"; +const ENABLE_VIDEO_15_LIVE = process.env.OPENCLAW_LIVE_XAI_VIDEO_15 === "1"; const liveEnabled = XAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const; @@ -63,6 +66,27 @@ function createReferencePng(): Buffer { return encodePngRgba(buf, width, height); } +function createVideoReferencePng(): Buffer { + const width = 384; + const height = 384; + const buf = Buffer.alloc(width * height * 4, 255); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const blue = Math.round(160 + (80 * y) / height); + fillPixel(buf, x, y, width, 32, 96, blue, 255); + } + } + + for (let y = 112; y < 272; y += 1) { + for (let x = 112; x < 272; x += 1) { + fillPixel(buf, x, y, width, 255, 153, 51, 255); + } + } + + return encodePngRgba(buf, width, height); +} + async function createTempAgentDir(): Promise<string> { return await fs.mkdtemp(path.join(os.tmpdir(), "xai-plugin-live-")); } @@ -74,6 +98,20 @@ const registerXaiPlugin = () => name: "xAI Provider", }); +function registerXaiToolFactories(): Map<string, OpenClawPluginToolFactory> { + const factories = new Map<string, OpenClawPluginToolFactory>(); + plugin.register( + createTestPluginApi({ + registerTool(tool, options) { + if (typeof tool === "function" && options?.name) { + factories.set(options.name, tool); + } + }, + }), + ); + return factories; +} + async function runXaiLiveCase(label: string, run: () => Promise<void>): Promise<void> { try { await run(); @@ -92,6 +130,76 @@ function isRealtimeOpenBillingDrift(error: Error): boolean { } describeLive("xai plugin live", () => { + it("gates registered billed tools and honors explicit cross-provider consent", async () => { + await runXaiLiveCase("billed-tool-policy", async () => { + const codeExecutionFactory = registerXaiToolFactories().get("code_execution"); + if (!codeExecutionFactory) { + throw new Error("expected code_execution factory to be registered"); + } + const baseConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { apiKey: XAI_API_KEY }, + }, + }, + }, + }, + } as OpenClawConfig; + const explicitConfig = { + plugins: { + entries: { + xai: { + config: { + webSearch: { apiKey: XAI_API_KEY }, + codeExecution: { enabled: true, maxTurns: 1, timeoutSeconds: 90 }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect( + codeExecutionFactory({ + config: baseConfig, + activeModel: { provider: "xai", modelId: "grok-4.3" }, + }), + ).not.toBeNull(); + expect( + codeExecutionFactory({ + config: baseConfig, + activeModel: { provider: "openai", modelId: "gpt-5.4" }, + }), + ).toBeNull(); + expect( + codeExecutionFactory({ + config: explicitConfig, + }), + ).toBeNull(); + + const explicitCrossProviderTool = codeExecutionFactory({ + config: explicitConfig, + activeModel: { provider: "openai", modelId: "gpt-5.4" }, + }); + if (!explicitCrossProviderTool || Array.isArray(explicitCrossProviderTool)) { + throw new Error("expected explicit cross-provider code_execution tool"); + } + const result = await explicitCrossProviderTool.execute("code-execution:cross-provider-live", { + task: "Use the code interpreter to calculate 6 multiplied by 7.", + }); + const details = (result.details ?? {}) as { + content?: string; + model?: string; + usedCodeExecution?: boolean; + }; + + expect(details.model).toBe("grok-4.3"); + expect(details.usedCodeExecution).toBe(true); + expect(details.content).toContain("42"); + }); + }, 120_000); + it("runs remote code execution with the current default model", async () => { await runXaiLiveCase("code-execution", async () => { const tool = createCodeExecutionTool({ @@ -357,4 +465,54 @@ describeLive("xai plugin live", () => { } }); }, 300_000); + + it.skipIf(!ENABLE_VIDEO_15_LIVE)( + "generates a Grok Imagine Video 1.5 clip from one image", + async () => { + await runXaiLiveCase("video-1.5", async () => { + const { videoProviders } = await registerXaiPlugin(); + const videoProvider = requireRegisteredProvider(videoProviders, "xai"); + const cfg = createLiveConfig(); + const agentDir = await createTempAgentDir(); + + try { + const generated = await videoProvider.generateVideo({ + provider: "xai", + model: "grok-imagine-video-1.5", + prompt: + "Animate the orange square with a subtle slow rotation. Keep the framing fixed.", + cfg, + agentDir, + authStore: EMPTY_AUTH_STORE, + timeoutMs: 10 * 60_000, + durationSeconds: 1, + resolution: "1080P", + inputImages: [ + { + buffer: createVideoReferencePng(), + mimeType: "image/png", + fileName: "video-reference.png", + }, + ], + }); + + expect(generated.model).toBe("grok-imagine-video-1.5"); + expect(generated.videos).toHaveLength(1); + const video = generated.videos[0]; + if (!video?.buffer) { + throw new Error("xAI Video 1.5 did not return a buffered video"); + } + expect(video.mimeType.startsWith("video/")).toBe(true); + expect(video.buffer.byteLength).toBeGreaterThan(1_000); + const outputPath = process.env.OPENCLAW_LIVE_XAI_VIDEO_15_OUTPUT?.trim(); + if (outputPath) { + await fs.writeFile(outputPath, video.buffer); + } + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + }, + 12 * 60_000, + ); }); diff --git a/package.json b/package.json index 94571629a0be..268601164bae 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dist/", "!dist/.buildstamp", "!dist/.runtime-postbuildstamp", + "!dist/OpenClaw.app/**", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", "!dist/plugin-sdk/agent-runtime-test-contracts.js", diff --git a/packages/gateway-protocol/src/cron-validators.test.ts b/packages/gateway-protocol/src/cron-validators.test.ts index 00b4f39ecac4..ec1624e20ad8 100644 --- a/packages/gateway-protocol/src/cron-validators.test.ts +++ b/packages/gateway-protocol/src/cron-validators.test.ts @@ -36,6 +36,28 @@ describe("cron protocol validators", () => { expect(validateCronAddParams(minimalAddParams)).toBe(true); }); + it("rejects schedule integers that SQLite cannot round-trip safely", () => { + const unsafe = Number.MAX_SAFE_INTEGER + 1; + expect( + validateCronAddParams({ + ...minimalAddParams, + schedule: { kind: "every", everyMs: unsafe }, + }), + ).toBe(false); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { schedule: { kind: "every", everyMs: 60_000, anchorMs: unsafe } }, + }), + ).toBe(false); + expect( + validateCronUpdateParams({ + id: "job-1", + patch: { schedule: { kind: "cron", expr: "0 * * * *", staggerMs: unsafe } }, + }), + ).toBe(false); + }); + it("accepts trigger add, patch, and clear shapes", () => { expect( validateCronAddParams({ @@ -151,6 +173,37 @@ describe("cron protocol validators", () => { expect(validateCronUpdateParams({ jobId: "job-2", patch: { enabled: true } })).toBe(true); }); + it("accepts only non-empty cron config revisions", () => { + expect( + validateCronUpdateParams({ + id: "job-1", + expectedConfigRevision: "sha256:current", + patch: { enabled: false }, + }), + ).toBe(true); + expect( + validateCronUpdateParams({ + id: "job-1", + expectedConfigRevision: "", + patch: { enabled: false }, + }), + ).toBe(false); + expect( + validateCronUpdateParams({ + id: "job-1", + expectedConfigRevision: 1, + patch: { enabled: false }, + }), + ).toBe(false); + expect( + validateCronUpdateParams({ + id: "job-1", + expectedConfigRevision: "x".repeat(129), + patch: { enabled: false }, + }), + ).toBe(false); + }); + it("accepts nullable model clears only on update payload patches", () => { expect( validateCronUpdateParams({ diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts index f9fcc4ca43ee..0e88e97c23bb 100644 --- a/packages/gateway-protocol/src/schema/cron.ts +++ b/packages/gateway-protocol/src/schema/cron.ts @@ -68,6 +68,7 @@ function cronRunStatusSchema(options: Record<string, unknown> = {}) { } const CronRunStatusSchema = cronRunStatusSchema(); +const CronConfigRevisionSchema = Type.String({ minLength: 1, maxLength: 128 }); const DeprecatedCronRunStatusSchema = cronRunStatusSchema({ deprecated: true, description: "Deprecated alias for lastRunStatus.", @@ -220,8 +221,8 @@ export const CronScheduleSchema = Type.Union([ Type.Object( { kind: Type.Literal("every"), - everyMs: Type.Integer({ minimum: 1 }), - anchorMs: Type.Optional(Type.Integer({ minimum: 0 })), + everyMs: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }), + anchorMs: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), }, { additionalProperties: false }, ), @@ -230,7 +231,7 @@ export const CronScheduleSchema = Type.Union([ kind: Type.Literal("cron"), expr: NonEmptyString, tz: Type.Optional(Type.String()), - staggerMs: Type.Optional(Type.Integer({ minimum: 0 })), + staggerMs: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })), }, { additionalProperties: false }, ), @@ -490,6 +491,8 @@ export const CronJobSchema = Type.Object( deleteAfterRun: Type.Optional(Type.Boolean()), createdAtMs: Type.Integer({ minimum: 0 }), updatedAtMs: Type.Integer({ minimum: 0 }), + /** Opaque Gateway-computed token for the job definition, excluding scheduler state. */ + configRevision: Type.Optional(CronConfigRevisionSchema), schedule: CronScheduleSchema, trigger: Type.Optional(CronTriggerSchema), sessionTarget: CronSessionTargetSchema, @@ -589,6 +592,8 @@ export const CronJobPatchSchema = Type.Object( /** Updates a cron job by id or legacy jobId alias. */ export const CronUpdateParamsSchema = cronIdOrJobIdParams({ patch: CronJobPatchSchema, + /** Rejects the patch when the current definition does not match the caller's token. */ + expectedConfigRevision: Type.Optional(CronConfigRevisionSchema), }); /** Removes a cron job by id or legacy jobId alias. */ diff --git a/packages/media-generation-core/src/catalog.test.ts b/packages/media-generation-core/src/catalog.test.ts index f42b58a2f55c..d0cf91496d6f 100644 --- a/packages/media-generation-core/src/catalog.test.ts +++ b/packages/media-generation-core/src/catalog.test.ts @@ -56,6 +56,49 @@ describe("media-generation catalog", () => { ).toEqual(["video-default", "video-pro"]); }); + it("uses per-model capabilities and modes when provided", () => { + type VideoCapabilities = { + generate?: { maxVideos: number }; + imageToVideo?: { enabled: boolean; maxInputImages: number }; + }; + const providerCapabilities: VideoCapabilities = { + generate: { maxVideos: 1 }, + }; + const alternateCapabilities: VideoCapabilities = { + imageToVideo: { enabled: true, maxInputImages: 1 }, + }; + + const rows = synthesizeMediaGenerationCatalogEntries({ + kind: "video_generation", + provider: { + id: "example", + defaultModel: "default-video", + models: ["default-video", "image-video"], + capabilities: providerCapabilities, + catalogByModel: { + "image-video": { + capabilities: alternateCapabilities, + modes: ["imageToVideo"], + }, + }, + }, + modes: ["generate"], + }); + + expect(rows).toEqual([ + expect.objectContaining({ + model: "default-video", + capabilities: providerCapabilities, + modes: ["generate"], + }), + expect.objectContaining({ + model: "image-video", + capabilities: alternateCapabilities, + modes: ["imageToVideo"], + }), + ]); + }); + it("marks a trimmed default model as the catalog default", () => { expect( synthesizeMediaGenerationCatalogEntries({ diff --git a/packages/media-generation-core/src/catalog.ts b/packages/media-generation-core/src/catalog.ts index d87c8e946b28..a6a82cd77325 100644 --- a/packages/media-generation-core/src/catalog.ts +++ b/packages/media-generation-core/src/catalog.ts @@ -30,6 +30,12 @@ export type MediaGenerationCatalogEntry<TCapabilities = unknown> = { warnings?: readonly string[]; }; +/** Static catalog metadata that overrides provider defaults for one model. */ +export type MediaGenerationCatalogModelEntry<TCapabilities = unknown> = { + capabilities?: TCapabilities; + modes?: readonly string[]; +}; + /** Provider metadata used to synthesize static media generation catalog entries. */ export type MediaGenerationCatalogProvider<TCapabilities = unknown> = { id: string; @@ -38,6 +44,7 @@ export type MediaGenerationCatalogProvider<TCapabilities = unknown> = { defaultModel?: string; models?: readonly string[]; capabilities: TCapabilities; + catalogByModel?: Readonly<Record<string, MediaGenerationCatalogModelEntry<TCapabilities>>>; }; /** Return unique configured models with default model first when present. */ @@ -53,12 +60,13 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: { }): Array<MediaGenerationCatalogEntry<TCapabilities>> { const defaultModel = uniqueTrimmedStrings([params.provider.defaultModel])[0]; return uniqueModels(params.provider).map((model) => { + const modelCatalogEntry = params.provider.catalogByModel?.[model]; const entry: MediaGenerationCatalogEntry<TCapabilities> = { kind: params.kind, provider: params.provider.id, model, source: "static", - capabilities: params.provider.capabilities, + capabilities: modelCatalogEntry?.capabilities ?? params.provider.capabilities, }; if (params.provider.label) { entry.label = params.provider.label; @@ -66,8 +74,9 @@ export function synthesizeMediaGenerationCatalogEntries<TCapabilities>(params: { if (model === defaultModel) { entry.default = true; } - if (params.modes) { - entry.modes = params.modes; + const modes = modelCatalogEntry?.modes ?? params.modes; + if (modes) { + entry.modes = modes; } return entry; }); diff --git a/scripts/e2e/lib/doctor-install-switch/scenario.sh b/scripts/e2e/lib/doctor-install-switch/scenario.sh index 55073c62a60f..b9bacacb1ba4 100644 --- a/scripts/e2e/lib/doctor-install-switch/scenario.sh +++ b/scripts/e2e/lib/doctor-install-switch/scenario.sh @@ -55,6 +55,7 @@ git_cli="$git_root/openclaw.mjs" package_version="$(node -p "require(\"$npm_root/package.json\").version")" update_doctor_env="OPENCLAW_UPDATE_IN_PROGRESS=1" +update_doctor_env+=" OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1" update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1" update_doctor_env+=" OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1" update_doctor_env+=" OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1" @@ -161,6 +162,7 @@ run_flow() { fi assert_entrypoint "$unit_path" "$doctor_expected" + assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS" } run_flow \ @@ -177,6 +179,85 @@ run_flow \ "$update_doctor_env $npm_bin doctor --repair --force --yes --non-interactive" \ "$npm_entry" +plugin_binding_approval_count() { + local database_path="$1" + if [ ! -f "$database_path" ]; then + echo "0" + return + fi + node --no-warnings - "$database_path" <<'NODE' +const { DatabaseSync } = require("node:sqlite"); +const database = new DatabaseSync(process.argv[2]); +const table = database + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get("plugin_binding_approvals"); +const row = table + ? database.prepare("SELECT COUNT(*) AS count FROM plugin_binding_approvals").get() + : { count: 0 }; +database.close(); +process.stdout.write(String(row.count)); +NODE +} + +run_cross_state_approval_flow() { + local name="cross-state-approvals" + local automated_log="/tmp/openclaw-doctor-switch-${name}-automated.log" + local direct_log="/tmp/openclaw-doctor-switch-${name}-direct.log" + local command_timeout="${OPENCLAW_DOCKER_DOCTOR_SWITCH_COMMAND_TIMEOUT:-900s}" + + echo "== Flow: $name ==" + openclaw_test_state_create "switch-${name}" empty + export USER="testuser" + + local default_state_dir="$HOME/.openclaw" + local custom_state_dir="$HOME/custom-state" + local exec_source="$default_state_dir/exec-approvals.json" + local plugin_source="$default_state_dir/plugin-binding-approvals.json" + local state_database="$custom_state_dir/state/openclaw.sqlite" + mkdir -p "$default_state_dir" "$custom_state_dir" + printf '%s\n' '{"version":1,"socket":{"token":"legacy-token"},"defaults":{"security":"deny","ask":"always"}}' >"$exec_source" + printf '%s\n' '{"version":1,"approvals":[{"pluginRoot":"/plugins/codex-a","pluginId":"codex","channel":"telegram","accountId":"default","approvedAt":2345}]}' >"$plugin_source" + local exec_source_hash + local plugin_source_hash + exec_source_hash="$(sha256sum "$exec_source" | awk '{print $1}')" + plugin_source_hash="$(sha256sum "$plugin_source" | awk '{print $1}')" + + if ! openclaw_e2e_maybe_timeout "$command_timeout" env \ + OPENCLAW_STATE_DIR="$custom_state_dir" \ + OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \ + OPENCLAW_UPDATE_IN_PROGRESS=1 \ + "$npm_bin" doctor --repair --yes --non-interactive >"$automated_log" 2>&1; then + openclaw_e2e_print_log "$automated_log" + exit 1 + fi + + test "$(sha256sum "$exec_source" | awk '{print $1}')" = "$exec_source_hash" + test "$(sha256sum "$plugin_source" | awk '{print $1}')" = "$plugin_source_hash" + test ! -e "$exec_source.migrated" + test ! -e "$plugin_source.migrated" + test ! -e "$custom_state_dir/exec-approvals.json" + test "$(plugin_binding_approval_count "$state_database")" = "0" + + if ! openclaw_e2e_maybe_timeout "$command_timeout" env \ + -u OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS \ + -u OPENCLAW_UPDATE_IN_PROGRESS \ + OPENCLAW_STATE_DIR="$custom_state_dir" \ + OPENCLAW_CONFIG_PATH="$custom_state_dir/openclaw.json" \ + "$npm_bin" doctor --repair --yes --non-interactive >"$direct_log" 2>&1; then + openclaw_e2e_print_log "$direct_log" + exit 1 + fi + + test ! -e "$exec_source" + test ! -e "$plugin_source" + test "$(sha256sum "$exec_source.migrated" | awk '{print $1}')" = "$exec_source_hash" + test "$(sha256sum "$plugin_source.migrated" | awk '{print $1}')" = "$plugin_source_hash" + test -e "$custom_state_dir/exec-approvals.json" + test "$(plugin_binding_approval_count "$state_database")" = "1" +} + +run_cross_state_approval_flow + run_proxy_env_flow() { local name="proxy-env-cleanup" local install_log="/tmp/openclaw-doctor-switch-${name}-install.log" @@ -206,6 +287,7 @@ run_proxy_env_flow() { } >>"$unit_path" if ! openclaw_e2e_maybe_timeout "$command_timeout" env \ OPENCLAW_UPDATE_IN_PROGRESS=1 \ + OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS=1 \ OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE=1 \ OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART=1 \ OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR=1 \ @@ -216,6 +298,7 @@ run_proxy_env_flow() { fi assert_no_env_key "$unit_path" "HTTP_PROXY" assert_no_env_key "$unit_path" "HTTPS_PROXY" + assert_no_env_key "$unit_path" "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS" } run_proxy_env_flow diff --git a/scripts/e2e/parallels/package-artifact.ts b/scripts/e2e/parallels/package-artifact.ts index ab1b24a9f5e2..b63b2c5d6895 100644 --- a/scripts/e2e/parallels/package-artifact.ts +++ b/scripts/e2e/parallels/package-artifact.ts @@ -1,6 +1,6 @@ // Package Artifact script supports OpenClaw repository automation. import { randomUUID } from "node:crypto"; -import { copyFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { sleep as delay } from "../../lib/sleep.mjs"; @@ -28,7 +28,19 @@ export async function packageBuildCommitFromTgz(tgzPath: string): Promise<string } function resolveNpmPackTarballFilename(value: unknown): string { - const filename = typeof value === "string" ? value.trim() : ""; + // npm 10/11 return arrays; npm 12 keys local-workspace results by package name. + const result = Array.isArray(value) + ? value.at(-1) + : value && typeof value === "object" && "openclaw" in value + ? value.openclaw + : value; + const filename = + result && + typeof result === "object" && + "filename" in result && + typeof result.filename === "string" + ? result.filename.trim() + : ""; if ( !filename.endsWith(".tgz") || filename.includes("\0") || @@ -145,7 +157,7 @@ export async function packOpenClaw(input: { ], { quiet: true }, ).stdout; - const packed = resolveNpmPackTarballFilename(JSON.parse(output).at(-1)?.filename); + const packed = resolveNpmPackTarballFilename(JSON.parse(output)); const tgzPath = path.join(input.destination, packed); const version = await packageVersionFromTgz(tgzPath); say(`Packed ${tgzPath}`); @@ -158,24 +170,28 @@ export async function packOpenClaw(input: { checkDirty: true, requireControlUi: input.requireControlUi, }); - run("node", [ - "--import", - "tsx", - "--input-type=module", - "--eval", - "import { writePackageDistInventory } from './src/infra/package-dist-inventory.ts'; await writePackageDistInventory(process.cwd());", - ]); const shortHead = run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(); - const output = run( - "npm", - ["pack", "--ignore-scripts", "--json", "--pack-destination", input.destination], - { - quiet: true, - }, - ).stdout; - const packed = resolveNpmPackTarballFilename(JSON.parse(output).at(-1)?.filename); const tgzPath = path.join(input.destination, `openclaw-main-${shortHead}.tgz`); - await copyFile(path.join(input.destination, packed), tgzPath); + // The canonical helper inventories the package, bundles private workspace runtime code, + // and rejects tarballs that still depend on unpublished workspace packages. + const packedPath = run( + "node", + [ + "scripts/package-openclaw-for-docker.mjs", + "--skip-build", + "--source-dir", + repoRoot, + "--output-dir", + input.destination, + "--output-name", + path.basename(tgzPath), + "--pnpm-pack", + ], + { quiet: true }, + ).stdout.trim(); + if (path.resolve(packedPath) !== path.resolve(tgzPath)) { + die(`package helper wrote an unexpected tarball: ${packedPath}`); + } const buildCommit = await packageBuildCommitFromTgz(tgzPath); if (!buildCommit) { die(`failed to read packed build commit from ${tgzPath}`); @@ -299,4 +315,5 @@ export const testing = { acquirePackageLock, removeStalePackageLock, readLockOwner, + resolveNpmPackTarballFilename, }; diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 471a83763f21..683fb8188aed 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -134,6 +134,7 @@ export function parseArgs(argv) { outputDir: "", outputName: "", packJson: "", + pnpmPack: false, skipBuild: false, sourceDir: ROOT_DIR, }; @@ -174,6 +175,8 @@ export function parseArgs(argv) { "packJson", readEqualsOptionValue(arg.slice("--pack-json=".length), "--pack-json"), ); + } else if (arg === "--pnpm-pack") { + setOnce(arg, "pnpmPack", true); } else if (arg === "--skip-build") { setOnce(arg, "skipBuild", true); } else if (arg === "--source-dir") { @@ -192,6 +195,9 @@ export function parseArgs(argv) { if (options.outputName) { validateOutputName(options.outputName); } + if (options.packJson && options.pnpmPack) { + throw new Error("--pack-json cannot be combined with --pnpm-pack"); + } return options; } @@ -638,6 +644,10 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options const prepareChangelog = options.prepareChangelog ?? preparePackageChangelog; const restoreChangelog = options.restoreChangelog ?? restorePackageChangelog; const prepareBundledAiRuntime = options.prepareBundledAiRuntime ?? prepareBundledAiRuntimePackage; + const packTool = options.pnpmPack ? "pnpm" : "npm"; + if (options.packJsonPath && options.pnpmPack) { + throw new Error("packJsonPath cannot be combined with pnpmPack"); + } console.error("==> Packing OpenClaw package"); await prepareChangelog(sourceDir); let packOutput; @@ -645,26 +655,24 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options try { await cleanPackedOpenClawTarballs(outputDir); cleanupBundledAiRuntime = await prepareBundledAiRuntime(sourceDir, outputDir, runCaptureImpl); - const packArgs = [ - "pack", - ...(options.packJsonPath ? ["--json"] : []), - "--silent", - "--ignore-scripts", - "--pack-destination", - outputDir, - ]; - packOutput = await runCaptureImpl( - "npm", - packArgs, - sourceDir, - { - deferForwardedSignalExit: true, - timeoutMs: resolveTimeoutMs( - "OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS", - DEFAULT_PACKAGE_PACK_TIMEOUT_MS, - ), - }, - ); + const packArgs = + packTool === "pnpm" + ? ["pack", "--silent", "--config.ignore-scripts=true", "--pack-destination", outputDir] + : [ + "pack", + ...(options.packJsonPath ? ["--json"] : []), + "--silent", + "--ignore-scripts", + "--pack-destination", + outputDir, + ]; + packOutput = await runCaptureImpl(packTool, packArgs, sourceDir, { + deferForwardedSignalExit: true, + timeoutMs: resolveTimeoutMs( + "OPENCLAW_DOCKER_PACKAGE_PACK_TIMEOUT_MS", + DEFAULT_PACKAGE_PACK_TIMEOUT_MS, + ), + }); } finally { try { await cleanupBundledAiRuntime(); @@ -672,7 +680,9 @@ export async function packOpenClawPackageForDocker(sourceDir, outputDir, options await restoreChangelog(sourceDir); } } - let tarball = await newestOpenClawTarball(outputDir, packOutput); + // pnpm reports an absolute destination path. The directory was emptied before packing, + // so scan that controlled destination instead of accepting a path from command output. + let tarball = await newestOpenClawTarball(outputDir, options.pnpmPack ? "" : packOutput); if (options.outputName) { const target = path.join(outputDir, options.outputName); if (target !== tarball) { @@ -720,6 +730,7 @@ async function main() { const tarball = await packOpenClawPackageForDocker(sourceDir, outputDir, { outputName: options.outputName, packJsonPath: options.packJson, + pnpmPack: options.pnpmPack, }); console.error("==> Checking OpenClaw package tarball"); diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index e928eb952f02..3486463bf1e2 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -195,7 +195,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10478, + 10480, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index ec5250fbe812..c109dd5329f6 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -82,6 +82,7 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[] "declarationKey", "displayName", "owner", + "configRevision", "nextRunAtMs", "lastRunAtMs", "lastRunStatus", diff --git a/scripts/watch-node.mjs b/scripts/watch-node.mjs index f92086ec0fdd..9235283888e0 100644 --- a/scripts/watch-node.mjs +++ b/scripts/watch-node.mjs @@ -20,6 +20,10 @@ const WATCH_LOCK_POLL_MS = 100; const WATCH_SHUTDOWN_KILL_GRACE_MS = 5_000; const WATCH_LOCK_DIR = path.join(".local", "watch-node"); const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]); +// The source watcher cannot import the TypeScript owner; keep this literal +// aligned with src/commands/doctor-invocation.ts. +const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV = + "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"; const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args]; const buildDoctorRunnerArgs = () => [WATCH_NODE_RUNNER, "doctor", "--fix", "--non-interactive"]; @@ -452,7 +456,10 @@ export async function runWatchMain(params = {}) { watchProcess = deps.spawn(deps.process.execPath, buildDoctorRunnerArgs(), { cwd: deps.cwd, detached: useChildProcessGroup, - env: childEnv, + env: { + ...childEnv, + [DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1", + }, stdio: "inherit", }); watchProcess.on("error", (error) => { diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 44281bd9d564..6a6677f69378 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -323,6 +323,29 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { ); }); + it("passes resolved agent context to compacted system prompt rebuilds", async () => { + resolveSessionAgentIdsMock.mockReturnValue({ + defaultAgentId: "main", + sessionAgentId: "marketing-agent", + }); + + await compactEmbeddedAgentSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:marketing-agent:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp/workspace", + }); + + expect(buildEmbeddedSystemPromptMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeInfo: expect.objectContaining({ + agentId: "marketing-agent", + sessionKey: "agent:marketing-agent:session-1", + }), + }), + ); + }); + it("keeps the embedded compaction system prompt after active tool selection", async () => { buildEmbeddedSystemPromptMock.mockReturnValueOnce("compaction system prompt"); diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 9c19c7c9b94a..532d783602f7 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -1098,6 +1098,8 @@ async function compactEmbeddedAgentSessionDirectOnce( : undefined; const runtimeInfo = { + agentId: sessionAgentId, + sessionKey: params.sessionKey, host: machineName, os: resolveRuntimeOsLabel(), arch: os.arch(), diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 04300dda27c8..3c69f2622077 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -1247,13 +1247,44 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { forwardedAuthProfileId: "openai:work", }, }); + const codexAuthStore = { + version: 1 as const, + runtimePersistedProfileIds: ["openai:work", "xai:work"], + profiles: { + "openai:work": { + type: "oauth" as const, + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + }, + "xai:work": { + type: "api_key" as const, + provider: "xai", + key: "xai-key", + }, + }, + }; clearAgentHarnesses(); registerAgentHarness({ id: "codex", label: "Codex", supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", runAttempt: pluginRunAttempt, }); + mockedEnsureAuthProfileStoreWithoutExternalProfiles.mockReturnValueOnce(codexAuthStore); + mockedResolveModelAsync.mockResolvedValueOnce({ + model: { + id: "gpt-5.4", + provider: "openai", + contextWindow: 200000, + api: "openai-chatgpt-responses", + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); mockedBuildAgentRuntimePlan.mockReturnValueOnce(runtimePlan); mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); @@ -1278,12 +1309,15 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { } expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); + expect(mockedEnsureAuthProfileStore).not.toHaveBeenCalled(); + expect(mockedEnsureAuthProfileStoreWithoutExternalProfiles).toHaveBeenCalled(); expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledTimes(1); expect(pluginRunAttempt).toHaveBeenCalledTimes(1); const pluginParams = expectMockCallFields(pluginRunAttempt, { provider: "openai", authProfileId: "openai:work", authProfileIdSource: "user", + resolvedApiKey: undefined, }); expectRuntimePlanFields(pluginParams.runtimePlan, { resolvedRef: { @@ -1297,8 +1331,18 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { forwardedAuthProfileId: "openai:work", }, }); - const harnessParams = mockCallArg(pluginRunAttempt) as { runtimePlan?: unknown }; + const harnessParams = mockCallArg(pluginRunAttempt) as { + runtimePlan?: unknown; + authProfileStore?: { profiles?: Record<string, unknown> }; + toolAuthProfileStore?: unknown; + }; expect(harnessParams?.runtimePlan).toBe(runtimePlan); + const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {}); + const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {}); + expect(Object.keys(authProfiles)).toEqual(["openai:work"]); + expect(forwardedAuthStore.runtimePersistedProfileIds).toEqual(["openai:work"]); + expectRecordFields(authProfiles["openai:work"], { provider: "openai" }); + expect(harnessParams.toolAuthProfileStore).toBe(codexAuthStore); expect(mockedMarkAuthProfileSuccess).toHaveBeenCalledTimes(1); const [[successParams]] = mockedMarkAuthProfileSuccess.mock.calls as unknown as Array< [{ provider?: string; profileId?: string }] @@ -1403,6 +1447,112 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); }); + it("delegates auth bootstrap to a forced Codex harness that owns it", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => + makeAttemptResult({ assistantTexts: ["ok"] }), + ); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + authBootstrap: "harness", + runAttempt: pluginRunAttempt, + }); + mockedResolveModelAsync.mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200000, + api: "openai-chatgpt-responses", + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + try { + await runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + model: { fallbacks: ["anthropic/claude-opus-4-6"] }, + }, + }, + }, + runId: "forced-codex-harness-auth", + }); + } finally { + clearAgentHarnesses(); + } + + expect(mockedGetApiKeyForModel).not.toHaveBeenCalled(); + expect(pluginRunAttempt).toHaveBeenCalledOnce(); + expectMockCallFields(pluginRunAttempt, { + provider: "openai", + authProfileId: undefined, + resolvedApiKey: undefined, + }); + }); + + it("keeps missing OpenClaw auth fatal for a Codex harness without owned bootstrap", async () => { + const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); + const { ProviderAuthError } = await import("../model-auth-runtime-shared.js"); + const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => + makeAttemptResult({ assistantTexts: ["ok"] }), + ); + const authError = new ProviderAuthError( + "missing-provider-auth", + "openai", + 'No API key found for provider "openai".', + ); + clearAgentHarnesses(); + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: codexHarnessSupportsKnownProviders, + runAttempt: pluginRunAttempt, + }); + mockedResolveModelAsync.mockResolvedValueOnce({ + model: { + id: "gpt-5.5", + provider: "openai", + contextWindow: 200000, + api: "openai-chatgpt-responses", + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + mockedGetApiKeyForModel.mockRejectedValueOnce(authError); + + try { + await expect( + runEmbeddedAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.5", + config: { + agents: { + defaults: { + agentRuntime: { id: "codex" }, + }, + }, + }, + runId: "codex-harness-missing-managed-auth", + }), + ).rejects.toBe(authError); + } finally { + clearAgentHarnesses(); + } + + expect(pluginRunAttempt).not.toHaveBeenCalled(); + }); + it("loads the external Codex auth overlay before auto-selecting forced Codex runtime profiles", async () => { const { clearAgentHarnesses, registerAgentHarness } = await import("../harness/registry.js"); const pluginRunAttempt = vi.fn<AgentHarness["runAttempt"]>(async () => diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 9ecc4a80a0fb..8906bb77c12e 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -1191,8 +1191,11 @@ async function runEmbeddedAgentInternal( startupStages.mark("model-resolution"); notifyExecutionPhase("model_resolution", { provider, model: modelId }); + const pluginHarnessOwnsAuthBootstrap = + pluginHarnessOwnsTransport && agentHarness.authBootstrap === "harness"; const pluginHarnessNeedsOpenClawAuthBootstrap = pluginHarnessOwnsTransport && + !pluginHarnessOwnsAuthBootstrap && provider === OPENAI_PROVIDER_ID && effectiveModel.api === "openai-chatgpt-responses"; const openClawNativeCodexResponsesNeedsAuthBootstrap = diff --git a/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts b/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts index 613e68913abe..618a7b177ea3 100644 --- a/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts +++ b/src/agents/embedded-agent-subscribe.block-reply-rejections.test.ts @@ -1,6 +1,7 @@ // Block-reply rejection tests ensure async callback failures are contained and // do not escape as process-level unhandled rejections. import { afterEach, describe, expect, it, vi } from "vitest"; +import { HEARTBEAT_RESPONSE_TOOL_NAME } from "../auto-reply/heartbeat-tool-response.js"; import { createSubscribedSessionHarness, emitAssistantTextDelta, @@ -17,6 +18,27 @@ const waitForAsyncCallbacks = async () => { }); }; +function emitToolRun(params: { + emit: (evt: unknown) => void; + toolName: string; + toolCallId: string; + result: unknown; +}): void { + params.emit({ + type: "tool_execution_start", + toolName: params.toolName, + toolCallId: params.toolCallId, + args: {}, + }); + params.emit({ + type: "tool_execution_end", + toolName: params.toolName, + toolCallId: params.toolCallId, + isError: false, + result: params.result, + }); +} + describe("subscribeEmbeddedAgentSession block reply rejections", () => { const unhandledRejections: unknown[] = []; const onUnhandledRejection = (reason: unknown) => { @@ -61,4 +83,83 @@ describe("subscribeEmbeddedAgentSession block reply rejections", () => { expect(onBlockReply).toHaveBeenCalledTimes(1); expect(unhandledRejections).toHaveLength(0); }); + + it("contains rejected assistant progress callbacks", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const rejectedCallback = vi.fn().mockRejectedValue(new Error("boom")); + const { emit } = createSubscribedSessionHarness({ + runId: "run", + onAgentEvent: rejectedCallback, + onPartialReply: rejectedCallback, + onAssistantMessageStart: rejectedCallback, + onReasoningStream: rejectedCallback, + onReasoningEnd: rejectedCallback, + reasoningMode: "stream", + }); + + emitMessageStartAndEndForAssistantText({ emit, text: "Hello" }); + emitAssistantTextDelta({ emit, delta: "Hello" }); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "thinking_delta", delta: "Because" }, + }); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "thinking_end" }, + }); + await waitForAsyncCallbacks(); + + expect(rejectedCallback).toHaveBeenCalled(); + expect(unhandledRejections).toHaveLength(0); + }); + + it("contains rejected tool presentation callbacks", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const onToolResult = vi.fn().mockRejectedValue(new Error("tool progress failed")); + const { emit } = createSubscribedSessionHarness({ + runId: "run", + onToolResult, + verboseLevel: "full", + }); + + emitToolRun({ + emit, + toolName: "read", + toolCallId: "tool-1", + result: { content: [{ type: "text", text: "file contents" }] }, + }); + await waitForAsyncCallbacks(); + + expect(onToolResult).toHaveBeenCalled(); + expect(unhandledRejections).toHaveLength(0); + }); + + it("contains rejected heartbeat response callbacks", async () => { + process.on("unhandledRejection", onUnhandledRejection); + const onHeartbeatToolResponse = vi.fn().mockRejectedValue(new Error("heartbeat failed")); + const { emit } = createSubscribedSessionHarness({ + runId: "run", + onHeartbeatToolResponse, + }); + + emitToolRun({ + emit, + toolName: HEARTBEAT_RESPONSE_TOOL_NAME, + toolCallId: "heartbeat-1", + result: { + details: { + status: "recorded", + outcome: "no_change", + notify: false, + summary: "Nothing needs attention.", + }, + }, + }); + await waitForAsyncCallbacks(); + + expect(onHeartbeatToolResponse).toHaveBeenCalledTimes(1); + expect(unhandledRejections).toHaveLength(0); + }); }); diff --git a/src/agents/embedded-agent-subscribe.callback-fatal.test.ts b/src/agents/embedded-agent-subscribe.callback-fatal.test.ts new file mode 100644 index 000000000000..11260411f538 --- /dev/null +++ b/src/agents/embedded-agent-subscribe.callback-fatal.test.ts @@ -0,0 +1,65 @@ +// Child-process proof uses OpenClaw's real fatal unhandled-rejection handler so +// an accidentally detached callback rejection terminates the negative control. +import { describe, expect, it } from "vitest"; +import { spawnNodeEvalSync } from "../test-utils/node-process.js"; + +const fatalHandlerImport = ` + import { installUnhandledRejectionHandler } from "./src/infra/unhandled-rejections.ts"; + installUnhandledRejectionHandler(); +`; + +describe("embedded agent callback rejection containment", () => { + it("proves the real process handler terminates an uncontained rejection", () => { + const result = spawnNodeEvalSync( + `${fatalHandlerImport} + void Promise.reject(new Error("negative-control-rejection")); + setTimeout(() => console.log("negative control survived"), 50);`, + { imports: ["tsx"], timeout: 20_000 }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Unhandled promise rejection"); + expect(result.stderr).toContain("negative-control-rejection"); + expect(result.stdout).not.toContain("negative control survived"); + }); + + it("keeps the production assistant progress path alive when its callback rejects", () => { + const result = spawnNodeEvalSync( + `${fatalHandlerImport} + import { subscribeEmbeddedAgentSession } from "./src/agents/embedded-agent-subscribe.ts"; + let emit = () => {}; + let callbackCalls = 0; + const session = { + subscribe(handler) { + emit = handler; + return () => {}; + }, + }; + subscribeEmbeddedAgentSession({ + session, + runId: "fatal-handler-proof", + onAgentEvent: async () => { + callbackCalls += 1; + throw new Error("assistant-progress-rejection"); + }, + }); + emit({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { type: "text_delta", delta: "hello" }, + }); + setTimeout(() => { + if (callbackCalls !== 1) { + console.error("unexpected callback count: " + callbackCalls); + process.exit(2); + } + console.log("assistant callback rejection contained"); + }, 50);`, + { imports: ["tsx"], timeout: 20_000 }, + ); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("assistant callback rejection contained"); + expect(result.stderr).not.toContain("Unhandled promise rejection"); + }); +}); diff --git a/src/agents/embedded-agent-subscribe.callback.ts b/src/agents/embedded-agent-subscribe.callback.ts new file mode 100644 index 000000000000..a34bb0fc1c9d --- /dev/null +++ b/src/agents/embedded-agent-subscribe.callback.ts @@ -0,0 +1,23 @@ +import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; + +type CallbackLogger = { + warn(message: string): void; +}; + +/** Contains failures from untracked subscriber presentation and telemetry callbacks. */ +export function runBestEffortCallback(params: { + callback: () => void | Promise<void>; + label: string; + log: CallbackLogger; +}): void { + try { + const result = params.callback(); + if (isPromiseLike<void>(result)) { + void Promise.resolve(result).catch((error: unknown) => { + params.log.warn(`${params.label} callback failed: ${String(error)}`); + }); + } + } catch (error) { + params.log.warn(`${params.label} callback failed: ${String(error)}`); + } +} diff --git a/src/agents/embedded-agent-subscribe.handlers.compaction.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.ts index bd376436cdba..cee617cfe926 100644 --- a/src/agents/embedded-agent-subscribe.handlers.compaction.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.ts @@ -7,6 +7,7 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { stripStaleAssistantUsageBeforeLatestCompaction } from "./compaction-usage.js"; import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; +import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js"; import type { AgentSessionEvent } from "./sessions/index.js"; type SessionCompactionStartEvent = Extract<AgentSessionEvent, { type: "compaction_start" }>; @@ -63,9 +64,13 @@ export function handleCompactionStart( stream: "compaction", data: { phase: "start" }, }); - void ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "start" }, + runBestEffortCallback({ + label: "compaction agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "start" }, + }), }); // Hooks are fire-and-forget so compaction state updates and liveness pauses @@ -153,9 +158,13 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com stream: "compaction", data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, }); - void ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, + runBestEffortCallback({ + label: "compaction agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.({ + stream: "compaction", + data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, + }), }); // after_compaction runs only once the run will not retry, matching the visible diff --git a/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts index 03053523037e..8861782db051 100644 --- a/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts @@ -31,7 +31,7 @@ vi.mock("../infra/agent-events.js", () => ({ function createContext( lastAssistant: unknown, overrides?: { - onAgentEvent?: (event: unknown) => void; + onAgentEvent?: (event: unknown) => void | Promise<void>; onBeforeLifecycleTerminal?: () => void | Promise<void>; onBeforeTerminalDelivery?: () => void | Promise<void>; onBlockReply?: ((payload: unknown) => void) | undefined; @@ -117,6 +117,18 @@ function firstWarnMeta(ctx: EmbeddedAgentSubscribeContext): Record<string, unkno } describe("handleAgentEnd", () => { + it("contains rejected lifecycle start event callbacks", async () => { + const onAgentEvent = vi.fn().mockRejectedValue(new Error("progress failed")); + const ctx = createContext(undefined, { onAgentEvent }); + + handleAgentStart(ctx); + await Promise.resolve(); + + expect(ctx.log.warn).toHaveBeenCalledWith( + expect.stringContaining("lifecycle agent event callback failed"), + ); + }); + it("keeps explicit session and agent identity on lifecycle start events", () => { emitAgentEventMock.mockClear(); const ctx = createContext(undefined); diff --git a/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts b/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts index 8355954ce845..433ffbceaa01 100644 --- a/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts +++ b/src/agents/embedded-agent-subscribe.handlers.lifecycle.ts @@ -25,6 +25,7 @@ import { hasAssistantVisibleReply, } from "./embedded-agent-subscribe.handlers.messages.js"; import type { EmbeddedAgentSubscribeContext } from "./embedded-agent-subscribe.handlers.types.js"; +import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js"; import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { isAssistantMessage } from "./embedded-agent-utils.js"; import type { AgentSessionEvent } from "./sessions/index.js"; @@ -51,9 +52,13 @@ export function handleAgentStart(ctx: EmbeddedAgentSubscribeContext) { startedAt: Date.now(), }, }); - void ctx.params.onAgentEvent?.({ - stream: "lifecycle", - data: { phase: "start" }, + runBestEffortCallback({ + label: "lifecycle agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.({ + stream: "lifecycle", + data: { phase: "start" }, + }), }); } @@ -213,7 +218,10 @@ export function handleAgentEnd( endedAt: Date.now(), }, }); - void ctx.params.onAgentEvent?.({ + runBestEffortCallback({ + label: "lifecycle agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.({ stream: "lifecycle", data: { phase, @@ -222,6 +230,7 @@ export function handleAgentEnd( ...(livenessState ? { livenessState } : {}), ...(replayInvalid ? { replayInvalid } : {}), }, + }), }); }; diff --git a/src/agents/embedded-agent-subscribe.handlers.messages.ts b/src/agents/embedded-agent-subscribe.handlers.messages.ts index fd497d88064c..23dc7b4da8b7 100644 --- a/src/agents/embedded-agent-subscribe.handlers.messages.ts +++ b/src/agents/embedded-agent-subscribe.handlers.messages.ts @@ -29,6 +29,7 @@ import type { EmbeddedAgentSubscribeContext, EmbeddedAgentSubscribeState, } from "./embedded-agent-subscribe.handlers.types.js"; +import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js"; import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { appendRawStream } from "./embedded-agent-subscribe.raw-stream.js"; import { warnIfAssistantEmittedToolText } from "./embedded-agent-subscribe.tool-text-diagnostics.js"; @@ -220,7 +221,19 @@ function emitReasoningEnd(ctx: EmbeddedAgentSubscribeContext) { return; } ctx.state.reasoningStreamOpen = false; - void ctx.params.onReasoningEnd?.(); + runBestEffortCallback({ + label: "reasoning end", + log: ctx.log, + callback: () => ctx.params.onReasoningEnd?.(), + }); +} + +function emitAssistantMessageStart(ctx: EmbeddedAgentSubscribeContext) { + runBestEffortCallback({ + label: "assistant message start", + log: ctx.log, + callback: () => ctx.params.onAssistantMessageStart?.(), + }); } function openReasoningStream(ctx: EmbeddedAgentSubscribeContext) { @@ -652,7 +665,7 @@ export function handleMessageStart( // re-trigger block replies. ctx.resetAssistantMessageState(ctx.state.assistantTexts.length); // Use assistant message_start as the earliest "writing" signal for typing. - void ctx.params.onAssistantMessageStart?.(); + emitAssistantMessageStart(ctx); } /** Handles assistant message deltas, reasoning, directives, and block replies. */ @@ -791,7 +804,7 @@ export function handleMessageUpdate( streamItemChanged = true; void ctx.flushBlockReplyBuffer({ assistantMessageIndex: ctx.state.assistantMessageIndex }); ctx.resetAssistantMessageState(ctx.state.assistantTexts.length); - void ctx.params.onAssistantMessageStart?.(); + emitAssistantMessageStart(ctx); } ctx.state.lastAssistantStreamItemId = streamItemId; } diff --git a/src/agents/embedded-agent-subscribe.handlers.tools.ts b/src/agents/embedded-agent-subscribe.handlers.tools.ts index 9c6a6a169213..1fc3b40c85f8 100644 --- a/src/agents/embedded-agent-subscribe.handlers.tools.ts +++ b/src/agents/embedded-agent-subscribe.handlers.tools.ts @@ -65,6 +65,7 @@ import type { ToolCallSummary, ToolHandlerContext, } from "./embedded-agent-subscribe.handlers.types.js"; +import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js"; import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { collectMessagingMediaUrlsFromRecord, @@ -317,35 +318,26 @@ function emitTrackedItemEvent(ctx: ToolHandlerContext, itemData: AgentItemEventD }); } -function warnBestEffortEventFailure(ctx: ToolHandlerContext, label: string, error: unknown): void { - ctx.log.warn(`${label} callback failed: ${String(error)}`); -} - function emitExecutionPhaseBestEffort( ctx: ToolHandlerContext, info: Parameters<NonNullable<ToolHandlerContext["params"]["onExecutionPhase"]>>[0], ): void { - try { - ctx.params.onExecutionPhase?.(info); - } catch (error) { - warnBestEffortEventFailure(ctx, "tool execution phase", error); - } + runBestEffortCallback({ + label: "tool execution phase", + log: ctx.log, + callback: () => ctx.params.onExecutionPhase?.(info), + }); } function emitAgentEventCallbackBestEffort( ctx: ToolHandlerContext, event: Parameters<NonNullable<ToolHandlerContext["params"]["onAgentEvent"]>>[0], ): void { - try { - const result = ctx.params.onAgentEvent?.(event); - if (isPromiseLike<void>(result)) { - void Promise.resolve(result).catch((error: unknown) => { - warnBestEffortEventFailure(ctx, "tool agent event", error); - }); - } - } catch (error) { - warnBestEffortEventFailure(ctx, "tool agent event", error); - } + runBestEffortCallback({ + label: "tool agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.(event), + }); } function applyCurrentMessageProvider( @@ -1482,7 +1474,11 @@ export async function handleToolExecutionEnd( const isFirstHeartbeatResponse = ctx.state.heartbeatToolResponse === undefined; ctx.state.heartbeatToolResponse = response; if (isFirstHeartbeatResponse) { - void ctx.params.onHeartbeatToolResponse?.(response); + runBestEffortCallback({ + label: "heartbeat tool response", + log: ctx.log, + callback: () => ctx.params.onHeartbeatToolResponse?.(response), + }); } } } diff --git a/src/agents/embedded-agent-subscribe.ts b/src/agents/embedded-agent-subscribe.ts index 72e9f2d7f01a..bcf286b07ef2 100644 --- a/src/agents/embedded-agent-subscribe.ts +++ b/src/agents/embedded-agent-subscribe.ts @@ -47,6 +47,7 @@ import type { EmbeddedAgentSubscribeContext, EmbeddedAgentSubscribeState, } from "./embedded-agent-subscribe.handlers.types.js"; +import { runBestEffortCallback } from "./embedded-agent-subscribe.callback.js"; import { isPromiseLike } from "./embedded-agent-subscribe.promise.js"; import { buildToolLifecycleErrorResult, @@ -279,12 +280,22 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess stream: "assistant", data, }); - void params.onAgentEvent?.({ - stream: "assistant", - data, - }); + if (params.onAgentEvent) { + runBestEffortCallback({ + label: "assistant agent event", + log, + callback: () => params.onAgentEvent?.({ + stream: "assistant", + data, + }), + }); + } if (delivery.emitPartialReply && params.onPartialReply && state.shouldEmitPartialReplies) { - void params.onPartialReply(data); + runBestEffortCallback({ + label: "assistant partial reply", + log, + callback: () => params.onPartialReply?.(data), + }); } }; const emitAssistantStreamData = ( @@ -729,15 +740,15 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess if (!parsed.text && filteredMediaUrls.length === 0) { return; } - try { - void params.onToolResult({ + runBestEffortCallback({ + label: "tool result", + log, + callback: () => params.onToolResult?.({ text: parsed.text, mediaUrls: filteredMediaUrls.length ? filteredMediaUrls : undefined, ...(mediaArtifact?.audioAsVoice ? { audioAsVoice: true } : {}), - }); - } catch { - // ignore tool result delivery failures - } + }), + }); }; const emitToolSummary = (toolName?: string, meta?: string) => { const agg = formatToolAggregate(toolName, meta ? [meta] : undefined, { @@ -1210,9 +1221,13 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess // render hook — uniformly, whether the thinking block rode in on a tool call // or arrived on its own. It still reaches the bus/archive above. if (state.streamReasoning && !hasMessageToolOnlySourceDelivery() && params.onReasoningStream) { - void params.onReasoningStream({ - text: trimmed, - ...(state.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }), + runBestEffortCallback({ + label: "reasoning stream", + log, + callback: () => params.onReasoningStream?.({ + text: trimmed, + ...(state.reasoningMode === "stream" ? {} : { requiresReasoningProgressOptIn: true }), + }), }); } }; diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index f762467914ea..f8a9f7d89550 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -109,6 +109,8 @@ type AgentHarnessRunCapability = { contextEngineHostCapabilities?: readonly import("../../context-engine/types.js").ContextEngineHostCapability[]; deliveryDefaults?: AgentHarnessDeliveryDefaults; supports(ctx: AgentHarnessSupportContext): AgentHarnessSupport; + /** Lets this harness resolve forwarded profiles or its own native credentials. */ + authBootstrap?: "harness"; runAttempt(params: AgentHarnessAttemptParams): Promise<AgentHarnessAttemptResult>; }; diff --git a/src/agents/openai-routing.test.ts b/src/agents/openai-routing.test.ts index 7d2aa341c0b9..0e255d47617a 100644 --- a/src/agents/openai-routing.test.ts +++ b/src/agents/openai-routing.test.ts @@ -45,6 +45,48 @@ describe("OpenAI runtime routing policy", () => { ).toBe("openai"); }); + it("honors explicit model runtime policy before the OpenAI base URL default", () => { + const customCodexConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "codex" } }, + }, + }, + }, + models: { + providers: { + openai: { + baseUrl: "https://example.test/v1", + models: [], + }, + }, + }, + } satisfies OpenClawConfig; + const officialOpenClawConfig = { + agents: { + defaults: { + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + }, + } satisfies OpenClawConfig; + + expect( + modelSelectionShouldEnsureCodexPlugin({ + model: "openai/gpt-5.5", + config: customCodexConfig, + }), + ).toBe(true); + expect( + modelSelectionShouldEnsureCodexPlugin({ + model: "openai/gpt-5.5", + config: officialOpenClawConfig, + }), + ).toBe(false); + }); + it("normalizes OpenAI provider keys before checking custom base URLs", () => { const config = { models: { diff --git a/src/agents/openai-routing.ts b/src/agents/openai-routing.ts index 74eba5ff7c07..1faec5fb38b8 100644 --- a/src/agents/openai-routing.ts +++ b/src/agents/openai-routing.ts @@ -5,6 +5,8 @@ */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isDefaultAgentRuntimeId, normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js"; +import { resolveModelRuntimePolicy } from "./model-runtime-policy.js"; /** Canonical provider id for OpenAI-hosted model routes. */ export const OPENAI_PROVIDER_ID = "openai"; @@ -82,9 +84,27 @@ export function parseModelRefProvider(value: unknown): string | undefined { export function modelSelectionShouldEnsureCodexPlugin(params: { model?: string; config?: OpenClawConfig; + agentId?: string; }): boolean { const provider = parseModelRefProvider(params.model); - return provider === OPENAI_PROVIDER_ID && !openAIProviderUsesCustomBaseUrl(params.config); + if (provider !== OPENAI_PROVIDER_ID) { + return false; + } + const modelRef = params.model?.trim(); + const slashIndex = modelRef?.indexOf("/") ?? -1; + const modelId = slashIndex >= 0 ? modelRef?.slice(slashIndex + 1) : undefined; + const configuredRuntime = normalizeOptionalAgentRuntimeId( + resolveModelRuntimePolicy({ + config: params.config, + provider, + modelId, + agentId: params.agentId, + }).policy?.id, + ); + if (configuredRuntime && !isDefaultAgentRuntimeId(configuredRuntime)) { + return configuredRuntime === "codex"; + } + return !openAIProviderUsesCustomBaseUrl(params.config); } /** Lists auth-profile providers for an OpenAI runtime route. */ diff --git a/src/agents/openai-transport-stream.test.ts b/src/agents/openai-transport-stream.test.ts index e45282908a11..eae0f14033d5 100644 --- a/src/agents/openai-transport-stream.test.ts +++ b/src/agents/openai-transport-stream.test.ts @@ -10294,7 +10294,7 @@ describe("openai transport stream", () => { expect(toolCalls).toHaveLength(1); }); - it("does not promote tool calls when provider omits final finish_reason", async () => { + it("promotes tool calls when stream completes cleanly without finish_reason", async () => { const model = { id: "qwen3.6-27b", name: "Qwen 3.6 27B", @@ -10324,7 +10324,157 @@ describe("openai transport stream", () => { tool_calls: [ { index: 0, - id: "call_unfinished", + id: "call_cleanstream", + function: { name: "bash", arguments: '{"cmd":"echo hi"}' }, + }, + ], + }, + logprobs: null, + finish_reason: null, + }, + ], + }, + ] as const; + + async function* mockStream() { + for (const chunk of mockChunks) { + yield chunk as never; + } + } + + await testing.processOpenAICompletionsStream(mockStream(), output, model, stream, { + sawStreamDONE: () => true, + }); + + expect(output.stopReason).toBe("toolUse"); + const toolCalls = output.content.filter( + (block) => (block as { type?: string }).type === "toolCall", + ); + expect(toolCalls).toHaveLength(1); + }); + + it.each([ + { chunks: ["data: [DO", "NE]\r\n\r\n"], expected: true }, + { chunks: ["data:[DONE]"], expected: true }, + { chunks: ['data: {"value":"[DONE]"}\n\n'], expected: false }, + { chunks: [`data: ${"x".repeat(1_024)}data: [DONE]\n\n`], expected: false }, + ])( + "detects only an exact bounded SSE terminal line: $chunks", + ({ chunks, expected }) => { + const detector = testing.createSseDoneDetector(); + const encoder = new TextEncoder(); + for (const chunk of chunks) { + detector.observe(encoder.encode(chunk)); + } + detector.finish(); + + expect(detector.sawDone()).toBe(expected); + }, + ); + + it("does not promote native tool calls when stream ends without [DONE] and without finish_reason", async () => { + const model = { + id: "qwen3.6-27b", + name: "Qwen 3.6 27B", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">; + + const output = createAssistantOutput(model); + const stream = { push: () => {} }; + + const mockChunks = [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk" as const, + created: 1775425651, + model: "qwen3.6-27b", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_nodone", + function: { name: "bash", arguments: '{"cmd":"echo hi"}' }, + }, + ], + }, + logprobs: null, + finish_reason: null, + }, + ], + }, + ] as const; + + async function* mockStream() { + for (const chunk of mockChunks) { + yield chunk as never; + } + } + + // sawStreamDONE defaults to false — connection drop without [DONE] + await testing.processOpenAICompletionsStream(mockStream(), output, model, stream); + + // EOF without [DONE] and without finish_reason → fail-closed + expect(output.stopReason).toBe("stop"); + expect( + output.content.filter((block) => (block as { type?: string }).type === "toolCall"), + ).toStrictEqual([]); + }); + + it("strips tool calls when stream has visible text and no finish_reason", async () => { + const model = { + id: "qwen3.6-27b", + name: "Qwen 3.6 27B", + api: "openai-completions", + provider: "vllm", + baseUrl: "http://localhost:8000/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">; + + const output = createAssistantOutput(model); + const stream = { push: () => {} }; + + const mockChunks = [ + { + id: "chatcmpl-test", + object: "chat.completion.chunk" as const, + created: 1775425651, + model: "qwen3.6-27b", + choices: [ + { + index: 0, + delta: { content: "Let me think about this." }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-test", + object: "chat.completion.chunk" as const, + created: 1775425651, + model: "qwen3.6-27b", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_with_text", function: { name: "bash", arguments: '{"cmd":"echo hi"}' }, }, ], @@ -10344,6 +10494,8 @@ describe("openai transport stream", () => { await testing.processOpenAICompletionsStream(mockStream(), output, model, stream); + // Visible text + tool calls without finish_reason is ambiguous; + // conservatively strip tool calls. expect(output.stopReason).toBe("stop"); expect( output.content.filter((block) => (block as { type?: string }).type === "toolCall"), @@ -10435,6 +10587,217 @@ describe("openai transport stream", () => { expect(output.content.some((block) => (block as { type?: string }).type === "text")).toBe(true); }); + it("promotes native tool calls through fetch wrapper when SSE terminates cleanly with [DONE] without finish_reason", async () => { + const server = createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + void body; + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const created = Math.floor(Date.now() / 1000); + // Emit a delta.tool_calls chunk with no finish_reason + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-loopback-done", + object: "chat.completion.chunk", + created, + model: "qwen3.6-27b", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_loopback_done", + function: { name: "bash", arguments: '{"cmd":"echo loopback"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + // Split CRLF-formatted terminal proof across chunks. The SDK accepts this + // framing, so the raw terminal observer must preserve the same contract. + res.write("data: [DO"); + res.write("NE]\r\n\r\n"); + res.end(); + }); + }); + + await new Promise<void>((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Missing loopback server address"); + } + const baseModel = { + id: "qwen3.6-27b", + name: "Qwen 3.6 27B", + api: "openai-completions", + provider: "vllm", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">; + const stream = createOpenAICompletionsTransportStreamFn()( + baseModel, + { + systemPrompt: "system", + messages: [{ role: "user", content: "Run a command", timestamp: Date.now() }], + tools: [], + } as never, + { apiKey: "test-key" } as never, + ); + + let doneReason: string | undefined; + let hasToolCallEvent = false; + const doneMessage: { content?: Array<{ type?: string }> } = {}; + for await (const event of stream as AsyncIterable<{ + type: string; + reason?: string; + message?: { content?: Array<{ type?: string }> }; + }>) { + if (event.type === "toolcall_start") { + hasToolCallEvent = true; + } + if (event.type === "done") { + doneReason = event.reason; + if (event.message) { + Object.assign(doneMessage, event.message); + } + } + } + + // fetch wrapper detected data: [DONE] → sawStreamDONE=true → promotion to toolUse + expect(doneReason).toBe("toolUse"); + expect(hasToolCallEvent).toBe(true); + // The output message should retain the toolCall blocks + const toolCallBlocks = + doneMessage.content?.filter((block) => block.type === "toolCall") ?? []; + expect(toolCallBlocks).toHaveLength(1); + } finally { + await new Promise<void>((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + + it("keeps tool calls fail-closed through fetch wrapper when stream ends without [DONE] and without finish_reason", async () => { + const server = createServer((req, res) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + void body; + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const created = Math.floor(Date.now() / 1000); + // Emit delta.tool_calls chunk with no finish_reason + res.write( + `data: ${JSON.stringify({ + id: "chatcmpl-loopback-nodone", + object: "chat.completion.chunk", + created, + model: "qwen3.6-27b", + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: "call_loopback_nodone", + function: { name: "bash", arguments: '{"cmd":"echo no done"}' }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + // Close WITHOUT data: [DONE] — simulates connection drop / truncated stream + res.end(); + }); + }); + + await new Promise<void>((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Missing loopback server address"); + } + const baseModel = { + id: "qwen3.6-27b", + name: "Qwen 3.6 27B", + api: "openai-completions", + provider: "vllm", + baseUrl: `http://127.0.0.1:${address.port}/v1`, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">; + const stream = createOpenAICompletionsTransportStreamFn()( + baseModel, + { + systemPrompt: "system", + messages: [{ role: "user", content: "Run a command", timestamp: Date.now() }], + tools: [], + } as never, + { apiKey: "test-key" } as never, + ); + + let doneReason: string | undefined; + const doneMessage: { content?: Array<{ type?: string }> } = {}; + for await (const event of stream as AsyncIterable<{ + type: string; + reason?: string; + message?: { content?: Array<{ type?: string }> }; + }>) { + if (event.type === "done") { + doneReason = event.reason; + if (event.message) { + Object.assign(doneMessage, event.message); + } + } + } + + // EOF without [DONE] → sawStreamDONE stays false → fail-closed + expect(doneReason).toBe("stop"); + const toolCallBlocks = + doneMessage.content?.filter((block) => block.type === "toolCall") ?? []; + expect(toolCallBlocks).toStrictEqual([]); + } finally { + await new Promise<void>((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it("keeps tool call blocks when provider signals finish_reason tool_calls", async () => { const model = { id: "llama-3.3-70b", diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 35969dcb1492..4e107446a63c 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -2655,11 +2655,63 @@ function assertOpenAICompletionsPayloadHasConversationTurn( ); } +const SSE_DONE_LINE_RE = /^data:[ \t]*\[DONE\][ \t]*$/i; +const SSE_DONE_MAX_LINE_CHARS = 1_024; + +function createSseDoneDetector() { + const decoder = new TextDecoder(); + let line = ""; + let lineOverflowed = false; + let sawDone = false; + + const finishLine = () => { + if (!lineOverflowed && SSE_DONE_LINE_RE.test(line)) { + sawDone = true; + } + line = ""; + lineOverflowed = false; + }; + const observeText = (text: string) => { + for (const char of text) { + if (char === "\n" || char === "\r") { + finishLine(); + continue; + } + if (!lineOverflowed && line.length < SSE_DONE_MAX_LINE_CHARS) { + line += char; + } else { + // Never let truncation turn a suffix of a large data line into a + // standalone terminal marker. + lineOverflowed = true; + } + } + }; + + return { + observe(chunk: Uint8Array) { + if (!sawDone) { + observeText(decoder.decode(chunk, { stream: true })); + } + }, + finish() { + if (sawDone) { + return; + } + observeText(decoder.decode()); + if (line || lineOverflowed) { + finishLine(); + } + }, + sawDone: () => sawDone, + }; +} + function createOpenAICompletionsClient( model: Model, context: Context, apiKey: string, optionHeaders?: Record<string, string>, + opts?: { fetch?: typeof globalThis.fetch }, ) { const clientConfig = buildOpenAICompletionsClientConfig(model, context, optionHeaders); return new OpenAI({ @@ -2668,7 +2720,7 @@ function createOpenAICompletionsClient( dangerouslyAllowBrowser: true, defaultHeaders: clientConfig.defaultHeaders, defaultQuery: clientConfig.defaultQuery, - fetch: buildGuardedModelFetch(model), + fetch: opts?.fetch ?? buildGuardedModelFetch(model), ...buildOpenAISdkClientOptions(model), }); } @@ -2769,7 +2821,38 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { let firstEventAbort: ReturnType<typeof createFirstStreamEventAbortController> | undefined; try { const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; - const client = createOpenAICompletionsClient(model, context, apiKey, options?.headers); + // The OpenAI SDK consumes the SSE terminal without yielding it. Observe + // the raw body so native tool calls can distinguish clean DONE from EOF. + const doneDetector = createSseDoneDetector(); + const baseFetch = buildGuardedModelFetch(model); + const doneDetectingFetch: typeof globalThis.fetch = async (url, init) => { + const response = await baseFetch(url as never, init); + if (!response.body || !response.ok) { + return response; + } + if (typeof TransformStream === "undefined" || !response.body.pipeThrough) { + return response; + } + const transformed = response.body.pipeThrough( + new TransformStream<Uint8Array, Uint8Array>({ + transform(chunk, controller) { + doneDetector.observe(chunk); + controller.enqueue(chunk); + }, + flush() { + doneDetector.finish(); + }, + }), + ); + return new Response(transformed, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + }; + const client = createOpenAICompletionsClient(model, context, apiKey, options?.headers, { + fetch: doneDetectingFetch, + }); let params = buildOpenAICompletionsParams( model as OpenAIModeModel, context, @@ -2806,6 +2889,7 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn { firstEventTimeoutMs: getFirstStreamEventTimeoutMs(options), abortFirstEventStream: firstEventAbort.abort, onFirstEventTimeout: getFirstStreamEventTimeoutHandler(options), + sawStreamDONE: doneDetector.sawDone, }); finalizeTransportStream({ stream, output, signal: options?.signal }); } catch (error) { @@ -2829,6 +2913,7 @@ async function processOpenAICompletionsStream( firstEventTimeoutMs?: number; abortFirstEventStream?: (reason: Error) => void; onFirstEventTimeout?: (reason: Error) => void; + sawStreamDONE?: () => boolean; }, ) { const MAX_POST_TOOL_CALL_BUFFER_BYTES = 256_000; @@ -2864,6 +2949,7 @@ async function processOpenAICompletionsStream( const toolCallBlockBytes = new WeakMap<ToolCallBlock, number>(); const toolCallBlockIndices = new WeakMap<ToolCallBlock, number>(); let sawStopFinishReason = false; + let sawNativeToolCallDelta = false; const blockIndex = () => output.content.length - 1; const measureUtf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); let chunkPushedEvent = false; @@ -3193,6 +3279,7 @@ async function processOpenAICompletionsStream( } } if (choiceDelta.tool_calls && choiceDelta.tool_calls.length > 0) { + sawNativeToolCallDelta = true; flushReasoningTagTextPartitionerAtEnd(); for (const toolCall of choiceDelta.tool_calls) { const streamIndex = typeof toolCall.index === "number" ? toolCall.index : undefined; @@ -3275,9 +3362,20 @@ async function processOpenAICompletionsStream( if (output.stopReason === "toolUse" && !hasToolCalls) { output.stopReason = "stop"; } - // Tool-call recovery is executable only after an explicit provider terminal. - // EOF alone can mean transport truncation, even when the recovered call parses. - if (sawStopFinishReason && output.stopReason === "stop" && hasToolCalls && !hasVisibleText) { + // Promote complete silent tool-call-only responses when the stream finished + // cleanly (reached post-loop). Two paths: + // sawStopFinishReason: explicit provider terminal (legacy DSML / #88791) + // sawNativeToolCallDelta + sawStreamDONE: structured delta.tool_calls with + // a clean SSE [DONE] terminal but no finish_reason (e.g. Evolink + // DeepSeek V4). [DONE] tracking distinguishes clean termination from + // connection drops (EOF without [DONE] remains fail-closed). + // Truncated streams throw before reaching this code. + if ( + output.stopReason === "stop" && + hasToolCalls && + !hasVisibleText && + (sawStopFinishReason || (sawNativeToolCallDelta && (options?.sawStreamDONE?.() ?? false))) + ) { output.stopReason = "toolUse"; } if (hasToolCalls && output.stopReason !== "toolUse") { @@ -4523,6 +4621,7 @@ export const testing = { buildOpenAISdkClientOptions, buildOpenAISdkRequestOptions, createAzureOpenAIClient, + createSseDoneDetector, createOpenAICompletionsClient, createOpenAIResponsesClient, enforceCodeModeResponsesToolSurface, diff --git a/src/agents/system-prompt.memory.test.ts b/src/agents/system-prompt.memory.test.ts index 9ad6d0f1a8df..6c4a07a4ce27 100644 --- a/src/agents/system-prompt.memory.test.ts +++ b/src/agents/system-prompt.memory.test.ts @@ -23,4 +23,37 @@ describe("buildAgentSystemPrompt memory guidance", () => { expect(promptWithMemory).toContain("## Memory Recall"); expect(promptWithoutMemory).not.toContain("## Memory Recall"); }); + + it("passes the active agent context to memory prompt assembly", () => { + let observedContext: + | { agentId?: string; agentSessionKey?: string; sandboxed?: boolean } + | undefined; + registerMemoryPromptSection((context) => { + observedContext = context; + return [ + "## Agent Memory", + `agent=${context.agentId} session=${context.agentSessionKey} sandboxed=${context.sandboxed}`, + "", + ]; + }); + + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + toolNames: ["memory_search", "memory_get"], + runtimeInfo: { + agentId: "marketing-agent", + sessionKey: "agent:marketing-agent:main", + }, + sandboxInfo: { enabled: true }, + }); + + expect(observedContext).toMatchObject({ + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }); + expect(prompt).toContain( + "agent=marketing-agent session=agent:marketing-agent:main sandboxed=true", + ); + }); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 75b93ef689fa..7f0b2f4acbc9 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -289,6 +289,9 @@ function buildMemorySection(params: { includeMemorySection?: boolean; availableTools: Set<string>; citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; }) { if (params.isMinimal || params.includeMemorySection === false) { return []; @@ -296,6 +299,9 @@ function buildMemorySection(params: { return buildMemoryPromptSection({ availableTools: params.availableTools, citationsMode: params.citationsMode, + agentId: params.agentId, + agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, }); } @@ -975,6 +981,9 @@ export function buildAgentSystemPrompt(params: { includeMemorySection: params.includeMemorySection, availableTools, citationsMode: params.memoryCitationsMode, + agentId: params.runtimeInfo?.agentId, + agentSessionKey: params.runtimeInfo?.sessionKey, + sandboxed: params.sandboxInfo?.enabled === true, }); const docsSection = buildDocsSection({ docsPath: params.docsPath, diff --git a/src/agents/tools/media-generate-tool-actions-shared.ts b/src/agents/tools/media-generate-tool-actions-shared.ts index 429a9ad5cc4c..75172a31a2e2 100644 --- a/src/agents/tools/media-generate-tool-actions-shared.ts +++ b/src/agents/tools/media-generate-tool-actions-shared.ts @@ -26,6 +26,7 @@ type MediaGenerateProvider = { defaultModel?: string; models?: readonly string[]; capabilities: unknown; + catalogByModel?: Readonly<Record<string, { capabilities?: unknown; modes?: readonly string[] }>>; isConfigured?: (ctx: { cfg?: OpenClawConfig; agentDir?: string }) => boolean; }; @@ -41,6 +42,11 @@ type MediaGenerateListProviderDetails<TProvider extends MediaGenerateProvider> = catalog: ReturnType<typeof synthesizeMediaGenerationCatalogEntries<TProvider["capabilities"]>>; }; +type MediaGenerateCapabilitySummaryOptions = { + modes?: readonly string[]; + includeModes?: boolean; +}; + /** Common tool result shape for media generation list/status actions. */ export type { MediaGenerateActionResult }; @@ -56,7 +62,10 @@ export function createMediaGenerateProviderListActionResult< agentDir?: string; authStore?: AuthProfileStore; listModes: (provider: TProvider) => string[]; - summarizeCapabilities: (provider: TProvider) => string; + summarizeCapabilities: ( + provider: TProvider, + options?: MediaGenerateCapabilitySummaryOptions, + ) => string; formatAuthHint?: (provider: { id: string; authEnvVars: readonly string[] }) => string | undefined; }): MediaGenerateActionResult { if (params.providers.length === 0) { @@ -104,6 +113,22 @@ export function createMediaGenerateProviderListActionResult< const authHint = params.formatAuthHint?.({ id: details.id, authEnvVars: authHints }) ?? (authHints.length > 0 ? `set ${authHints.join(" / ")} to use ${details.id}/*` : undefined); + const modelCapabilityLines = details.catalog.flatMap((entry) => { + if (!provider.catalogByModel?.[entry.model]) { + return []; + } + const modelProvider = { + ...provider, + capabilities: entry.capabilities ?? provider.capabilities, + } as TProvider; + const modelCapabilities = params.summarizeCapabilities(modelProvider, { + modes: entry.modes, + includeModes: false, + }); + const modelModes = entry.modes?.length ? `modes=${entry.modes.join("/")}` : undefined; + const modelSummary = [modelModes, modelCapabilities || undefined].filter(Boolean).join(", "); + return [` model ${entry.model}: ${modelSummary || "no capabilities declared"}`]; + }); return [ `${details.id}${details.defaultModel ? ` (default ${details.defaultModel})` : ""}`, ` models: ${modelLine}`, @@ -111,6 +136,7 @@ export function createMediaGenerateProviderListActionResult< ...(authHint ? [` auth: ${authHint}`] : []), " source: static", ...(capabilities ? [` capabilities: ${capabilities}`] : []), + ...modelCapabilityLines, ]; }); diff --git a/src/agents/tools/video-generate-tool.actions.ts b/src/agents/tools/video-generate-tool.actions.ts index 765768b24e69..d449a4cb5405 100644 --- a/src/agents/tools/video-generate-tool.actions.ts +++ b/src/agents/tools/video-generate-tool.actions.ts @@ -24,11 +24,26 @@ type VideoGenerateActionResult = MediaGenerateActionResult; function summarizeVideoGenerationCapabilities( provider: ReturnType<typeof listRuntimeVideoGenerationProviders>[number], + options?: { modes?: readonly string[]; includeModes?: boolean }, ): string { - const supportedModes = listSupportedVideoGenerationModes(provider); + const supportedModes = options?.modes ?? listSupportedVideoGenerationModes(provider); const generate = provider.capabilities.generate; const imageToVideo = provider.capabilities.imageToVideo; const videoToVideo = provider.capabilities.videoToVideo; + const activeModeCapabilities = [ + supportedModes.includes("generate") ? generate : undefined, + supportedModes.includes("imageToVideo") && imageToVideo?.enabled ? imageToVideo : undefined, + supportedModes.includes("videoToVideo") && videoToVideo?.enabled ? videoToVideo : undefined, + ].filter((capabilities) => capabilities !== undefined); + const maxDurationSeconds = activeModeCapabilities + .map((capabilities) => capabilities.maxDurationSeconds) + .find((value) => typeof value === "number"); + const supportedDurationSeconds = activeModeCapabilities + .map((capabilities) => capabilities.supportedDurationSeconds) + .find((value) => value && value.length > 0); + const supportedDurationSecondsByModel = activeModeCapabilities + .map((capabilities) => capabilities.supportedDurationSecondsByModel) + .find((value) => value && Object.keys(value).length > 0); // providerOptions may be declared at the mode level (generate) or at the flat // provider-capabilities level. The runtime checks both; surface the union so // the agent sees a single merged view of which opaque keys each provider @@ -52,28 +67,39 @@ function summarizeVideoGenerationCapabilities( videoToVideo?.maxInputAudios ?? provider.capabilities.maxInputAudios; const capabilities = [ - supportedModes.length > 0 ? `modes=${supportedModes.join("/")}` : null, + options?.includeModes !== false && supportedModes.length > 0 + ? `modes=${supportedModes.join("/")}` + : null, generate?.maxVideos ? `maxVideos=${generate.maxVideos}` : null, imageToVideo?.maxInputImages ? `maxInputImages=${imageToVideo.maxInputImages}` : null, videoToVideo?.maxInputVideos ? `maxInputVideos=${videoToVideo.maxInputVideos}` : null, typeof maxInputAudios === "number" && maxInputAudios > 0 ? `maxInputAudios=${maxInputAudios}` : null, - generate?.maxDurationSeconds ? `maxDurationSeconds=${generate.maxDurationSeconds}` : null, - generate?.supportedDurationSeconds?.length - ? `supportedDurationSeconds=${generate.supportedDurationSeconds.join("/")}` + maxDurationSeconds ? `maxDurationSeconds=${maxDurationSeconds}` : null, + supportedDurationSeconds + ? `supportedDurationSeconds=${supportedDurationSeconds.join("/")}` : null, - generate?.supportedDurationSecondsByModel && - Object.keys(generate.supportedDurationSecondsByModel).length > 0 - ? `supportedDurationSecondsByModel=${Object.entries(generate.supportedDurationSecondsByModel) + supportedDurationSecondsByModel + ? `supportedDurationSecondsByModel=${Object.entries(supportedDurationSecondsByModel) .map(([modelId, durations]) => `${modelId}:${durations.join("/")}`) .join("; ")}` : null, - generate?.supportsResolution ? "resolution" : null, - generate?.supportsAspectRatio ? "aspectRatio" : null, - generate?.supportsSize ? "size" : null, - generate?.supportsAudio ? "audio" : null, - generate?.supportsWatermark ? "watermark" : null, + activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsResolution) + ? "resolution" + : null, + activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsAspectRatio) + ? "aspectRatio" + : null, + activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsSize) + ? "size" + : null, + activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsAudio) + ? "audio" + : null, + activeModeCapabilities.some((modeCapabilities) => modeCapabilities.supportsWatermark) + ? "watermark" + : null, Object.keys(declaredProviderOptions).length > 0 ? `providerOptions={${Object.entries(declaredProviderOptions) .map(([key, type]) => `${key}:${type}`) diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index d66888ffc013..4442e1a79b56 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -1295,6 +1295,73 @@ describe("createVideoGenerateTool", () => { expect(providers[0]?.modes).toEqual(["generate", "imageToVideo"]); }); + it("lists model-specific catalog capabilities and modes", async () => { + const imageToVideoCapabilities = { + imageToVideo: { + enabled: true, + maxInputImages: 1, + maxDurationSeconds: 15, + resolutions: ["480P", "720P", "1080P"] as const, + aspectRatios: ["16:9", "9:16"] as const, + supportsResolution: true, + supportsAspectRatio: true, + }, + }; + vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([ + { + id: "video-plugin", + defaultModel: "text-video", + models: ["text-video", "image-video"], + capabilities: { + generate: { + maxDurationSeconds: 10, + }, + }, + catalogByModel: { + "image-video": { + capabilities: imageToVideoCapabilities, + modes: ["imageToVideo"], + }, + }, + generateVideo: vi.fn(async () => { + throw new Error("not used"); + }), + }, + ]); + + const tool = createVideoGenerateTool({ + config: asConfig({ + agents: { + defaults: { + videoGenerationModel: { primary: "video-plugin/text-video" }, + }, + }, + }), + }); + if (!tool) { + throw new Error("expected video_generate tool"); + } + + const result = await tool.execute("call-1", { action: "list" }); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + expect(text).toContain( + "model image-video: modes=imageToVideo, maxInputImages=1, maxDurationSeconds=15, resolution, aspectRatio", + ); + const providers = resultDetails(result).providers as Array<{ + catalog?: Array<{ + model?: string; + capabilities?: unknown; + modes?: string[]; + }>; + }>; + const catalogEntry = providers[0]?.catalog?.find((entry) => entry.model === "image-video"); + expect(catalogEntry).toMatchObject({ + model: "image-video", + capabilities: imageToVideoCapabilities, + modes: ["imageToVideo"], + }); + }); + it("rejects image-to-video when the provider disables that mode", async () => { vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([ { diff --git a/src/cli/program/config-guard.test.ts b/src/cli/program/config-guard.test.ts index 837ddb949587..6fe558955828 100644 --- a/src/cli/program/config-guard.test.ts +++ b/src/cli/program/config-guard.test.ts @@ -182,10 +182,17 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, }); } }); + it("keeps status config guard reads non-observing", async () => { + await runEnsureConfigReady(["status"]); + + expect(readConfigFileSnapshotMock).toHaveBeenCalledWith({ observe: false }); + }); + it("runs doctor flow when lightweight startup detection finds legacy state", async () => { const root = useTempOpenClawHome(); writeLegacyTaskSidecarMarker(root); @@ -196,6 +203,8 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + observe: false, + crossStateDirImports: false, }); }); @@ -209,6 +218,8 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + observe: false, + crossStateDirImports: false, }); }); @@ -219,6 +230,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, requireStartupMigrationCheckpoint: true, }); }); @@ -251,6 +263,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, }); }); @@ -274,6 +287,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, }); }); @@ -298,6 +312,7 @@ describe("ensureConfigReady", () => { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + crossStateDirImports: false, }); expect(setRuntimeConfigSnapshotMock).toHaveBeenCalledWith( migratedSnapshot.runtimeConfig, @@ -314,18 +329,36 @@ describe("ensureConfigReady", () => { expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce(); }); - it("does not run doctor flow for default-state-dir exec approvals when a custom state dir is set", async () => { - // Cross-state-dir imports are doctor-owned; the implicit preflight must not - // trigger (and must never archive) files that belong to the default dir. - const root = useTempOpenClawHome(); - const stateDir = path.join(root, "custom-state"); - setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - writeStateMarker(root, "exec-approvals.json"); + it.each([ + { commandPath: ["agent"], source: "exec-approvals.json" }, + { commandPath: ["status"], source: "plugin-binding-approvals.json" }, + { commandPath: ["plugins", "list"], source: "exec-approvals.json" }, + { commandPath: ["tasks", "list"], source: "plugin-binding-approvals.json" }, + ])( + "runs notice-only preflight for $commandPath with default-state $source", + async ({ commandPath, source }) => { + const root = useTempOpenClawHome(); + const stateDir = path.join(root, "custom-state"); + setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); + writeStateMarker(root, source); + const sourcePath = path.join(root, ".openclaw", source); + const sourceRaw = fs.readFileSync(sourcePath, "utf8"); - await runEnsureConfigReady(["agent"]); + await runEnsureConfigReady(commandPath); - expect(loadAndMaybeMigrateDoctorConfigMock).not.toHaveBeenCalled(); - }); + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledOnce(); + expect(loadAndMaybeMigrateDoctorConfigMock).toHaveBeenCalledWith({ + migrateState: true, + migrateLegacyConfig: false, + invalidConfigNote: false, + ...(commandPath[0] === "status" ? { observe: false } : {}), + crossStateDirImports: false, + }); + expect(fs.readFileSync(sourcePath, "utf8")).toBe(sourceRaw); + expect(fs.existsSync(`${sourcePath}.migrated`)).toBe(false); + expect(fs.existsSync(path.join(stateDir, "exec-approvals.json"))).toBe(false); + }, + ); it.each([ ["Discord model picker preferences", "discord/model-picker-preferences.json"], diff --git a/src/cli/program/config-guard.ts b/src/cli/program/config-guard.ts index a3f7617d0066..6eda8edc4045 100644 --- a/src/cli/program/config-guard.ts +++ b/src/cli/program/config-guard.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { withSuppressedNotes } from "../../../packages/terminal-core/src/note.js"; import { readConfigFileSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js"; -import { resolveLegacyStateDirs, resolveOAuthDir, resolveStateDir } from "../../config/paths.js"; +import { + resolveLegacyStateDirs, + resolveNewStateDir, + resolveOAuthDir, + resolveStateDir, +} from "../../config/paths.js"; import type { ConfigFileSnapshot } from "../../config/types.js"; import { resolveRequiredHomeDir } from "../../infra/home-dir.js"; import { ExitError, type RuntimeEnv } from "../../runtime.js"; @@ -98,6 +103,23 @@ function hasBundledChannelLegacyStateMigrationInputs(stateDir: string, oauthDir: return dirHasFile(oauthDir, isLegacyWhatsAppAuthFile); } +function hasCrossStateDirApprovalMigrationInputs(stateDir: string): boolean { + if (!process.env.OPENCLAW_STATE_DIR?.trim()) { + return false; + } + const homeDir = resolveRequiredHomeDir(process.env, os.homedir); + const defaultStateDir = resolveNewStateDir(() => homeDir); + if (path.resolve(defaultStateDir) === path.resolve(stateDir)) { + return false; + } + const execApprovalsSource = path.join(defaultStateDir, "exec-approvals.json"); + const execApprovalsTarget = path.join(stateDir, "exec-approvals.json"); + return ( + (fileOrDirExists(execApprovalsSource) && !fileOrDirExists(execApprovalsTarget)) || + fileOrDirExists(path.join(defaultStateDir, "plugin-binding-approvals.json")) + ); +} + function hasPendingSqliteSidecarArchive(sourcePath: string): boolean { return ( fileOrDirExists(`${sourcePath}.migrated`) && @@ -133,7 +155,8 @@ function hasLegacyStateMigrationInputs(): boolean { sqliteSidecarPaths.some( (sourcePath) => fileOrDirExists(sourcePath) || hasPendingSqliteSidecarArchive(sourcePath), ) || - hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) + hasBundledChannelLegacyStateMigrationInputs(stateDir, oauthDir) || + hasCrossStateDirApprovalMigrationInputs(stateDir) ); } @@ -168,7 +191,10 @@ function shouldRequireStartupMigrationCheckpoint(commandPath: string[]): boolean ); } -async function getConfigSnapshot() { +async function getConfigSnapshot(options?: { observe: false }) { + if (options?.observe === false) { + return readConfigFileSnapshot(options); + } // Tests often mutate config fixtures; caching can make those flaky. if (process.env.VITEST === "true") { return readConfigFileSnapshot(); @@ -193,6 +219,8 @@ export async function ensureConfigReady(params: { beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>; }): Promise<void> { const commandPath = params.commandPath ?? []; + const commandName = commandPath[0]; + const subcommandName = commandPath[1]; let preflightSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>> | null = null; const shouldConsiderStateMigration = shouldMigrateStateFromPath(commandPath); const requiresLegacyStateInput = shouldRunStateMigrationOnlyWithLegacyInputs(commandPath); @@ -203,6 +231,8 @@ export async function ensureConfigReady(params: { migrateState: true, migrateLegacyConfig: false, invalidConfigNote: false, + ...(commandName === "status" ? { observe: false } : {}), + crossStateDirImports: false, ...(shouldRequireStartupMigrationCheckpoint(commandPath) ? { requireStartupMigrationCheckpoint: true } : {}), @@ -230,7 +260,11 @@ export async function ensureConfigReady(params: { preflightSnapshot = await runStateMigrationPreflight(); } - let snapshot = preflightSnapshot ?? (await getConfigSnapshot()); + // Status performs a second non-observing read for its materialized/source pair; + // keep the startup guard from recording config health before the command begins. + const configSnapshotOptions = + commandName === "status" ? ({ observe: false } as const) : undefined; + let snapshot = preflightSnapshot ?? (await getConfigSnapshot(configSnapshotOptions)); if ( !preflightSnapshot && !didRunDoctorConfigFlow && @@ -242,8 +276,6 @@ export async function ensureConfigReady(params: { preflightSnapshot = await runStateMigrationPreflight(); snapshot = preflightSnapshot; } - const commandName = commandPath[0]; - const subcommandName = commandPath[1]; const isBareGatewayForegroundRun = commandName === "gateway" && (subcommandName === undefined || subcommandName.trim() === ""); const isReadOnlyTaskStateCommand = diff --git a/src/cli/program/register.maintenance.test.ts b/src/cli/program/register.maintenance.test.ts index d6c3d46325b9..0cddcb999e5d 100644 --- a/src/cli/program/register.maintenance.test.ts +++ b/src/cli/program/register.maintenance.test.ts @@ -1,6 +1,7 @@ // Register maintenance tests cover maintenance command registration in the CLI program. import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js"; import { registerMaintenanceCommands } from "./register.maintenance.js"; const mocks = vi.hoisted(() => ({ @@ -68,6 +69,10 @@ describe("registerMaintenanceCommands doctor action", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + it("exits with code 0 after successful doctor run", async () => { doctorCommand.mockResolvedValue(undefined); @@ -101,6 +106,28 @@ describe("registerMaintenanceCommands doctor action", () => { const [runtimeArg, options] = commandCall(doctorCommand); expect(runtimeArg).toBe(runtime); expect(options.repair).toBe(true); + expect(options.crossStateDirImports).toBe(true); + }); + + it("denies cross-state imports when an automation parent disables them", async () => { + doctorCommand.mockResolvedValue(undefined); + vi.stubEnv(DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV, "1"); + + await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]); + + const [, options] = commandCall(doctorCommand); + expect(options.repair).toBe(true); + expect(options.crossStateDirImports).toBe(false); + }); + + it("denies cross-state imports for older update parents", async () => { + doctorCommand.mockResolvedValue(undefined); + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1"); + + await runMaintenanceCli(["doctor", "--fix", "--non-interactive"]); + + const [, options] = commandCall(doctorCommand); + expect(options.crossStateDirImports).toBe(false); }); it("maps --acknowledge-non-clawhub-install to the doctor acknowledgement option", async () => { diff --git a/src/cli/program/register.maintenance.ts b/src/cli/program/register.maintenance.ts index 12673671dc9a..77a9c0483106 100644 --- a/src/cli/program/register.maintenance.ts +++ b/src/cli/program/register.maintenance.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; +import { resolveDoctorCrossStateDirImports } from "../../commands/doctor-invocation.js"; import { defaultRuntime } from "../../runtime.js"; import { runCommandWithRuntime } from "../cli-utils.js"; @@ -104,6 +105,7 @@ export function registerMaintenanceCommands(program: Command) { deep: Boolean(opts.deep), postUpgrade: Boolean(opts.postUpgrade), json: Boolean(opts.json), + crossStateDirImports: resolveDoctorCrossStateDirImports(), }); defaultRuntime.exit(0); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a34daef91f87..d451dd35d2aa 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -7406,6 +7406,7 @@ describe("update-cli", () => { nonInteractive: true, repair: true, yes: true, + crossStateDirImports: false, }); expect(syncPluginCall()?.channel).toBe("stable"); expect(syncPluginCall()?.acknowledgeClawHubRisk).toBe(true); @@ -7505,6 +7506,7 @@ describe("update-cli", () => { nonInteractive: true, repair: true, yes: false, + crossStateDirImports: false, }); expect(syncPluginCall()?.channel).toBe("beta"); expect(syncPluginCall()?.config).toEqual({ diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 5568535bd784..6d58a71f7435 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js"; import { buildGatewayInstallEntrypointCandidates as resolveGatewayInstallEntrypointCandidates, resolveGatewayInstallEntrypoint, @@ -216,6 +217,7 @@ describe("resolvePostInstallDoctorEnv", () => { expect(env.PATH).toBe("/bin"); expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1"); + expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1"); expect(env.OPENCLAW_STATE_DIR).toBe(path.join("/srv/openclaw", "daemon-state")); expect(env.OPENCLAW_CONFIG_PATH).toBe( path.join("/srv/openclaw", "daemon-state", "openclaw.json"), @@ -234,6 +236,7 @@ describe("resolvePostInstallDoctorEnv", () => { expect(env.PATH).toBe("/bin"); expect(env.NODE_DISABLE_COMPILE_CACHE).toBe("1"); + expect(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]).toBe("1"); expect(env.OPENCLAW_STATE_DIR).toBe("/caller/state"); expect(env.OPENCLAW_PROFILE).toBe("caller"); }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 1d27cd7b34ec..8fbbab0c375b 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -16,6 +16,7 @@ import { checkShellCompletionStatus, ensureCompletionCacheExists, } from "../../commands/doctor-completion.js"; +import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../../commands/doctor-invocation.js"; import { doctorCommand } from "../../commands/doctor.js"; import { UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, @@ -1494,7 +1495,10 @@ export function resolvePostInstallDoctorEnv(params?: { serviceEnv?: NodeJS.ProcessEnv; invocationCwd?: string; }): NodeJS.ProcessEnv { - const resolvedEnv = disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env); + const resolvedEnv: NodeJS.ProcessEnv = { + ...disableUpdatedPackageCompileCacheEnv(params?.baseEnv ?? process.env), + [DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1", + }; if (!params?.serviceEnv) { return resolvedEnv; } @@ -2803,6 +2807,7 @@ async function maybeRestartService(params: { process.stdin.isTTY && !params.opts.json && params.opts.yes !== true; await doctorCommand(defaultRuntime, { nonInteractive: !interactiveDoctor, + crossStateDirImports: false, }); } catch (err) { defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`)); @@ -2989,6 +2994,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis nonInteractive: true, repair: true, yes: opts.yes === true, + crossStateDirImports: false, }); configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); if (requestedChannel) { @@ -3483,6 +3489,7 @@ async function continuePostCoreUpdateInFreshProcess(params: { env: { ...stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), OPENCLAW_UPDATE_IN_PROGRESS: "1", + [DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1", [POST_CORE_UPDATE_ENV]: "1", [POST_CORE_UPDATE_CHANNEL_ENV]: params.channel, ...(params.requestedChannel diff --git a/src/commands/codex-runtime-plugin-install.test.ts b/src/commands/codex-runtime-plugin-install.test.ts index 0f05e0603ba8..05549a49a731 100644 --- a/src/commands/codex-runtime-plugin-install.test.ts +++ b/src/commands/codex-runtime-plugin-install.test.ts @@ -1,6 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; const mocks = vi.hoisted(() => ({ + loadInstalledPluginIndexInstallRecords: vi.fn(), repairMissingPluginInstallsForIds: vi.fn(), })); @@ -26,9 +28,14 @@ vi.mock("./doctor/shared/missing-configured-plugin-install.js", () => ({ repairMissingPluginInstallsForIds: mocks.repairMissingPluginInstallsForIds, })); +vi.mock("../plugins/installed-plugin-index-records.js", () => ({ + loadInstalledPluginIndexInstallRecords: mocks.loadInstalledPluginIndexInstallRecords, +})); + describe("Codex runtime plugin install repair", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({}); mocks.repairMissingPluginInstallsForIds.mockResolvedValue({ changes: [], warnings: [], @@ -61,4 +68,98 @@ describe("Codex runtime plugin install repair", () => { warnings: [reviewNotice], }); }); + + it.each([ + ["plugins disabled", { plugins: { enabled: false } }], + ["denylisted", { plugins: { deny: ["codex"] } }], + ["not allowlisted", { plugins: { allow: ["other"] } }], + ])("does not report an existing Codex install as usable when %s", async (_label, cfg) => { + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({ + codex: { source: "npm", installPath: process.cwd() }, + }); + const { ensureCodexRuntimePluginForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + + const result = await ensureCodexRuntimePluginForModelSelection({ + cfg, + model: "openai/gpt-5.5", + prompter: {} as never, + runtime: {} as never, + }); + + expect(result).toMatchObject({ + cfg, + required: true, + installed: false, + status: "failed", + }); + expect(result.reason).toBeTruthy(); + }); + + it("enables an allowed existing Codex install", async () => { + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({ + codex: { source: "npm", installPath: process.cwd() }, + }); + const cfg: OpenClawConfig = { + plugins: { + allow: ["codex"], + entries: { codex: { enabled: false } }, + }, + }; + const { ensureCodexRuntimePluginForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + + const result = await ensureCodexRuntimePluginForModelSelection({ + cfg, + model: "openai/gpt-5.5", + prompter: {} as never, + runtime: {} as never, + }); + + expect(result).toMatchObject({ + required: true, + installed: true, + status: "installed", + cfg: { plugins: { entries: { codex: { enabled: true } } } }, + }); + }); + + it("sees an agent-scoped Codex runtime pin behind a custom OpenAI route", async () => { + mocks.loadInstalledPluginIndexInstallRecords.mockResolvedValue({ + codex: { source: "npm", installPath: process.cwd() }, + }); + const cfg = { + agents: { + list: [ + { + id: "ops", + default: true, + model: { primary: "openai/gpt-5.5" }, + models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, + }, + ], + }, + models: { + providers: { + openai: { baseUrl: "https://proxy.example.test/v1", models: [] }, + }, + }, + }; + const { ensureCodexRuntimePluginForModelSelection } = + await import("./codex-runtime-plugin-install.js"); + + const result = await ensureCodexRuntimePluginForModelSelection({ + cfg, + model: "openai/gpt-5.5", + agentId: "ops", + prompter: {} as never, + runtime: {} as never, + }); + + expect(result).toMatchObject({ + required: true, + installed: true, + status: "installed", + }); + }); }); diff --git a/src/commands/codex-runtime-plugin-install.ts b/src/commands/codex-runtime-plugin-install.ts index e873b0625f41..6d7d144d110d 100644 --- a/src/commands/codex-runtime-plugin-install.ts +++ b/src/commands/codex-runtime-plugin-install.ts @@ -14,10 +14,11 @@ const CODEX_RUNTIME_PLUGIN_DESCRIPTOR = { const codexRuntimePluginInstall = createRuntimePluginModelSelectionHelpers({ descriptor: CODEX_RUNTIME_PLUGIN_DESCRIPTOR, - shouldEnsure: ({ cfg, model }) => + shouldEnsure: ({ cfg, model, agentId }) => modelSelectionShouldEnsureCodexPlugin({ config: cfg, model, + agentId, }), }); diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index c8e47406583c..809e2b99d396 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -14,6 +14,7 @@ type TerminalNote = (message: string, title?: string) => void; const terminalNoteMock = vi.hoisted(() => vi.fn<TerminalNote>()); const callGatewayMock = vi.hoisted(() => vi.fn()); const runDoctorRepairSequenceMock = vi.hoisted(() => vi.fn()); +const runDoctorConfigPreflightOptionsMock = vi.hoisted(() => vi.fn()); const collectDoctorPreviewNotesParamsMock = vi.hoisted(() => vi.fn()); const collectImplicitFallbackClobberWarningsMock = vi.hoisted(() => vi.fn<(cfg: unknown) => string[]>(() => []), @@ -1301,7 +1302,8 @@ vi.mock("./doctor-config-preflight.js", async () => { } return { - runDoctorConfigPreflight: vi.fn(async () => { + runDoctorConfigPreflight: vi.fn(async (options: unknown) => { + runDoctorConfigPreflightOptionsMock(options); const injected = getDoctorConfigInputForTest(); const configPath = injected?.path ?? resolveConfigPath(); let parsed: Record<string, unknown> = injected?.config @@ -1530,6 +1532,31 @@ describe("doctor config flow", () => { collectImplicitFallbackClobberWarningsMock.mockClear(); collectImplicitFallbackClobberWarningsMock.mockReturnValue([]); noteImplicitFallbackClobberWarningsMock.mockClear(); + runDoctorConfigPreflightOptionsMock.mockClear(); + }); + + it("grants config preflight cross-state imports only with repair and direct capability", async () => { + await runDoctorConfigWithInput({ + config: {}, + repair: true, + run: ({ options, confirm }) => + loadAndMaybeMigrateDoctorConfig({ + options: { ...options, crossStateDirImports: true }, + confirm: async () => confirm(), + }), + }); + expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ crossStateDirImports: true }), + ); + + await runDoctorConfigWithInput({ + config: {}, + repair: true, + run: loadAndMaybeMigrateDoctorConfig, + }); + expect(runDoctorConfigPreflightOptionsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ crossStateDirImports: false }), + ); }); it("preserves invalid config for doctor repairs", async () => { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 31644704d885..20d2079c9438 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -141,7 +141,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { const preflight = await runDoctorConfigPreflight({ repairPrefixedConfig: shouldRepair, recoverCorruptTargetStore: shouldRepair, - crossStateDirImports: shouldRepair, + crossStateDirImports: shouldRepair && params.options.crossStateDirImports === true, }); const snapshot = preflight.snapshot; const baseCfg = preflight.baseConfig; diff --git a/src/commands/doctor-config-preflight.test.ts b/src/commands/doctor-config-preflight.test.ts index c95bbd8df949..bb0cb49a6f91 100644 --- a/src/commands/doctor-config-preflight.test.ts +++ b/src/commands/doctor-config-preflight.test.ts @@ -1,14 +1,53 @@ // Doctor config preflight tests cover last-known-good snapshots and config snapshot promotion. import fs from "node:fs/promises"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { promoteConfigSnapshotToLastKnownGood, readConfigFileSnapshot } from "../config/config.js"; import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; +import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; import { runDoctorConfigPreflight, shouldSkipPluginValidationForDoctorConfigPreflight, } from "./doctor-config-preflight.js"; +type ConfigHealthDatabase = Pick<OpenClawStateKyselyDatabase, "config_health_entries">; + +function readConfigHealthRow(env: NodeJS.ProcessEnv, configPath: string) { + const { db } = openOpenClawStateDatabase({ env }); + const healthDb = getNodeSqliteKysely<ConfigHealthDatabase>(db); + return executeSqliteQueryTakeFirstSync( + db, + healthDb + .selectFrom("config_health_entries") + .select("config_path") + .where("config_path", "=", configPath), + ); +} + describe("runDoctorConfigPreflight", () => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + }); + + it("supports non-observing config reads", async () => { + await withTempHome(async (home) => { + const configPath = await writeOpenClawConfig(home, { gateway: { mode: "local" } }); + + await runDoctorConfigPreflight({ + migrateState: false, + migrateLegacyConfig: false, + invalidConfigNote: false, + observe: false, + }); + + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toBeUndefined(); + }); + }); + it("skips plugin schema validation while doctor is running inside update", () => { expect( shouldSkipPluginValidationForDoctorConfigPreflight({ diff --git a/src/commands/doctor-config-preflight.ts b/src/commands/doctor-config-preflight.ts index 05ca5cc17119..6174b220713e 100644 --- a/src/commands/doctor-config-preflight.ts +++ b/src/commands/doctor-config-preflight.ts @@ -178,6 +178,7 @@ export async function runDoctorConfigPreflight( repairPrefixedConfig?: boolean; recoverCorruptTargetStore?: boolean; invalidConfigNote?: string | false; + observe?: boolean; /** Return false or reject on config drift; the preflight always unwinds owned resources. */ beforeStateMigrations?: (snapshot?: ConfigFileSnapshot) => Promise<boolean>; requireStartupMigrationCheckpoint?: boolean; @@ -254,6 +255,7 @@ export async function runDoctorConfigPreflight( } const readOptions = { + ...(options.observe === false ? { observe: false } : {}), skipPluginValidation: shouldSkipPluginValidationForDoctorConfigPreflight(), }; let snapshot = addDoctorLegacyIssues(await readConfigFileSnapshot(readOptions)); diff --git a/src/commands/doctor-invocation.ts b/src/commands/doctor-invocation.ts new file mode 100644 index 000000000000..ed936152e227 --- /dev/null +++ b/src/commands/doctor-invocation.ts @@ -0,0 +1,16 @@ +/** Internal doctor invocation capabilities shared by direct and automated callers. */ +import { isTruthyEnvValue } from "../infra/env.js"; +import { UPDATE_IN_PROGRESS_ENV } from "./doctor/shared/update-phase.js"; + +export const DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV = + "OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS"; + +/** Direct CLI doctor owns cross-state imports unless its automation parent denies them. */ +export function resolveDoctorCrossStateDirImports(env: NodeJS.ProcessEnv = process.env): boolean { + // Older update parents know only OPENCLAW_UPDATE_IN_PROGRESS. Treat that + // existing cross-version handshake as deny-by-default for a newer doctor. + return !( + isTruthyEnvValue(env[DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]) || + isTruthyEnvValue(env[UPDATE_IN_PROGRESS_ENV]) + ); +} diff --git a/src/commands/doctor.types.ts b/src/commands/doctor.types.ts index 2d2e6c47a633..962f861c2ac2 100644 --- a/src/commands/doctor.types.ts +++ b/src/commands/doctor.types.ts @@ -11,4 +11,6 @@ export type DoctorOptions = { allowExec?: boolean; postUpgrade?: boolean; json?: boolean; + /** Internal capability granted only to direct operator-owned doctor invocations. */ + crossStateDirImports?: boolean; }; diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts index 59d5ae517bb6..e35a333fd369 100644 --- a/src/commands/onboard-inference.test.ts +++ b/src/commands/onboard-inference.test.ts @@ -55,6 +55,31 @@ describe("detectInferenceBackends", () => { expect(candidates[4]?.modelRef).toBe(CODEX_APP_SERVER_DEFAULT_MODEL_REF); }); + it("prefers the configured default agent model over the global default", async () => { + const candidates = await detectInferenceBackends({ + config: { + agents: { + defaults: { model: "openai/gpt-5.5" }, + list: [ + { id: "fallback", model: "google/gemini-3.1-pro-preview" }, + { id: "ops", default: true, model: "anthropic/claude-opus-4-8" }, + ], + }, + }, + env: {}, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({}), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + + expect(candidates).toMatchObject([ + { kind: "existing-model", modelRef: "anthropic/claude-opus-4-8" }, + ]); + }); + it("sinks a definitively logged-out CLI below a logged-in one", async () => { const candidates = await detectInferenceBackends({ env: {}, diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts index a630d5496eb5..23d3270c0b7a 100644 --- a/src/commands/onboard-inference.ts +++ b/src/commands/onboard-inference.ts @@ -1,3 +1,4 @@ +import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, @@ -107,7 +108,12 @@ export async function detectInferenceBackends( (() => readGeminiCliCredentialsCached({ ttlMs: 60_000 })); const candidates: InferenceBackendCandidate[] = []; - const existingModel = resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model); + const defaultAgentModel = options.config + ? resolveAgentConfig(options.config, resolveDefaultAgentId(options.config))?.model + : undefined; + const existingModel = + resolveAgentModelPrimaryValue(defaultAgentModel) ?? + resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model); if (existingModel) { candidates.push({ kind: "existing-model", diff --git a/src/commands/runtime-plugin-install.ts b/src/commands/runtime-plugin-install.ts index 1bf623bc7e72..2cb5f4c4fd19 100644 --- a/src/commands/runtime-plugin-install.ts +++ b/src/commands/runtime-plugin-install.ts @@ -28,15 +28,21 @@ export type RuntimePluginInstallResult = { required: boolean; installed: boolean; status?: "installed" | "skipped" | "failed" | "timed_out"; + reason?: string; }; /** Predicate that decides whether a config/model pair needs the runtime plugin. */ -export type RuntimePluginSelection = (params: { cfg: OpenClawConfig; model?: string }) => boolean; +export type RuntimePluginSelection = (params: { + cfg: OpenClawConfig; + model?: string; + agentId?: string; +}) => boolean; /** Parameters for installing or enabling a runtime plugin during setup. */ export type RuntimePluginEnsureParams = { cfg: OpenClawConfig; model?: string; + agentId?: string; prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir?: string; @@ -47,6 +53,7 @@ export type RuntimePluginEnsureParams = { export type RuntimePluginRepairParams = { cfg: OpenClawConfig; model?: string; + agentId?: string; env?: NodeJS.ProcessEnv; }; @@ -73,6 +80,7 @@ function isInstalledRecordPresentOnDisk( async function ensureRuntimePluginForModelSelection(params: { cfg: OpenClawConfig; model?: string; + agentId?: string; prompter: WizardPrompter; runtime: RuntimeEnv; workspaceDir?: string; @@ -80,7 +88,13 @@ async function ensureRuntimePluginForModelSelection(params: { descriptor: RuntimePluginInstallDescriptor; shouldEnsure: RuntimePluginSelection; }): Promise<RuntimePluginInstallResult> { - if (!params.shouldEnsure({ cfg: params.cfg, model: params.model })) { + if ( + !params.shouldEnsure({ + cfg: params.cfg, + model: params.model, + agentId: params.agentId, + }) + ) { return { cfg: params.cfg, required: false, @@ -94,6 +108,7 @@ async function ensureRuntimePluginForModelSelection(params: { const repair = await repairRuntimePluginInstallForModelSelection({ cfg: params.cfg, model: params.model, + agentId: params.agentId, env: process.env, descriptor: params.descriptor, shouldEnsure: params.shouldEnsure, @@ -106,10 +121,11 @@ async function ensureRuntimePluginForModelSelection(params: { } const enableResult = enablePluginInConfig(params.cfg, params.descriptor.pluginId); return { - cfg: enableResult.enabled ? enableResult.config : params.cfg, + cfg: enableResult.config, required: true, - installed: true, - status: "installed", + installed: enableResult.enabled, + status: enableResult.enabled ? "installed" : "failed", + ...(enableResult.reason ? { reason: enableResult.reason } : {}), }; } const { ensureOnboardingPluginInstalled } = await import("./onboarding-plugin-install.js"); @@ -146,11 +162,18 @@ async function ensureRuntimePluginForModelSelection(params: { async function repairRuntimePluginInstallForModelSelection(params: { cfg: OpenClawConfig; model?: string; + agentId?: string; env?: NodeJS.ProcessEnv; descriptor: RuntimePluginInstallDescriptor; shouldEnsure: RuntimePluginSelection; }): Promise<{ required: boolean; changes: string[]; warnings: string[] }> { - if (!params.shouldEnsure({ cfg: params.cfg, model: params.model })) { + if ( + !params.shouldEnsure({ + cfg: params.cfg, + model: params.model, + agentId: params.agentId, + }) + ) { return { required: false, changes: [], warnings: [] }; } const { repairMissingPluginInstallsForIds } = diff --git a/src/commands/status-all/report-data.test.ts b/src/commands/status-all/report-data.test.ts new file mode 100644 index 000000000000..2c4e592b92be --- /dev/null +++ b/src/commands/status-all/report-data.test.ts @@ -0,0 +1,71 @@ +// Status-all report data tests cover local read-only diagnosis probes. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })), +})); + +vi.mock("../../agents/exec-defaults.js", () => ({ canExecRequestNode: () => false })); +vi.mock("../../config/config.js", () => ({ + readConfigFileSnapshot: mocks.readConfigFileSnapshot, + resolveGatewayPort: () => 18789, +})); +vi.mock("../../daemon/diagnostics.js", () => ({ + readLastGatewayErrorLine: async () => null, +})); +vi.mock("../../infra/ports.js", () => ({ inspectPortUsage: async () => null })); +vi.mock("../../infra/restart-sentinel.js", () => ({ readRestartSentinel: async () => null })); +vi.mock("../../plugins/status.js", () => ({ buildPluginCompatibilityNotices: () => [] })); +vi.mock("../../skills/discovery/status.js", () => ({ buildWorkspaceSkillStatus: () => null })); +vi.mock("../../skills/runtime/remote.js", () => ({ getRemoteSkillEligibility: () => ({}) })); +vi.mock("../status-overview-rows.ts", () => ({ buildStatusAllOverviewRows: () => [] })); +vi.mock("../status-overview-surface.ts", () => ({ + buildStatusOverviewSurfaceFromOverview: () => ({}), +})); +vi.mock("../status-runtime-shared.ts", () => ({ + resolveStatusGatewayDiagnosticsSafe: async () => null, + resolveStatusGatewayHealthSafe: async () => undefined, +})); +vi.mock("../status-update-restart.ts", () => ({ + formatUpdateRestartStatusValue: () => null, +})); +vi.mock("../status.gateway-connection.ts", () => ({ + resolveStatusAllConnectionDetails: () => [], +})); + +import { buildStatusAllReportData } from "./report-data.js"; + +describe("buildStatusAllReportData", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockClear(); + }); + + it("keeps local config diagnosis non-observing", async () => { + await buildStatusAllReportData({ + overview: { + cfg: {}, + gatewaySnapshot: { + gatewayReachable: false, + gatewayProbe: null, + gatewayCallOverrides: undefined, + gatewayConnection: {}, + remoteUrlMissing: false, + }, + secretDiagnostics: [], + tailscaleMode: "off", + tailscaleDns: null, + agentStatus: { agents: [], defaultId: null }, + channels: { rows: [], details: [] }, + channelIssues: [], + osSummary: { label: "test" }, + } as never, + daemon: {} as never, + nodeService: {} as never, + nodeOnlyGateway: {} as never, + progress: { setLabel: vi.fn(), tick: vi.fn() }, + }); + + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledOnce(); + expect(mocks.readConfigFileSnapshot).toHaveBeenCalledWith({ observe: false }); + }); +}); diff --git a/src/commands/status-all/report-data.ts b/src/commands/status-all/report-data.ts index 5d16c800101a..4f5583fa632f 100644 --- a/src/commands/status-all/report-data.ts +++ b/src/commands/status-all/report-data.ts @@ -80,7 +80,7 @@ async function resolveStatusAllLocalDiagnosis(params: { }; }> { const { overview } = params; - const snap = await readConfigFileSnapshot().catch(() => null); + const snap = await readConfigFileSnapshot({ observe: false }).catch(() => null); const configPath = resolveStatusAllConfigPath(snap?.path); const health = params.nodeOnlyGateway diff --git a/src/commands/status.scan-overview.test.ts b/src/commands/status.scan-overview.test.ts index df5ca99e0e80..65e237c7a618 100644 --- a/src/commands/status.scan-overview.test.ts +++ b/src/commands/status.scan-overview.test.ts @@ -134,6 +134,10 @@ describe("collectStatusScanOverview", () => { useGatewayCallOverridesForChannelsStatus: true, }); + expect(mocks.readBestEffortConfigSnapshot).toHaveBeenCalledWith({ + observe: false, + skipPluginValidation: undefined, + }); expect(mocks.callGateway).toHaveBeenCalledOnce(); const gatewayRequest = firstGatewayRequest(); expect(gatewayRequest?.method).toBe("channels.status"); diff --git a/src/commands/status.scan-overview.ts b/src/commands/status.scan-overview.ts index 0be8df0ef29f..cbe62ba474bb 100644 --- a/src/commands/status.scan-overview.ts +++ b/src/commands/status.scan-overview.ts @@ -194,6 +194,7 @@ export async function collectStatusScanOverview(params: { allowMissingConfigFastPath: params.allowMissingConfigFastPath, readConfigSnapshot: async () => (await loadConfigModule()).readBestEffortConfigSnapshot({ + observe: false, skipPluginValidation: params.skipConfigPluginValidation, }), resolveConfig: async (loadedConfig) => diff --git a/src/config/io.best-effort.test.ts b/src/config/io.best-effort.test.ts index a86a321ca5ba..b7e7d07a0e83 100644 --- a/src/config/io.best-effort.test.ts +++ b/src/config/io.best-effort.test.ts @@ -243,9 +243,9 @@ describe("readBestEffortConfig", () => { }); }); - it("returns source and materialized config from one snapshot", async () => { + it("controls observation while returning source and materialized config", async () => { await withTempHome(async (home) => { - await writeOpenClawConfig(home, { + const configPath = await writeOpenClawConfig(home, { auth: { profiles: { "anthropic:api": { provider: "anthropic", mode: "api_key" }, @@ -257,12 +257,22 @@ describe("readBestEffortConfig", () => { }, }, }); + const configRaw = await fs.readFile(configPath, "utf-8"); - const snapshot = await readBestEffortConfigSnapshot(); + const snapshot = await readBestEffortConfigSnapshot({ observe: false }); expect(snapshot.sourceConfig.agents?.defaults?.contextPruning?.mode).toBeUndefined(); expect(snapshot.config.agents?.defaults?.contextPruning?.mode).toBe("cache-ttl"); expect(snapshot.config.agents?.defaults?.compaction?.mode).toBe("safeguard"); + await expect(fs.readFile(configPath, "utf-8")).resolves.toBe(configRaw); + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toBeUndefined(); + + await readBestEffortConfigSnapshot(); + + expect(readConfigHealthRow({ ...process.env, HOME: home }, configPath)).toMatchObject({ + config_path: configPath, + last_known_good_json: expect.any(String), + }); }); }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 0bf2a7df0206..c83a9e0eadd7 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -2826,11 +2826,13 @@ export async function readBestEffortConfig(options?: { } export async function readBestEffortConfigSnapshot(options?: { + observe?: boolean; skipPluginValidation?: boolean; }): Promise<BestEffortConfigSnapshot> { - return await createConfigIO( - options?.skipPluginValidation ? { pluginValidation: "skip" } : {}, - ).readBestEffortConfigSnapshot(); + return await createConfigIO({ + ...(options?.observe === false ? { observe: false } : {}), + ...(options?.skipPluginValidation ? { pluginValidation: "skip" } : {}), + }).readBestEffortConfigSnapshot(); } export async function readSourceConfigBestEffort(): Promise<OpenClawConfig> { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 312a425afa48..4339168a1bee 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -444,6 +444,7 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([ "cerebras", "chutes", "claude-cli", + "clawrouter", "cloudflare-ai-gateway", "codex", "comfy", diff --git a/src/config/zod-schema.models.test.ts b/src/config/zod-schema.models.test.ts index 410f02e22a42..3a7e28d74b57 100644 --- a/src/config/zod-schema.models.test.ts +++ b/src/config/zod-schema.models.test.ts @@ -6,6 +6,7 @@ describe("ModelsConfigSchema", () => { it.each([ "claude-cli", "azure-openai-responses", + "clawrouter", "gmi", "gmi-cloud", "gmicloud", diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 3bbbd2ecc80f..7efbfb710dda 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -714,6 +714,25 @@ describe("Engine contract tests", () => { ).toBe("## Memory Recall\ncitations=off"); }); + it("passes agent context through delegated memory prompt assembly", () => { + registerMemoryPromptSection(({ agentId, agentSessionKey, sandboxed }) => [ + "## Agent Memory", + `agent=${agentId} session=${agentSessionKey} sandboxed=${sandboxed}`, + "", + ]); + + expect( + buildMemorySystemPromptAddition({ + availableTools: new Set(["memory_search", "memory_get"]), + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }), + ).toBe( + "## Agent Memory\nagent=marketing-agent session=agent:marketing-agent:main sandboxed=true", + ); + }); + it("returns undefined when the active memory prompt path contributes nothing", () => { expect( buildMemorySystemPromptAddition({ diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index c885a4f0c74f..94ecccbcb736 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -99,10 +99,16 @@ export async function delegateCompactionToRuntime( export function buildMemorySystemPromptAddition(params: { availableTools: Set<string>; citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; }): string | undefined { const lines = buildMemoryPromptSection({ availableTools: params.availableTools, citationsMode: params.citationsMode, + agentId: params.agentId, + agentSessionKey: params.agentSessionKey, + sandboxed: params.sandboxed, }); if (lines.length === 0) { return undefined; diff --git a/src/crestodian/setup-apply.ts b/src/crestodian/setup-apply.ts index cb24a14ad142..0bce66ea8432 100644 --- a/src/crestodian/setup-apply.ts +++ b/src/crestodian/setup-apply.ts @@ -1,6 +1,8 @@ // Applies Crestodian's conversational setup: config, workspace files, gateway. import { resolveGatewayPort } from "../config/config.js"; +import type { AgentModelEntryConfig } from "../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { shortenHomePath } from "../utils.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -72,6 +74,70 @@ function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig { }; } +export async function applyCrestodianModelSelection(params: { + config: OpenClawConfig; + model: string; + agentRuntimeId?: string; +}): Promise<OpenClawConfig> { + const [agentScope, modelConfig, runtimePolicy] = await Promise.all([ + import("../agents/agent-scope.js"), + import("../commands/models/shared.js"), + import("../agents/model-runtime-policy.js"), + ]); + const nextConfig = structuredClone(params.config); + const agentId = agentScope.resolveDefaultAgentId(nextConfig); + const writesAgent = Boolean(agentScope.resolveAgentExplicitModelPrimary(nextConfig, agentId)); + let models: Record<string, AgentModelEntryConfig>; + if (writesAgent) { + const agent = nextConfig.agents?.list?.find((entry) => normalizeAgentId(entry.id) === agentId); + if (!agent) { + throw new Error(`Could not resolve configured default agent "${agentId}".`); + } + models = { ...agent.models }; + agent.models = models; + } else { + nextConfig.agents ??= {}; + nextConfig.agents.defaults ??= {}; + models = { ...nextConfig.agents.defaults.models }; + nextConfig.agents.defaults.models = models; + } + const target = modelConfig.resolveModelTarget({ raw: params.model, cfg: nextConfig }); + const key = modelConfig.upsertCanonicalModelConfigEntry(models, target); + if (params.agentRuntimeId) { + models[key] = { + ...models[key], + agentRuntime: { id: params.agentRuntimeId }, + }; + } + agentScope.setAgentEffectiveModelPrimary(nextConfig, agentId, key); + if (params.agentRuntimeId) { + const effectiveRuntime = runtimePolicy.resolveModelRuntimePolicy({ + config: nextConfig, + provider: target.provider, + modelId: target.model, + agentId, + }).policy?.id; + if (effectiveRuntime !== params.agentRuntimeId) { + // An inherited primary can still have higher-priority per-agent model + // metadata. Pin the selected runtime at that owner as well. + const agent = nextConfig.agents?.list?.find( + (entry) => normalizeAgentId(entry.id) === agentId, + ); + if (!agent) { + throw new Error(`Could not resolve configured default agent "${agentId}".`); + } + const agentModels = { ...agent.models }; + const agentKey = modelConfig.upsertCanonicalModelConfigEntry(agentModels, target); + agentModels[agentKey] = { + ...agentModels[agentKey], + agentRuntime: { id: params.agentRuntimeId }, + }; + agent.models = agentModels; + } + } + return nextConfig; +} + export async function applyCrestodianSetup( params: CrestodianSetupApplyParams, ): Promise<CrestodianSetupApplyResult> { @@ -92,11 +158,9 @@ export async function applyCrestodianSetup( let nextConfig = applyLocalSetupWorkspaceConfig(baseConfig, workspace); if (model) { - const { applyDefaultModelPrimaryUpdate } = await import("../commands/models/shared.js"); - nextConfig = applyDefaultModelPrimaryUpdate({ - cfg: nextConfig, - modelRaw: model, - field: "model", + nextConfig = await applyCrestodianModelSelection({ + config: nextConfig, + model, }); } nextConfig = applySecurityAcknowledgement(nextConfig); diff --git a/src/crestodian/setup-inference.test.ts b/src/crestodian/setup-inference.test.ts index 33dc5e338c2a..25ac209c9f04 100644 --- a/src/crestodian/setup-inference.test.ts +++ b/src/crestodian/setup-inference.test.ts @@ -8,8 +8,11 @@ import { } from "../agents/auth-profiles/oauth-test-utils.js"; import { upsertAuthProfileWithLock } from "../agents/auth-profiles/profiles.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { withoutPluginInstallRecords } from "../plugins/installed-plugin-index-records.js"; import type { ProviderAuthChoiceMetadata } from "../plugins/provider-auth-choices.js"; import type { ProviderPlugin } from "../plugins/types.js"; +import { applyCrestodianModelSelection } from "./setup-apply.js"; import { activateSetupInference, detectSetupInference, @@ -56,6 +59,38 @@ async function makeTempDir(): Promise<string> { return await fs.mkdtemp(path.join(os.tmpdir(), "setup-inference-test-")); } +describe("applyCrestodianModelSelection", () => { + it("overrides higher-priority runtime metadata on an inheriting default agent", async () => { + const config = { + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + { + id: "ops", + default: true, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }, + } satisfies OpenClawConfig; + + const result = await applyCrestodianModelSelection({ + config, + model: "openai/gpt-5.5", + agentRuntimeId: "codex", + }); + + expect(result.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.5" }); + expect(result.agents?.list?.[0]).toMatchObject({ + id: "ops", + models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, + }); + expect(config.agents.list[0]?.models["openai/gpt-5.5"]?.agentRuntime?.id).toBe("openclaw"); + }); +}); + describe("detectSetupInference", () => { it("marks the first non-logged-out candidate recommended", async () => { const resolveManifestProviderAuthChoices = vi.fn(() => []); @@ -174,9 +209,10 @@ describe("activateSetupInference", () => { }); it("does not touch config when the live test fails", async () => { + const providerSecret = "gsk_abcdefghijklmnop"; const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] })); const runCliAgent = vi.fn(async () => { - throw new Error("401 invalid_api_key"); + throw new Error(`401 invalid_api_key ${providerSecret}`); }); const result = await activateSetupInference({ kind: "claude-cli", @@ -191,6 +227,7 @@ describe("activateSetupInference", () => { expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain("invalid_api_key"); + expect(result.error).not.toContain(providerSecret); } expect(applySetup).not.toHaveBeenCalled(); }); @@ -220,6 +257,72 @@ describe("activateSetupInference", () => { expect(applySetup).not.toHaveBeenCalled(); }); + it("probes a built-in API candidate through the effective default-agent route", async () => { + const initialConfig = { + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + { + id: "ops", + default: true, + model: { primary: "openai/gpt-5.4" }, + models: { + "anthropic/claude-opus-4-8": { agentRuntime: { id: "codex" } }, + }, + }, + ], + }, + } satisfies OpenClawConfig; + const runEmbeddedAgent = vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })); + const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] })); + + const result = await activateSetupInference({ + kind: "anthropic-api-key", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config: initialConfig, + runtimeConfig: initialConfig, + })) as never, + runEmbeddedAgent: runEmbeddedAgent as never, + applySetup: applySetup as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: true, modelRef: "anthropic/claude-opus-4-8" }); + expect(runEmbeddedAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "ops", + provider: "anthropic", + model: "claude-opus-4-8", + config: expect.objectContaining({ + agents: expect.objectContaining({ + list: [ + expect.objectContaining({ + id: "ops", + model: { primary: "anthropic/claude-opus-4-8" }, + models: { + "anthropic/claude-opus-4-8": { agentRuntime: { id: "codex" } }, + }, + }), + ], + }), + }), + }), + ); + expect(applySetup).toHaveBeenCalledWith( + expect.objectContaining({ model: "anthropic/claude-opus-4-8" }), + ); + }); + it("rejects manual activation without a supported provider", async () => { const result = await activateSetupInference({ kind: "api-key", @@ -546,7 +649,7 @@ describe("activateSetupInference", () => { }), resolveAgentDir: () => agentDir, runEmbeddedAgent: vi.fn(async () => { - throw new Error("401 invalid_api_key"); + throw new Error("401 rejected credential bad-groq-key"); }) as never, applySetup: vi.fn() as never, updateConfig: vi.fn() as never, @@ -555,40 +658,490 @@ describe("activateSetupInference", () => { }); expect(result).toMatchObject({ ok: false, status: "auth" }); + if (!result.ok) { + expect(result.error).toContain("401 rejected credential [redacted]"); + expect(result.error).not.toContain("bad-groq-key"); + } expect(readAuthProfileStoreForTest(agentDir).profiles["groq:default"]).toBeUndefined(); } finally { await removeOAuthTestTempRoot(stateDir); } }); - it("runs the codex plugin ensure step only after a passing test", async () => { - const applySetup = vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: ["ok"] })); - const ensureCodex = vi.fn(async () => ({ - cfg: {}, - required: false, - installed: false, - })); - const runEmbeddedAgent = vi.fn(async (_params: unknown) => ({ - meta: { finalAssistantVisibleText: "OK" }, - })); + it("installs the codex runtime independently of a custom OpenAI route", async () => { + const events: string[] = []; + const initialConfig = { + gateway: { port: 18789 }, + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + { + id: "ops", + default: true, + model: { + primary: "anthropic/claude-opus-4-8", + fallbacks: ["google/gemini-3.1-pro-preview"], + }, + models: { + "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } }, + }, + }, + ], + }, + models: { + providers: { + openai: { + baseUrl: "https://proxy.example.test/v1", + models: [], + }, + }, + }, + plugins: { + entries: { + codex: { + enabled: false, + config: { appServer: { command: "codex", mode: "yolo" } }, + }, + }, + }, + } satisfies OpenClawConfig; + const applySetup = vi.fn(async () => { + events.push("persist-setup"); + return { configPath: "/tmp/openclaw.json", lines: ["ok"] }; + }); + const ensureCodex = vi.fn(async (params: { cfg: OpenClawConfig }) => { + events.push("install-plugin"); + return { + cfg: { + ...params.cfg, + plugins: { + ...params.cfg.plugins, + entries: { + ...params.cfg.plugins?.entries, + codex: { + ...params.cfg.plugins?.entries?.codex, + enabled: true, + }, + }, + installs: { + ...params.cfg.plugins?.installs, + codex: { + source: "npm" as const, + spec: "@openclaw/codex", + installPath: "/tmp/plugins/codex", + }, + }, + }, + }, + required: true, + installed: true, + status: "installed" as const, + }; + }); + const runEmbeddedAgent = vi.fn(async (_params: unknown) => { + events.push("live-test"); + return { meta: { finalAssistantVisibleText: "OK" } }; + }); + let persistedConfig: OpenClawConfig = { + ...initialConfig, + gateway: { port: 19000 }, + }; + const pendingCodexInstalls: unknown[] = []; + const transformConfig = vi.fn( + async (params: { transform: (config: OpenClawConfig) => { nextConfig: OpenClawConfig } }) => { + const transformed = params.transform(persistedConfig).nextConfig; + const configuredRuntime = + transformed.agents?.defaults?.models?.["openai/gpt-5.5"]?.agentRuntime?.id ?? + transformed.agents?.list?.find((agent) => agent.id === "ops")?.models?.["openai/gpt-5.5"] + ?.agentRuntime?.id; + events.push( + configuredRuntime === "codex" ? "persist-plugin-config" : "persist-plugin-install", + ); + pendingCodexInstalls.push(transformed.plugins?.installs?.codex); + persistedConfig = withoutPluginInstallRecords(transformed); + return { nextConfig: persistedConfig }; + }, + ); + const refreshPluginRegistry = vi.fn(async () => { + events.push("refresh-plugin-registry"); + }); const result = await activateSetupInference({ kind: "codex-cli", + workspace: "/tmp/openclaw-workspace", surface: "gateway", runtime, deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config: initialConfig, + runtimeConfig: initialConfig, + })) as never, runEmbeddedAgent: runEmbeddedAgent as never, applySetup: applySetup as never, ensureCodexRuntimePlugin: ensureCodex as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, createTempDir: makeTempDir, }, }); expect(result.ok).toBe(true); expect(ensureCodex).toHaveBeenCalledOnce(); + expect(ensureCodex).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + expect.objectContaining({ + id: "ops", + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3.1-pro-preview"], + }, + models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, + }), + ], + }, + models: { + providers: { + openai: { baseUrl: "https://proxy.example.test/v1", models: [] }, + }, + }, + }), + model: "openai/gpt-5.5", + agentId: "ops", + }), + ); + expect(events).toEqual([ + "install-plugin", + "persist-plugin-install", + "live-test", + "persist-plugin-config", + "refresh-plugin-registry", + "persist-setup", + ]); + expect(transformConfig).toHaveBeenCalledTimes(2); + expect(transformConfig).toHaveBeenCalledWith( + expect.objectContaining({ + afterWrite: { + mode: "none", + reason: "Crestodian setup finalizes config after refresh", + }, + }), + ); + expect(refreshPluginRegistry).toHaveBeenCalledWith({ + config: persistedConfig, + reason: "source-changed", + workspaceDir: "/tmp/openclaw-workspace", + logger: { warn: expect.any(Function) }, + }); // Harness selection: codex tests run embedded with the codex harness. expect(runEmbeddedAgent.mock.calls[0]?.[0]).toMatchObject({ - agentHarnessId: "codex", + agentId: "ops", + agentDir: expect.stringContaining("setup-inference-test-"), provider: "openai", + config: { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + }, + list: [ + expect.objectContaining({ + id: "ops", + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3.1-pro-preview"], + }, + models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, + }), + ], + }, + plugins: { + entries: { codex: { enabled: true } }, + }, + tools: { exec: { mode: "full" } }, + }, }); + expect(runEmbeddedAgent.mock.calls[0]?.[0]).not.toHaveProperty("agentHarnessRuntimeOverride"); + expect(persistedConfig).toMatchObject({ + gateway: { port: 19000 }, + models: { + providers: { + openai: { baseUrl: "https://proxy.example.test/v1" }, + }, + }, + agents: { + defaults: { model: { primary: "openai/gpt-5.4" } }, + list: [ + expect.objectContaining({ + id: "ops", + model: { + primary: "openai/gpt-5.5", + fallbacks: ["google/gemini-3.1-pro-preview"], + }, + models: { "openai/gpt-5.5": { agentRuntime: { id: "codex" } } }, + }), + ], + }, + plugins: { + entries: { + codex: { + enabled: true, + config: { appServer: { command: "codex", mode: "yolo" } }, + }, + }, + }, + }); + expect(persistedConfig.plugins?.installs).toBeUndefined(); + expect(pendingCodexInstalls[0]).toMatchObject({ + source: "npm", + spec: "@openclaw/codex", + installPath: "/tmp/plugins/codex", + }); + expect(pendingCodexInstalls[1]).toBeUndefined(); + }); + + it("commits only the refreshed codex record when authored install metadata is stale", async () => { + const staleAuthoredRecords = { + codex: { + source: "npm" as const, + spec: "@openclaw/codex@1.0.0", + installPath: "/tmp/plugins/codex-v1", + }, + unrelated: { + source: "npm" as const, + spec: "@openclaw/unrelated@1.0.0", + installPath: "/tmp/plugins/unrelated-v1", + }, + }; + const canonicalRecords = { + codex: { + source: "npm" as const, + spec: "@openclaw/codex@2.0.0", + installPath: "/tmp/plugins/codex-v2", + }, + unrelated: { + source: "npm" as const, + spec: "@openclaw/unrelated@2.0.0", + installPath: "/tmp/plugins/unrelated-v2", + }, + }; + const refreshedCodexRecord = { + source: "npm" as const, + spec: "@openclaw/codex@3.0.0", + installPath: "/tmp/plugins/codex-v3", + }; + const sourceConfig = { + plugins: { installs: staleAuthoredRecords }, + } satisfies OpenClawConfig; + const runtimeConfig = { + plugins: { installs: canonicalRecords }, + } satisfies OpenClawConfig; + const ensureCodex = vi.fn(async (params: { cfg: OpenClawConfig }) => ({ + cfg: { + ...params.cfg, + plugins: { + ...params.cfg.plugins, + installs: { codex: refreshedCodexRecord }, + }, + }, + required: true, + installed: true, + status: "installed" as const, + })); + let persistedConfig: OpenClawConfig = sourceConfig; + let installIndex: Record<string, PluginInstallRecord> = structuredClone(canonicalRecords); + const pendingInstallRecords: unknown[] = []; + const transformConfig = vi.fn( + async (params: { transform: (config: OpenClawConfig) => { nextConfig: OpenClawConfig } }) => { + const transformed = params.transform(persistedConfig).nextConfig; + const pending = transformed.plugins?.installs; + pendingInstallRecords.push(pending); + installIndex = { ...installIndex, ...pending }; + persistedConfig = withoutPluginInstallRecords(transformed); + return { nextConfig: persistedConfig }; + }, + ); + + const result = await activateSetupInference({ + kind: "codex-cli", + workspace: "/tmp/openclaw-workspace", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config: sourceConfig, + runtimeConfig, + })) as never, + ensureCodexRuntimePlugin: ensureCodex as never, + runEmbeddedAgent: vi.fn(async () => ({ + meta: { finalAssistantVisibleText: "OK" }, + })) as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: vi.fn(async () => {}) as never, + applySetup: vi.fn(async () => ({ configPath: "/tmp/openclaw.json", lines: [] })) as never, + createTempDir: makeTempDir, + }, + }); + + expect(result.ok).toBe(true); + expect(ensureCodex).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.not.objectContaining({ + plugins: expect.objectContaining({ installs: expect.anything() }), + }), + }), + ); + expect(pendingInstallRecords).toStrictEqual([{ codex: refreshedCodexRecord }, undefined]); + expect(installIndex).toStrictEqual({ + codex: refreshedCodexRecord, + unrelated: canonicalRecords.unrelated, + }); + expect(persistedConfig.plugins?.installs).toBeUndefined(); + }); + + it("does not run or persist when the codex runtime install fails", async () => { + const runEmbeddedAgent = vi.fn(); + const applySetup = vi.fn(); + const transformConfig = vi.fn(); + const refreshPluginRegistry = vi.fn(); + const result = await activateSetupInference({ + kind: "codex-cli", + surface: "gateway", + runtime, + deps: { + ensureCodexRuntimePlugin: vi.fn(async () => ({ + cfg: {}, + required: true, + installed: false, + status: "failed" as const, + })) as never, + runEmbeddedAgent: runEmbeddedAgent as never, + applySetup: applySetup as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: false, status: "unavailable" }); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + expect(transformConfig).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applySetup).not.toHaveBeenCalled(); + }); + + it("does not install codex when plugin policy blocks it", async () => { + const ensureCodex = vi.fn(); + const runEmbeddedAgent = vi.fn(); + const applySetup = vi.fn(); + const transformConfig = vi.fn(); + const refreshPluginRegistry = vi.fn(); + const blockedConfig: OpenClawConfig = { plugins: { allow: ["other"] } }; + const result = await activateSetupInference({ + kind: "codex-cli", + surface: "gateway", + runtime, + deps: { + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + issues: [], + config: blockedConfig, + runtimeConfig: blockedConfig, + })) as never, + ensureCodexRuntimePlugin: ensureCodex as never, + runEmbeddedAgent: runEmbeddedAgent as never, + applySetup: applySetup as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ + ok: false, + status: "unavailable", + error: expect.stringContaining("blocked by allowlist"), + }); + expect(ensureCodex).not.toHaveBeenCalled(); + expect(runEmbeddedAgent).not.toHaveBeenCalled(); + expect(transformConfig).not.toHaveBeenCalled(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applySetup).not.toHaveBeenCalled(); + }); + + it("records codex install ownership but not setup when the live test fails", async () => { + const applySetup = vi.fn(); + let pendingCodexInstall: unknown; + let recordCommitConfig: OpenClawConfig | undefined; + const transformConfig = vi.fn( + async (params: { transform: (config: OpenClawConfig) => { nextConfig: OpenClawConfig } }) => { + const transformed = params.transform({}).nextConfig; + recordCommitConfig = transformed; + pendingCodexInstall = transformed.plugins?.installs?.codex; + return { nextConfig: withoutPluginInstallRecords(transformed) }; + }, + ); + const refreshPluginRegistry = vi.fn(); + const result = await activateSetupInference({ + kind: "codex-cli", + surface: "gateway", + runtime, + deps: { + ensureCodexRuntimePlugin: vi.fn(async () => ({ + cfg: { + plugins: { + installs: { + codex: { + source: "npm" as const, + spec: "@openclaw/codex", + installPath: "/tmp/plugins/codex", + }, + }, + }, + }, + required: true, + installed: true, + status: "installed" as const, + })) as never, + runEmbeddedAgent: vi.fn(async () => { + throw new Error("401 invalid_api_key"); + }) as never, + applySetup: applySetup as never, + transformConfigWithPendingPluginInstalls: transformConfig as never, + refreshPluginRegistryAfterConfigMutation: refreshPluginRegistry as never, + createTempDir: makeTempDir, + }, + }); + + expect(result).toMatchObject({ ok: false, status: "auth" }); + expect(transformConfig).toHaveBeenCalledOnce(); + expect(transformConfig).toHaveBeenCalledWith( + expect.objectContaining({ + afterWrite: { + mode: "none", + reason: "Crestodian records the installed Codex runtime before probing", + }, + }), + ); + expect(pendingCodexInstall).toMatchObject({ + source: "npm", + spec: "@openclaw/codex", + installPath: "/tmp/plugins/codex", + }); + expect(recordCommitConfig?.agents).toBeUndefined(); + expect(recordCommitConfig?.plugins?.entries).toBeUndefined(); + expect(refreshPluginRegistry).not.toHaveBeenCalled(); + expect(applySetup).not.toHaveBeenCalled(); }); }); @@ -624,16 +1177,17 @@ describe("verifySetupInference", () => { expect(updateConfig).not.toHaveBeenCalled(); }); - it("maps live-check failures without writing config or auth", async () => { + it("redacts live-check failures without writing config or auth", async () => { const applySetup = vi.fn(); const updateConfig = vi.fn(); + const secret = "sk-verifysetupsecret123"; // pragma: allowlist secret const result = await verifySetupInference({ runtime, timeoutMs: 50, deps: { readConfigFileSnapshot: vi.fn(async () => configuredSnapshot()) as never, runEmbeddedAgent: vi.fn(async () => { - throw new Error("401 invalid_api_key"); + throw new Error(`401 invalid_api_key OPENAI_API_KEY=${secret}`); }) as never, applySetup: applySetup as never, updateConfig: updateConfig as never, @@ -642,6 +1196,10 @@ describe("verifySetupInference", () => { }); expect(result).toMatchObject({ ok: false, status: "auth" }); + if (!result.ok) { + expect(result.error).not.toContain(secret); + expect(result.error).toContain("OPENAI_API_KEY="); + } expect(applySetup).not.toHaveBeenCalled(); expect(updateConfig).not.toHaveBeenCalled(); }); diff --git a/src/crestodian/setup-inference.ts b/src/crestodian/setup-inference.ts index a9d06a105383..afe9daf161fa 100644 --- a/src/crestodian/setup-inference.ts +++ b/src/crestodian/setup-inference.ts @@ -43,9 +43,12 @@ import { resolvePluginProviders } from "../plugins/providers.runtime.js"; import type { ProviderAuthMethod, ProviderAuthResult } from "../plugins/types.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; -import { buildCliPlannerConfig, buildCodexAppServerPlannerConfig } from "./assistant-backends.js"; import { loadAuthoredSetupConfig } from "./onboarding-welcome.js"; -import { applyCrestodianSetup, createQuickstartNotePrompter } from "./setup-apply.js"; +import { + applyCrestodianModelSelection, + applyCrestodianSetup, + createQuickstartNotePrompter, +} from "./setup-apply.js"; /** * Inference is the one required onboarding step (docs/cli/crestodian.md @@ -121,7 +124,9 @@ export type ActivateSetupInferenceDeps = { runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent; applySetup?: typeof applyCrestodianSetup; ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection; + transformConfigWithPendingPluginInstalls?: typeof import("../cli/plugins-install-record-commit.js").transformConfigWithPendingPluginInstalls; updateConfig?: typeof import("../commands/models/shared.js").updateConfig; + refreshPluginRegistryAfterConfigMutation?: typeof import("../cli/plugins-registry-refresh.js").refreshPluginRegistryAfterConfigMutation; resolvePluginProviders?: typeof resolvePluginProviders; resolveManifestProviderAuthChoice?: typeof resolveManifestProviderAuthChoice; enablePluginInConfig?: typeof enablePluginInConfig; @@ -219,8 +224,9 @@ type SetupInferenceTestPlan = { model: string; modelRef: string; config: OpenClawConfig; - agentHarnessId?: string; + agentId?: string; agentDir?: string; + cleanupBundleMcpOnRunEnd?: boolean; authProfileId?: string; /** Model to persist as default on success; undefined keeps the current one. */ persistModelRef?: string; @@ -295,6 +301,7 @@ async function buildTestPlan(params: { model: ref.model, modelRef, config: cfg, + agentId: resolveDefaultAgentId(cfg), }; } case "claude-cli": { @@ -303,7 +310,8 @@ async function buildTestPlan(params: { runner: "cli", ...ref, modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, - config: buildCliPlannerConfig(workspaceDir, CLAUDE_CLI_DEFAULT_MODEL_REF), + config: cfg, + agentId: resolveDefaultAgentId(cfg), persistModelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, }; } @@ -313,7 +321,8 @@ async function buildTestPlan(params: { runner: "cli", ...ref, modelRef: GEMINI_CLI_DEFAULT_MODEL_REF, - config: buildCliPlannerConfig(workspaceDir, GEMINI_CLI_DEFAULT_MODEL_REF), + config: cfg, + agentId: resolveDefaultAgentId(cfg), persistModelRef: GEMINI_CLI_DEFAULT_MODEL_REF, }; } @@ -323,8 +332,10 @@ async function buildTestPlan(params: { runner: "embedded", ...ref, modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF, - config: buildCodexAppServerPlannerConfig(workspaceDir), - agentHarnessId: "codex", + config: cfg, + agentId: resolveDefaultAgentId(cfg), + agentDir: params.agentDir, + cleanupBundleMcpOnRunEnd: true, persistModelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF, }; } @@ -334,7 +345,8 @@ async function buildTestPlan(params: { runner: "embedded", ...ref, modelRef: OPENAI_API_DEFAULT_MODEL_REF, - config: buildCliPlannerConfig(workspaceDir, OPENAI_API_DEFAULT_MODEL_REF), + config: cfg, + agentId: resolveDefaultAgentId(cfg), persistModelRef: OPENAI_API_DEFAULT_MODEL_REF, }; } @@ -344,7 +356,8 @@ async function buildTestPlan(params: { runner: "embedded", ...ref, modelRef: ANTHROPIC_API_DEFAULT_MODEL_REF, - config: buildCliPlannerConfig(workspaceDir, ANTHROPIC_API_DEFAULT_MODEL_REF), + config: cfg, + agentId: resolveDefaultAgentId(cfg), persistModelRef: ANTHROPIC_API_DEFAULT_MODEL_REF, }; } @@ -456,6 +469,7 @@ async function buildTestPlan(params: { modelRef, agentDir: params.agentDir, config: preparedConfig, + agentId: resolveDefaultAgentId(preparedConfig), authProfileId: matchingProfile.profileId, persistModelRef: modelRef, manualAuth: { @@ -554,6 +568,25 @@ async function runProviderManualSecretMethod(params: { */ export async function activateSetupInference( params: ActivateSetupInferenceParams, +): Promise<ActivateSetupInferenceResult> { + try { + const result = await activateSetupInferenceUnredacted(params); + if (result.ok) { + return result; + } + return { + ...result, + error: await redactSetupInferenceError(result.error, params.apiKey), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // oxlint-disable-next-line preserve-caught-error -- The original cause can contain the submitted setup secret. + throw new Error(await redactSetupInferenceError(message, params.apiKey)); + } +} + +async function activateSetupInferenceUnredacted( + params: ActivateSetupInferenceParams, ): Promise<ActivateSetupInferenceResult> { const deps = params.deps ?? {}; const readSnapshot = @@ -591,6 +624,106 @@ export async function activateSetupInference( return { ok: false, status: "unavailable", error: plan.error }; } + let testPlan = plan; + if (plan.persistModelRef) { + const stagedConfig = await applyCrestodianModelSelection({ + config: plan.config, + model: plan.persistModelRef, + ...(params.kind === "codex-cli" ? { agentRuntimeId: "codex" } : {}), + }); + testPlan = { + ...plan, + config: stagedConfig, + agentId: resolveDefaultAgentId(stagedConfig), + }; + } + + let codexPluginPatch: unknown; + if (params.kind === "codex-cli") { + const { stripPendingPluginInstallRecords } = + await import("../cli/plugins-install-record-commit.js"); + // This explicit Codex CLI choice owns its runtime independently of the + // user's existing OpenAI provider route (which may use a custom base URL). + const codexInstallBase = stripPendingPluginInstallRecords(testPlan.config); + const enabledCodexBase = enablePluginInConfig(codexInstallBase, "codex"); + if (!enabledCodexBase.enabled) { + return { + ok: false, + status: "unavailable", + error: `Could not enable the Codex runtime plugin: ${enabledCodexBase.reason ?? "plugin disabled"}.`, + }; + } + const ensureCodex = + deps.ensureCodexRuntimePlugin ?? + (await import("../commands/codex-runtime-plugin-install.js")) + .ensureCodexRuntimePluginForModelSelection; + const ensured = await ensureCodex({ + cfg: enabledCodexBase.config, + model: plan.modelRef, + agentId: testPlan.agentId, + prompter: createQuickstartNotePrompter(params.runtime), + runtime: params.runtime, + workspaceDir: tempDir, + }); + if (!ensured.installed) { + return { + ok: false, + status: ensured.status === "timed_out" ? "timeout" : "unavailable", + error: + ensured.status === "timed_out" + ? "Codex runtime plugin installation timed out. Try again." + : ensured.reason + ? `Could not enable the Codex runtime plugin: ${ensured.reason}.` + : "Could not install the Codex runtime plugin. Try again once the plugin is available.", + }; + } + const pendingCodexInstall = ensured.cfg.plugins?.installs?.codex; + if (pendingCodexInstall) { + // The package is already in the managed global root. Record ownership now so a + // failed or abandoned live probe cannot leave an untracked install behind. + const transformConfig = + deps.transformConfigWithPendingPluginInstalls ?? + (await import("../cli/plugins-install-record-commit.js")) + .transformConfigWithPendingPluginInstalls; + await transformConfig({ + afterWrite: { + mode: "none", + reason: "Crestodian records the installed Codex runtime before probing", + }, + transform: (current) => { + const strippedCurrent = stripPendingPluginInstallRecords(current); + return { + nextConfig: { + ...strippedCurrent, + plugins: { + ...strippedCurrent.plugins, + installs: { codex: pendingCodexInstall }, + }, + }, + }; + }, + }); + } + const enabledCodex = enablePluginInConfig(ensured.cfg, "codex"); + if (!enabledCodex.enabled) { + return { + ok: false, + status: "unavailable", + error: `Could not enable the Codex runtime plugin: ${enabledCodex.reason ?? "plugin disabled"}.`, + }; + } + // Enablement and the model-scoped runtime pin remain transient probe inputs. + // Persist them only after completion; the managed install record is durable above. + const stagedCodexConfig = stripPendingPluginInstallRecords(enabledCodex.config); + codexPluginPatch = createMergePatch(cfg, stagedCodexConfig); + testPlan = { + ...testPlan, + config: applyMergePatch(stagedCodexConfig, { + tools: { exec: { mode: "full" } }, + }) as OpenClawConfig, + }; + } + if (plan.manualAuth) { const staged = await persistManualAuthProfiles(plan.manualAuth.profiles, testAgentDir); if (!staged) { @@ -602,30 +735,41 @@ export async function activateSetupInference( } } - const test = await runSetupInferenceTest({ plan, tempDir, deps }); + const test = await runSetupInferenceTest({ plan: testPlan, tempDir, deps }); if (!test.ok) { return test; } - // Test passed — persist. Codex routes openai/* through the Codex plugin, - // so make sure it is installed/enabled before the model ref lands in config. - if (params.kind === "codex-cli") { - const ensureCodex = - deps.ensureCodexRuntimePlugin ?? - (await import("../commands/codex-runtime-plugin-install.js")) - .ensureCodexRuntimePluginForModelSelection; - const ensured = await ensureCodex({ - cfg, - model: plan.modelRef, - prompter: createQuickstartNotePrompter(params.runtime), - runtime: params.runtime, - workspaceDir: tempDir, + if (codexPluginPatch !== undefined) { + // Persist success-gated enablement and the model-scoped runtime pin. The managed + // install record was committed before the live probe. + const { stripPendingPluginInstallRecords } = + await import("../cli/plugins-install-record-commit.js"); + const transformConfig = + deps.transformConfigWithPendingPluginInstalls ?? + (await import("../cli/plugins-install-record-commit.js")) + .transformConfigWithPendingPluginInstalls; + const committed = await transformConfig({ + // Keep the setup RPC alive until the final model/setup write completes. The explicit + // registry refresh below makes the newly installed plugin available without a restart. + afterWrite: { mode: "none", reason: "Crestodian setup finalizes config after refresh" }, + transform: (current) => ({ + nextConfig: applyMergePatch( + stripPendingPluginInstallRecords(current), + codexPluginPatch, + ) as OpenClawConfig, + }), + }); + const refreshPluginRegistry = + deps.refreshPluginRegistryAfterConfigMutation ?? + (await import("../cli/plugins-registry-refresh.js")) + .refreshPluginRegistryAfterConfigMutation; + await refreshPluginRegistry({ + config: committed.nextConfig, + reason: "source-changed", + workspaceDir: workspace, + logger: { warn: (message) => params.runtime.log?.(message) }, }); - if (ensured.required) { - const updateConfig = - deps.updateConfig ?? (await import("../commands/models/shared.js")).updateConfig; - await updateConfig((current) => enablePluginInConfig(current, "codex").config); - } } if (plan.manualAuth) { const manualAuth = plan.manualAuth; @@ -657,6 +801,18 @@ export async function activateSetupInference( } } +async function redactSetupInferenceError(message: string, apiKey?: string): Promise<string> { + const secrets = new Set( + [apiKey, apiKey?.trim()].filter((value): value is string => Boolean(value)), + ); + let redacted = message; + for (const secret of Array.from(secrets).toSorted((a, b) => b.length - a.length)) { + redacted = redacted.split(secret).join("[redacted]"); + } + const { redactToolPayloadText } = await import("../logging/redact.js"); + return redactToolPayloadText(redacted); +} + /** Live-test the configured default model without changing config or auth state. */ export async function verifySetupInference(params: { kind?: "existing-model"; @@ -690,7 +846,13 @@ export async function verifySetupInference(params: { return { ok: false, status: "unavailable", error: plan.error }; } const test = await runSetupInferenceTest({ plan, tempDir, deps }); - return test.ok ? { ...test, modelRef: plan.modelRef } : test; + if (test.ok) { + return { ...test, modelRef: plan.modelRef }; + } + return { + ...test, + error: await redactSetupInferenceError(test.error), + }; } finally { await (deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true })))( tempDir, @@ -752,7 +914,7 @@ async function runSetupInferenceTest(params: { result = (await runCli({ sessionId, sessionKey: `temp:setup-inference:${runId}`, - agentId: "crestodian", + agentId: plan.agentId ?? "crestodian", trigger: "manual", sessionFile, workspaceDir: tempDir, @@ -773,7 +935,7 @@ async function runSetupInferenceTest(params: { result = (await runEmbedded({ sessionId, sessionKey: `temp:setup-inference:${runId}`, - agentId: "crestodian", + agentId: plan.agentId ?? "crestodian", trigger: "manual", sessionFile, workspaceDir: tempDir, @@ -785,9 +947,7 @@ async function runSetupInferenceTest(params: { ...(plan.authProfileId ? { authProfileId: plan.authProfileId, authProfileIdSource: "user" as const } : {}), - ...(plan.agentHarnessId - ? { agentHarnessId: plan.agentHarnessId, cleanupBundleMcpOnRunEnd: true } - : {}), + ...(plan.cleanupBundleMcpOnRunEnd ? { cleanupBundleMcpOnRunEnd: true } : {}), timeoutMs, runId, lane: `session:probe-setup-inference:${plan.provider}`, @@ -812,11 +972,10 @@ async function runSetupInferenceTest(params: { return { ok: true, latencyMs: Date.now() - started }; } catch (error) { const described = describeFailoverError(error); - const { redactSecrets } = await import("../commands/status-all/format.js"); return { ok: false, status: mapFailoverReasonToSetupStatus(described.reason), - error: redactSecrets(described.message), + error: described.message, }; } } diff --git a/src/cron/config-revision.test.ts b/src/cron/config-revision.test.ts new file mode 100644 index 000000000000..b61ab1717847 --- /dev/null +++ b/src/cron/config-revision.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "vitest"; +import { resolveCronJobConfigRevision } from "./config-revision.js"; +import { setupCronServiceSuite } from "./service.test-harness.js"; +import { loadCronStore, saveCronStore } from "./store.js"; +import type { CronJob } from "./types.js"; + +const { makeStorePath } = setupCronServiceSuite({ prefix: "cron-config-revision-" }); + +function makeJob(): CronJob { + return { + id: "job-1", + name: "daily report", + enabled: true, + createdAtMs: 1_000, + updatedAtMs: 2_000, + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "Summarize the day" }, + delivery: { mode: "announce", channel: "telegram", to: "chat-1" }, + state: {}, + }; +} + +describe("resolveCronJobConfigRevision", () => { + it("ignores runtime timestamps and scheduler state", () => { + const original = makeJob(); + const cyclicState: Record<string, unknown> = {}; + cyclicState.self = cyclicState; + const runtimeChanged: CronJob = { + ...original, + updatedAtMs: 9_000, + state: { + lastRunAtMs: 8_000, + lastRunStatus: "ok", + nextRunAtMs: 10_000, + triggerState: cyclicState, + }, + }; + + expect(resolveCronJobConfigRevision(runtimeChanged)).toBe( + resolveCronJobConfigRevision(original), + ); + }); + + it("changes for definition updates and same-id recreation", () => { + const original = makeJob(); + + expect(resolveCronJobConfigRevision({ ...original, description: "changed" })).not.toBe( + resolveCronJobConfigRevision(original), + ); + expect(resolveCronJobConfigRevision({ ...original, createdAtMs: 2_000 })).not.toBe( + resolveCronJobConfigRevision(original), + ); + }); + + it("is stable across nested key ordering", () => { + const original = makeJob(); + const reordered: CronJob = { + ...original, + payload: { + kind: "command", + argv: ["printenv"], + env: { B: "2", A: "1" }, + }, + }; + const canonical: CronJob = { + ...reordered, + payload: { + kind: "command", + argv: ["printenv"], + env: { A: "1", B: "2" }, + }, + }; + + expect(resolveCronJobConfigRevision(reordered)).toBe(resolveCronJobConfigRevision(canonical)); + }); + + it("preserves order when case-insensitive command env keys collide on Windows", () => { + const firstWinsLast: CronJob = { + ...makeJob(), + payload: { + kind: "command", + argv: ["printenv"], + env: { Path: "first", PATH: "second" }, + }, + }; + const secondWinsLast: CronJob = { + ...firstWinsLast, + payload: { + kind: "command", + argv: ["printenv"], + env: { PATH: "second", Path: "first" }, + }, + }; + + expect(resolveCronJobConfigRevision(firstWinsLast)).not.toBe( + resolveCronJobConfigRevision(secondWinsLast), + ); + }); + + it("distinguishes inherited and explicitly cleared delivery fields", () => { + const inherited = makeJob(); + const explicitlyCleared: CronJob = { + ...inherited, + delivery: { + mode: "announce", + channel: "telegram", + to: "chat-1", + failureDestination: { channel: undefined }, + }, + }; + + expect(resolveCronJobConfigRevision(explicitlyCleared)).not.toBe( + resolveCronJobConfigRevision(inherited), + ); + }); + + it("is stable across the SQLite store round-trip", async () => { + const { storePath } = await makeStorePath(); + const job: CronJob = { + ...makeJob(), + agentId: undefined, + description: undefined, + payload: { + kind: "agentTurn", + message: "Summarize the day", + toolsAllow: ["read"], + toolsAllowIsDefault: false, + }, + delivery: { + mode: "announce", + failureDestination: { + channel: undefined, + accountId: undefined, + }, + }, + }; + + await saveCronStore(storePath, { version: 1, jobs: [job] }); + const reloaded = (await loadCronStore(storePath)).jobs[0]; + if (!reloaded) { + throw new Error("expected the persisted cron job to reload"); + } + + expect(resolveCronJobConfigRevision(reloaded)).toBe(resolveCronJobConfigRevision(job)); + }); + + it("matches SQLite normalization across schedule, payload, trigger, and alert variants", async () => { + const { storePath } = await makeStorePath(); + const jobs: CronJob[] = [ + { + ...makeJob(), + id: "command-empty-env", + schedule: { kind: "every", everyMs: Number.MAX_SAFE_INTEGER, anchorMs: 0 }, + payload: { kind: "command", argv: ["true"], env: {}, input: "" }, + failureAlert: false, + }, + { + ...makeJob(), + id: "default-tools-without-list", + schedule: { kind: "cron", expr: "0 9 * * *", tz: "" }, + payload: { + kind: "agentTurn", + message: "Summarize the day", + toolsAllowIsDefault: true, + }, + failureAlert: {}, + trigger: { script: "json({ fire: true })", once: true }, + }, + { + ...makeJob(), + id: "windows-env-key-order", + payload: { + kind: "command", + argv: ["printenv"], + env: { Path: "first", PATH: "second" }, + }, + }, + { + ...makeJob(), + id: "default-empty-tools", + schedule: { kind: "at", at: "2027-01-01T00:00:00.000Z" }, + payload: { + kind: "agentTurn", + message: "Summarize the day", + toolsAllow: [], + toolsAllowIsDefault: true, + }, + }, + { + ...makeJob(), + id: "on-exit-system-event", + schedule: { kind: "on-exit", command: "true", cwd: "/tmp" }, + sessionTarget: "main", + payload: { kind: "systemEvent", text: "Process exited" }, + delivery: undefined, + failureAlert: { after: 2, cooldownMs: 0, includeSkipped: false }, + }, + ]; + + await saveCronStore(storePath, { version: 1, jobs }); + const reloadedById = new Map((await loadCronStore(storePath)).jobs.map((job) => [job.id, job])); + + for (const job of jobs) { + const reloaded = reloadedById.get(job.id); + if (!reloaded) { + throw new Error(`expected persisted cron job ${job.id} to reload`); + } + expect(resolveCronJobConfigRevision(reloaded), job.id).toBe( + resolveCronJobConfigRevision(job), + ); + } + }); +}); diff --git a/src/cron/config-revision.ts b/src/cron/config-revision.ts new file mode 100644 index 000000000000..51b2e4851de4 --- /dev/null +++ b/src/cron/config-revision.ts @@ -0,0 +1,39 @@ +/** Opaque revision token for cron configuration, excluding scheduler-maintained state. */ +import { stableStringify } from "../agents/stable-stringify.js"; +import { sha256Base64Url } from "../infra/crypto-digest.js"; +import { projectCronJobThroughStorageCodec } from "./store/row-codec.js"; +import type { CronJob } from "./types.js"; + +function configRevisionDefinition(projected: CronJob) { + const { updatedAtMs: _updatedAtMs, state: _state, ...definition } = projected; + if (definition.payload.kind !== "command" || !definition.payload.env) { + return definition; + } + + const foldedKeys = new Set<string>(); + const hasWindowsCollision = Object.keys(definition.payload.env).some((key) => { + const folded = key.toLowerCase(); + if (foldedKeys.has(folded)) { + return true; + } + foldedKeys.add(folded); + return false; + }); + if (!hasWindowsCollision) { + return definition; + } + + // Windows resolves case-insensitive duplicate env keys in insertion order. + // Preserve that order only when it changes command execution semantics. + const { env, ...payload } = definition.payload; + return { ...definition, payload: { ...payload, envEntries: Object.entries(env) } }; +} + +/** Hashes the job definition while preserving meaningful own-undefined config fields. */ +export function resolveCronJobConfigRevision(job: CronJob): string { + // The storage projector canonicalizes every persisted config seam. Feed it + // neutral runtime fields so large or malformed trigger state cannot affect the token. + const projected = projectCronJobThroughStorageCodec({ ...job, updatedAtMs: 0, state: {} }); + const fingerprint = stableStringify(configRevisionDefinition(projected)); + return `sha256:${sha256Base64Url(fingerprint)}`; +} diff --git a/src/cron/normalize.test.ts b/src/cron/normalize.test.ts index 20e826c59a35..acf385744c06 100644 --- a/src/cron/normalize.test.ts +++ b/src/cron/normalize.test.ts @@ -73,6 +73,20 @@ function normalizeMainSystemEventCreateJob(params: { } describe("normalizeCronJobCreate", () => { + it("trims cron timezones and drops blank values", () => { + const trimmed = normalizeMainSystemEventCreateJob({ + name: "trimmed-timezone", + schedule: { kind: "cron", expr: "0 * * * *", tz: " Europe/Vienna " }, + }); + const blank = normalizeMainSystemEventCreateJob({ + name: "blank-timezone", + schedule: { kind: "cron", expr: "0 * * * *", tz: " " }, + }); + + expect(trimmed.schedule).toMatchObject({ tz: "Europe/Vienna" }); + expect(blank.schedule).not.toHaveProperty("tz"); + }); + it("normalizes trigger scripts and preserves patch clears", () => { const normalized = normalizeCronJobCreate({ name: "watcher", diff --git a/src/cron/normalize.ts b/src/cron/normalize.ts index f60a2213e2a6..388675f16932 100644 --- a/src/cron/normalize.ts +++ b/src/cron/normalize.ts @@ -100,6 +100,7 @@ function coerceSchedule(schedule: UnknownRecord) { ? rawKind : undefined; const exprRaw = normalizeOptionalString(schedule.expr) ?? ""; + const timezone = normalizeOptionalString(schedule.tz); const commandRaw = normalizeOptionalString(schedule.command) ?? ""; const cwdRaw = normalizeOptionalString(schedule.cwd) ?? ""; const everyMs = coerceFiniteScheduleNumber(schedule.everyMs); @@ -123,6 +124,11 @@ function coerceSchedule(schedule: UnknownRecord) { } else if ("expr" in next) { delete next.expr; } + if (timezone) { + next.tz = timezone; + } else if ("tz" in next) { + delete next.tz; + } if (everyMs !== undefined && everyMs >= 1) { next.everyMs = Math.floor(everyMs); diff --git a/src/cron/schedule-number.ts b/src/cron/schedule-number.ts index 979c706241dd..b20a190fb7ed 100644 --- a/src/cron/schedule-number.ts +++ b/src/cron/schedule-number.ts @@ -1,7 +1,8 @@ -/** Coerces cron schedule number fields with strict finite-number parsing. */ +/** Coerces cron schedule number fields with strict safe-range parsing. */ import { parseStrictFiniteNumber } from "@openclaw/normalization-core/number-coercion"; -/** Coerces schedule numeric fields without accepting partial or non-finite numbers. */ +/** Coerces schedule numeric fields without accepting partial, non-finite, or unsafe values. */ export function coerceFiniteScheduleNumber(value: unknown): number | undefined { - return parseStrictFiniteNumber(value); + const parsed = parseStrictFiniteNumber(value); + return parsed !== undefined && Math.abs(parsed) <= Number.MAX_SAFE_INTEGER ? parsed : undefined; } diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index e3c9477ab932..4ccf3c23e259 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -249,6 +249,8 @@ describe("coerceFiniteScheduleNumber", () => { expect(coerceFiniteScheduleNumber("0x10")).toBeUndefined(); expect(coerceFiniteScheduleNumber(Number.NaN)).toBeUndefined(); expect(coerceFiniteScheduleNumber(Infinity)).toBeUndefined(); + expect(coerceFiniteScheduleNumber(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined(); + expect(coerceFiniteScheduleNumber(String(Number.MAX_SAFE_INTEGER + 1))).toBeUndefined(); expect(coerceFiniteScheduleNumber(null)).toBeUndefined(); expect(coerceFiniteScheduleNumber(undefined)).toBeUndefined(); }); diff --git a/src/cron/service/ops.regression.test.ts b/src/cron/service/ops.regression.test.ts index 98112945f382..e920b0011a29 100644 --- a/src/cron/service/ops.regression.test.ts +++ b/src/cron/service/ops.regression.test.ts @@ -16,7 +16,7 @@ import { } from "../../process/command-queue.js"; import { CommandLane } from "../../process/lanes.js"; import { saveCronStore } from "../store.js"; -import { enqueueRun, run, start } from "./ops.js"; +import { enqueueRun, remove, run, start } from "./ops.js"; import type { CronEvent } from "./state.js"; import { createCronServiceState } from "./state.js"; import { onTimer } from "./timer.js"; @@ -31,6 +31,7 @@ function expectQueuedRunAck(result: unknown) { expect(ack.ok).toBe(true); expect(ack.enqueued).toBe(true); expect(typeof ack.runId).toBe("string"); + return ack.runId as string; } function requireMockCall( @@ -92,7 +93,11 @@ describe("cron service ops regressions", () => { } }); - it("skips forced manual runs while a timer-triggered run is in progress", async () => { + it("records queued forced runs that lose a timer race as skipped", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + const store = opsRegressionFixtures.makeStorePath(); const dueAt = Date.now() - 1; const job = createIsolatedRegressionJob({ @@ -105,11 +110,20 @@ describe("cron service ops regressions", () => { }); await saveCronStore(store.storePath, { version: 1, jobs: [job] }); + const blockerStarted = createDeferred<void>(); + const releaseBlocker = createDeferred<void>(); + const blocker = enqueueCommandInLane(CommandLane.Cron, async () => { + blockerStarted.resolve(); + return await releaseBlocker.promise; + }); + await blockerStarted.promise; + let resolveRun: | ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void) | undefined; const started = createDeferred<void>(); const finished = createDeferred<void>(); + const events: CronEvent[] = []; const runIsolatedAgentJob = vi.fn( async () => await new Promise<{ status: "ok" | "error" | "skipped"; summary?: string; error?: string }>( @@ -127,6 +141,7 @@ describe("cron service ops regressions", () => { requestHeartbeat: vi.fn(), runIsolatedAgentJob, onEvent: (evt: CronEvent) => { + events.push(evt); if (evt.jobId !== job.id) { return; } @@ -138,17 +153,31 @@ describe("cron service ops regressions", () => { }, }); + const ack = await enqueueRun(state, job.id, "force"); + const runId = expectQueuedRunAck(ack); + const timerPromise = onTimer(state); await started.promise; expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); - const manualResult = await run(state, job.id, "force"); - expect(manualResult).toEqual({ ok: true, ran: false, reason: "already-running" }); + releaseBlocker.resolve(); + await blocker; + await waitForActiveTasks(5_000); expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1); + expect(events).toContainEqual( + expect.objectContaining({ + jobId: job.id, + action: "finished", + status: "skipped", + error: "queued manual run skipped before execution: already-running", + runId, + }), + ); resolveRun?.({ status: "ok", summary: "done" }); await finished.promise; await timerPromise; + clearCommandLane(CommandLane.Cron); }); it("does not double-run a job when cron.run overlaps a due timer tick", async () => { @@ -497,6 +526,7 @@ describe("cron service ops regressions", () => { await blockerStarted.promise; const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const })); + const events: CronEvent[] = []; const state = createCronServiceState({ cronEnabled: true, storePath: store.storePath, @@ -505,10 +535,11 @@ describe("cron service ops regressions", () => { enqueueSystemEvent: vi.fn(), requestHeartbeat: vi.fn(), runIsolatedAgentJob, + onEvent: (evt) => events.push(evt), }); const ack = await enqueueRun(state, job.id, "force"); - expectQueuedRunAck(ack); + const runId = expectQueuedRunAck(ack); state.stopped = true; releaseBlocker.resolve(); @@ -519,6 +550,67 @@ describe("cron service ops regressions", () => { expect( state.store?.jobs.find((entry) => entry.id === job.id)?.state.runningAtMs, ).toBeUndefined(); + expect(events).toContainEqual( + expect.objectContaining({ + jobId: job.id, + action: "finished", + status: "skipped", + error: "queued manual run skipped before execution: stopped", + runId, + }), + ); + + clearCommandLane(CommandLane.Cron); + }); + + it("emits one terminal event when a queued job is removed during execution", async () => { + vi.useRealTimers(); + clearCommandLane(CommandLane.Cron); + setCommandLaneConcurrency(CommandLane.Cron, 1); + + const store = opsRegressionFixtures.makeStorePath(); + const dueAt = Date.parse("2026-02-06T10:05:04.000Z"); + const job = createDueIsolatedJob({ + id: "queued-removed-manual", + nowMs: dueAt, + nextRunAtMs: dueAt, + }); + await saveCronStore(store.storePath, { version: 1, jobs: [job] }); + + const started = createDeferred<void>(); + const execution = createDeferred<{ status: "ok"; summary: string }>(); + const events: CronEvent[] = []; + const state = createCronServiceState({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + nowMs: () => dueAt, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => { + started.resolve(); + return await execution.promise; + }), + onEvent: (evt) => events.push(evt), + }); + + const ack = await enqueueRun(state, job.id, "force"); + const runId = expectQueuedRunAck(ack); + await started.promise; + + await expect(remove(state, job.id)).resolves.toEqual({ ok: true, removed: true }); + execution.resolve({ status: "ok", summary: "completed after removal" }); + await waitForActiveTasks(5_000); + + const terminalEvents = events.filter((evt) => evt.action === "finished" && evt.runId === runId); + expect(terminalEvents).toEqual([ + expect.objectContaining({ + jobId: job.id, + status: "ok", + summary: "completed after removal", + }), + ]); + expect(state.store?.jobs.some((entry) => entry.id === job.id)).toBe(false); clearCommandLane(CommandLane.Cron); }); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 0e8b8a97046d..84eea03ca673 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -53,6 +53,7 @@ import { locked } from "./locked.js"; import { normalizeOptionalAgentId } from "./normalize.js"; import type { CronAddOptions, + CronEvent, CronServiceState, CronUpdatePrecondition, CronWakeMode, @@ -800,6 +801,7 @@ type PreparedManualRun = jobId: string; runId?: string; taskRunId?: string; + terminalTracker?: ManualRunTerminalTracker; activeJobMarker?: CronActiveJobMarker; startedAt: number; executionJob: CronJob; @@ -809,8 +811,22 @@ type PreparedManualRun = type ManualRunOptions = { runId?: string; payload?: CronPayload; + terminalTracker?: ManualRunTerminalTracker; }; +type ManualRunTerminalTracker = { emitted: boolean }; + +function emitManualRunFinished( + state: CronServiceState, + evt: CronEvent & { action: "finished" }, + tracker?: ManualRunTerminalTracker, +): void { + emit(state, evt); + if (tracker) { + tracker.emitted = true; + } +} + type ManualRunDisposition = | Extract<PreparedManualRun, { ran: false }> | { ok: true; runnable: true }; @@ -831,6 +847,8 @@ async function skipInvalidPersistedManualRun(params: { state: CronServiceState; job: CronJob; mode?: "due" | "force"; + runId?: string; + terminalTracker?: ManualRunTerminalTracker; error: unknown; }) { const endedAt = params.state.deps.nowMs(); @@ -852,19 +870,24 @@ async function skipInvalidPersistedManualRun(params: { { preserveSchedule: params.mode === "force" }, ); - emit(params.state, { - jobId: params.job.id, - action: "finished", - status: "skipped", - error: errorText, - diagnostics, - runAtMs: endedAt, - durationMs: params.job.state.lastDurationMs, - nextRunAtMs: params.job.state.nextRunAtMs, - deliveryStatus: params.job.state.lastDeliveryStatus, - deliveryError: params.job.state.lastDeliveryError, - failureNotificationDelivery: failureNotificationDeliveryFromJobState(params.job), - }); + emitManualRunFinished( + params.state, + { + jobId: params.job.id, + action: "finished", + status: "skipped", + error: errorText, + diagnostics, + runId: params.runId, + runAtMs: endedAt, + durationMs: params.job.state.lastDurationMs, + nextRunAtMs: params.job.state.nextRunAtMs, + deliveryStatus: params.job.state.lastDeliveryStatus, + deliveryError: params.job.state.lastDeliveryError, + failureNotificationDelivery: failureNotificationDeliveryFromJobState(params.job), + }, + params.terminalTracker, + ); if (shouldDelete && params.state.store) { params.state.store.jobs = params.state.store.jobs.filter((entry) => entry.id !== params.job.id); @@ -965,6 +988,8 @@ async function inspectManualRunPreflight( state: CronServiceState, id: string, mode?: "due" | "force", + runId?: string, + terminalTracker?: ManualRunTerminalTracker, ): Promise<ManualRunPreflightResult> { return await locked(state, async () => { warnIfDisabled(state, "run"); @@ -983,7 +1008,7 @@ async function inspectManualRunPreflight( try { assertSupportedJobSpec(job); } catch (error) { - await skipInvalidPersistedManualRun({ state, job, mode, error }); + await skipInvalidPersistedManualRun({ state, job, mode, runId, terminalTracker, error }); return { ok: true, ran: false, reason: "invalid-spec" as const }; } if (typeof job.state.runningAtMs === "number") { @@ -1021,7 +1046,13 @@ async function prepareManualRun( mode?: "due" | "force", opts?: ManualRunOptions, ): Promise<PreparedManualRun> { - const preflight = await inspectManualRunPreflight(state, id, mode); + const preflight = await inspectManualRunPreflight( + state, + id, + mode, + opts?.runId, + opts?.terminalTracker, + ); if (!preflight.ok) { return preflight; } @@ -1080,6 +1111,7 @@ async function prepareManualRun( jobId: job.id, runId: opts?.runId ?? taskRunId, taskRunId, + terminalTracker: opts?.terminalTracker, activeJobMarker, startedAt: preflight.now, executionJob, @@ -1109,12 +1141,49 @@ async function finishPreparedManualRun( coreResult = { status: "error", error: normalizeCronRunErrorText(err) }; } const endedAt = state.deps.nowMs(); + const emitMissingQueuedTerminal = () => { + const tracker = prepared.terminalTracker; + if (!tracker || tracker.emitted) { + return; + } + const job = state.store?.jobs.find((entry) => entry.id === jobId); + const triggerSkipped = coreResult.status === "ok" && coreResult.triggerEval?.fired === false; + // enqueueRun acknowledges a concrete run id, so every accepted request + // needs one terminal event even if the job or service owner changes mid-run. + emitManualRunFinished( + state, + { + jobId, + action: "finished", + job, + status: triggerSkipped ? "skipped" : coreResult.status, + error: triggerSkipped + ? "queued manual run skipped: trigger condition not met" + : coreResult.error, + summary: triggerSkipped ? undefined : coreResult.summary, + diagnostics: coreResult.diagnostics, + delivered: coreResult.delivered, + delivery: coreResult.delivery, + sessionId: coreResult.sessionId, + sessionKey: coreResult.sessionKey, + runId, + runAtMs: startedAt, + durationMs: Math.max(0, endedAt - startedAt), + nextRunAtMs: job?.state.nextRunAtMs, + model: coreResult.model, + provider: coreResult.provider, + usage: coreResult.usage, + }, + tracker, + ); + }; tryFinishManualTaskRun(state, { taskRunId, coreResult, endedAt, }); if (!isCronActiveJobMarkerCurrent(prepared.activeJobMarker)) { + emitMissingQueuedTerminal(); return; } @@ -1161,30 +1230,34 @@ async function finishPreparedManualRun( triggerEval: coreResult.triggerEval, }); - emit(state, { - jobId: job.id, - action: "finished", - job, - status: coreResult.status, - error: coreResult.error, - summary: coreResult.summary, - diagnostics: coreResult.diagnostics, - delivered: job.state.lastDelivered, - deliveryStatus: job.state.lastDeliveryStatus, - deliveryError: job.state.lastDeliveryError, - failureNotificationDelivery: failureNotificationDeliveryFromJobState(job), - delivery: coreResult.delivery, - sessionId: coreResult.sessionId, - sessionKey: coreResult.sessionKey, - runId, - runAtMs: startedAt, - durationMs: job.state.lastDurationMs, - nextRunAtMs: job.state.nextRunAtMs, - ...(coreResult.triggerEval?.fired ? { triggerFired: true } : {}), - model: coreResult.model, - provider: coreResult.provider, - usage: coreResult.usage, - }); + emitManualRunFinished( + state, + { + jobId: job.id, + action: "finished", + job, + status: coreResult.status, + error: coreResult.error, + summary: coreResult.summary, + diagnostics: coreResult.diagnostics, + delivered: job.state.lastDelivered, + deliveryStatus: job.state.lastDeliveryStatus, + deliveryError: job.state.lastDeliveryError, + failureNotificationDelivery: failureNotificationDeliveryFromJobState(job), + delivery: coreResult.delivery, + sessionId: coreResult.sessionId, + sessionKey: coreResult.sessionKey, + runId, + runAtMs: startedAt, + durationMs: job.state.lastDurationMs, + nextRunAtMs: job.state.nextRunAtMs, + ...(coreResult.triggerEval?.fired ? { triggerFired: true } : {}), + model: coreResult.model, + provider: coreResult.provider, + usage: coreResult.usage, + }, + prepared.terminalTracker, + ); } if (shouldDelete && state.store) { @@ -1230,6 +1303,7 @@ async function finishPreparedManualRun( if (finalized) { armTimer(state); } + emitMissingQueuedTerminal(); } finally { clearManualCronJobActive(state, jobId, prepared.activeJobMarker); } @@ -1258,11 +1332,31 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du } const runId = `manual:${id}:${state.deps.nowMs()}:${nextManualRunId++}`; + const terminalTracker: ManualRunTerminalTracker = { emitted: false }; void enqueueCommandInLane( CommandLane.Cron, async () => { - const result = await run(state, id, mode, { runId }); + const result = await run(state, id, mode, { runId, terminalTracker }); if (result.ok && "ran" in result && !result.ran) { + if (result.reason !== "invalid-spec") { + const finishedAt = state.deps.nowMs(); + const job = state.store?.jobs.find((entry) => entry.id === id); + emitManualRunFinished( + state, + { + jobId: id, + action: "finished", + job, + status: "skipped", + error: `queued manual run skipped before execution: ${result.reason}`, + runId, + runAtMs: finishedAt, + durationMs: 0, + nextRunAtMs: job?.state.nextRunAtMs, + }, + terminalTracker, + ); + } state.deps.log.info( { jobId: id, runId, reason: result.reason }, "cron: queued manual run skipped before execution", @@ -1280,6 +1374,30 @@ export async function enqueueRun(state: CronServiceState, id: string, mode?: "du }, }, ).catch((err: unknown) => { + if (terminalTracker.emitted) { + state.deps.log.error( + { jobId: id, runId, err: String(err) }, + "cron: queued manual run failed after emitting its terminal event", + ); + return; + } + const finishedAt = state.deps.nowMs(); + const job = state.store?.jobs.find((entry) => entry.id === id); + emitManualRunFinished( + state, + { + jobId: id, + action: "finished", + job, + status: "error", + error: normalizeCronRunErrorText(err), + runId, + runAtMs: finishedAt, + durationMs: 0, + nextRunAtMs: job?.state.nextRunAtMs, + }, + terminalTracker, + ); state.deps.log.error( { jobId: id, runId, err: String(err) }, "cron: queued manual run background execution failed", diff --git a/src/cron/stagger.test.ts b/src/cron/stagger.test.ts index 51b655a04026..e8bbe400edf0 100644 --- a/src/cron/stagger.test.ts +++ b/src/cron/stagger.test.ts @@ -34,6 +34,7 @@ describe("cron stagger helpers", () => { expect(normalizeCronStaggerMs("abc")).toBeUndefined(); expect(normalizeCronStaggerMs("1e3")).toBeUndefined(); expect(normalizeCronStaggerMs("0x10")).toBeUndefined(); + expect(normalizeCronStaggerMs(Number.MAX_SAFE_INTEGER + 1)).toBeUndefined(); }); it("resolves effective stagger for cron schedules", () => { diff --git a/src/cron/stagger.ts b/src/cron/stagger.ts index 5457895a4563..31c20e574698 100644 --- a/src/cron/stagger.ts +++ b/src/cron/stagger.ts @@ -44,7 +44,8 @@ export function normalizeCronStaggerMs(raw: unknown): number | undefined { if (!Number.isFinite(numeric)) { return undefined; } - return Math.max(0, Math.floor(numeric)); + const normalized = Math.max(0, Math.floor(numeric)); + return Number.isSafeInteger(normalized) ? normalized : undefined; } /** Returns the default anti-thundering-herd stagger for top-of-hour recurring schedules. */ diff --git a/src/cron/store/row-codec.ts b/src/cron/store/row-codec.ts index d67203a63db8..fcbefbcc39b6 100644 --- a/src/cron/store/row-codec.ts +++ b/src/cron/store/row-codec.ts @@ -283,6 +283,20 @@ function rowToCronJob(row: CronJobRow): CronJob | null { }; } +/** Projects a live job through the same normalization/codecs used by SQLite persistence. */ +export function projectCronJobThroughStorageCodec(job: CronJob): CronJob { + const normalized = normalizeCronJobForSqlite(job); + if (!normalized) { + throw new Error(`cannot project invalid cron job ${job.id}`); + } + const row = bindCronJobRow("config-revision", normalized, 0) as CronJobRow; + const projected = rowToCronJob(row); + if (!projected) { + throw new Error(`cannot project cron job ${job.id} through storage codecs`); + } + return projected; +} + /** Loads cron rows in config order with deterministic fallbacks for old rows. */ export function loadCronRows(db: DatabaseSync, storeKey: string): CronJobRow[] { return executeSqliteQuerySync( diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index 8a9f19185c19..f0575c982ec2 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -1448,6 +1448,10 @@ describe("doctor health contributions", () => { await contribution.run(ctx); + expect(mocks.detectLegacyStateMigrations).toHaveBeenCalledWith({ + cfg, + crossStateDirImports: false, + }); expect(mocks.runLegacyStateMigrations).toHaveBeenCalledWith({ detected, config: cfg, @@ -1455,6 +1459,48 @@ describe("doctor health contributions", () => { }); }); + it("grants legacy-state cross-state imports only to capable doctor origins", async () => { + const contribution = requireDoctorContribution("doctor:legacy-state"); + const detected = { preview: [], warnings: [], notices: [] }; + mocks.detectLegacyStateMigrations.mockResolvedValue(detected); + + const directRepairContext = { + cfg: {}, + sourceConfigValid: true, + prompter: buildDoctorPrompter(true), + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { nonInteractive: true, repair: true, crossStateDirImports: true }, + } as unknown as Parameters<(typeof contribution)["run"]>[0]; + await contribution.run(directRepairContext); + expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({ + cfg: {}, + crossStateDirImports: true, + }); + + const interactivePrompter = buildDoctorPrompter(false); + interactivePrompter.repairMode.canPrompt = true; + interactivePrompter.repairMode.nonInteractive = false; + await contribution.run({ + ...directRepairContext, + prompter: interactivePrompter, + options: { crossStateDirImports: true }, + }); + expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({ + cfg: {}, + crossStateDirImports: true, + }); + + const automatedRepairContext = { + ...directRepairContext, + options: { nonInteractive: true, repair: true, crossStateDirImports: false }, + }; + await contribution.run(automatedRepairContext); + expect(mocks.detectLegacyStateMigrations).toHaveBeenLastCalledWith({ + cfg: {}, + crossStateDirImports: false, + }); + }); + it("prints legacy state migration notices during manual doctor runs", async () => { const contribution = requireDoctorContribution("doctor:legacy-state"); const detected = { preview: ["legacy sessions"], warnings: [], notices: [] }; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 240281ab3b49..234b0222a258 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -522,16 +522,15 @@ async function runLegacyStateHealth(ctx: DoctorHealthFlowContext): Promise<void> const { detectLegacyStateMigrations, runLegacyStateMigrations } = await import("../commands/doctor-state-migrations.js"); const { note } = await loadNoteModule(); - // Cross-state-dir imports (default home dir -> OPENCLAW_STATE_DIR) are - // allowed here only when the operator either confirms the previewed plan - // interactively or asked for repair; a bare non-interactive doctor stays - // read-only toward the default state dir. + // Only a direct operator-owned doctor may inspect the default state dir for + // imports. Automated repair callers explicitly lack this capability so a + // temporary OPENCLAW_STATE_DIR cannot capture and archive production trust. + const operatorCanApproveCrossStateDirImports = + ctx.prompter.repairMode.canPrompt || ctx.prompter.shouldRepair; const legacyState = await detectLegacyStateMigrations({ cfg: ctx.cfg, crossStateDirImports: - ctx.options.nonInteractive !== true || - ctx.options.repair === true || - ctx.options.yes === true, + ctx.options.crossStateDirImports === true && operatorCanApproveCrossStateDirImports, }); if (legacyState.warnings.length > 0) { note(legacyState.warnings.join("\n"), "Doctor warnings"); diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index c4013aa577c0..519b8d107999 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -198,6 +198,45 @@ describe("gateway cli backend live helpers", () => { }); }); + it("builds Claude continuity prompts without revealing the hidden note", () => { + const { buildClaudeCliResumeContinuityProbe } = liveHelpers; + const memoryToken = "CLI-MEM-A1B2C3D4E5F6"; + + const probe = buildClaudeCliResumeContinuityProbe({ + firstTurnNonce: "112233", + resumeNonce: "445566", + memoryToken, + }); + + expect(probe.firstTurnPrompt).toBe( + "Do not inspect files or run tools. Reply with exactly: CLI-BACKEND-112233.", + ); + expect(probe.resumePrompt).toBe( + "Do not inspect files or run tools. " + + "What private session note were you asked to remember earlier? " + + "Reply with exactly: CLI backend RESUME OK 445566 <remembered-note>.", + ); + expect(probe.firstTurnPrompt).not.toContain(memoryToken); + expect(probe.resumePrompt).not.toContain(memoryToken); + expect(probe.injectedContext).toContain(memoryToken); + expect(probe.expectedResumeReply).toBe("CLI backend RESUME OK 445566 CLI-MEM-A1B2C3D4E5F6."); + }); + + it("finds only Claude-imported native session ids", () => { + const { resolveImportedClaudeCliSessionId } = liveHelpers; + + expect( + resolveImportedClaudeCliSessionId([ + null, + { __openclaw: "invalid" }, + { __openclaw: { importedFrom: "codex-cli", cliSessionId: "wrong-provider" } }, + { __openclaw: { importedFrom: "claude-cli", cliSessionId: 42 } }, + { __openclaw: { importedFrom: "claude-cli", cliSessionId: "claude-session" } }, + ]), + ).toBe("claude-session"); + expect(resolveImportedClaudeCliSessionId([])).toBeUndefined(); + }); + it("retries Codex CLI timeout payloads only before the final attempt", async () => { const { isCliBackendLiveTimeoutPayload, shouldRetryCliBackendLiveTimeout } = await import("./gateway-cli-backend.live-helpers.js"); diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index ad6999a7b475..53206b90eb03 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -73,6 +73,15 @@ export type CliBackendLiveProviderSkipDecision = { message: string; }; +export type ClaudeCliResumeContinuityProbe = { + firstTurnMarker: string; + firstTurnPrompt: string; + injectedContext: string; + resumePrompt: string; + expectedFirstReply: string; + expectedResumeReply: string; +}; + function normalizeCliRuntimeModelTarget(raw: string | undefined): string | undefined { if (!raw) { return undefined; @@ -272,6 +281,44 @@ export function matchesCliBackendReply(text: string, expected: string): boolean ); } +export function buildClaudeCliResumeContinuityProbe(params: { + firstTurnNonce: string; + resumeNonce: string; + memoryToken: string; +}): ClaudeCliResumeContinuityProbe { + const firstTurnMarker = `CLI-BACKEND-${params.firstTurnNonce}`; + return { + firstTurnMarker, + firstTurnPrompt: `Do not inspect files or run tools. Reply with exactly: ${firstTurnMarker}.`, + injectedContext: + `For this turn only, remember the private session note ${params.memoryToken} for a later turn. ` + + "Do not include that note in this turn's reply.", + resumePrompt: + "Do not inspect files or run tools. " + + "What private session note were you asked to remember earlier? " + + `Reply with exactly: CLI backend RESUME OK ${params.resumeNonce} <remembered-note>.`, + expectedFirstReply: `${firstTurnMarker}.`, + expectedResumeReply: `CLI backend RESUME OK ${params.resumeNonce} ${params.memoryToken}.`, + }; +} + +export function resolveImportedClaudeCliSessionId(messages: unknown[]): string | undefined { + for (const message of messages) { + const metadata = + typeof message === "object" && message !== null + ? (message as Record<string, unknown>)["__openclaw"] + : undefined; + if (typeof metadata !== "object" || metadata === null) { + continue; + } + const imported = metadata as { cliSessionId?: unknown; importedFrom?: unknown }; + if (imported.importedFrom === "claude-cli" && typeof imported.cliSessionId === "string") { + return imported.cliSessionId; + } + } + return undefined; +} + export function withClaudeMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] { const next = [...args]; if (!next.includes("--strict-mcp-config")) { diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index 4444033e7aee..60a47e64a084 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -4,15 +4,26 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { resolveCliBackendConfig, resolveCliBackendLiveTest } from "../agents/cli-backends.js"; +import { + __testing as cliBackendsTesting, + resolveCliBackendConfig, + resolveCliBackendLiveTest, +} from "../agents/cli-backends.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; import { shouldSkipLiveProviderDrift } from "../agents/live-test-provider-drift.js"; import { parseModelRef } from "../agents/model-selection.js"; import { clearRuntimeConfigSnapshot, type OpenClawConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; +import { + createMockPluginRegistry, + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "../plugin-sdk/testing.js"; import { setTestEnvValue } from "../test-utils/env.js"; +import { resolveClaudeCliSessionFilePath } from "./cli-session-history.js"; import { applyCliBackendLiveEnv, + buildClaudeCliResumeContinuityProbe, createBootstrapWorkspace, ensurePairedTestGatewayClientIdentity, getFreeGatewayPort, @@ -23,6 +34,7 @@ import { resolveCliBackendLiveArgs, resolveCliBackendLiveModelSelection, resolveCliBackendLiveProviderSkipDecision, + resolveImportedClaudeCliSessionId, resolveCliModelSwitchProbeTarget, restoreCliBackendLiveEnv, shouldAllowCliBackendLiveProviderSkip, @@ -58,6 +70,7 @@ const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const MCP_SCHEMA_PROBE_PLUGIN_ID = "mcp-schema-probe"; const MCP_SCHEMA_PROBE_TOOL_NAME = "mcp_schema_probe_no_args"; +const CLI_CONTINUITY_PROBE_PLUGIN_ID = "cli-continuity-probe"; const DEFAULT_PROVIDER = "claude-cli"; const DEFAULT_MODEL = @@ -315,6 +328,20 @@ describeLive("gateway live (cli backend)", () => { const modelSwitchTarget = enableCliModelSwitchProbe ? modelSelection.configModelSwitchTarget : undefined; + const sessionKey = "agent:dev:live-cli-backend"; + const nonce = randomBytes(3).toString("hex").toUpperCase(); + const memoryNonce = randomBytes(6).toString("hex").toUpperCase(); + const memoryToken = `CLI-MEM-${memoryNonce}`; + const resumeNonce = randomBytes(3).toString("hex").toUpperCase(); + const enableCliResumeContinuityProbe = + providerId === "claude-cli" && CLI_RESUME && !modelSwitchTarget; + const resumeContinuityProbe = enableCliResumeContinuityProbe + ? buildClaudeCliResumeContinuityProbe({ + firstTurnNonce: nonce, + resumeNonce, + memoryToken, + }) + : undefined; logCliBackendLiveStep("model-selected", { providerId, modelKey, @@ -371,7 +398,7 @@ describeLive("gateway live (cli backend)", () => { : undefined; const useMinimalToolsProfile = providerId === "codex-cli" && !schemaProbePluginPath; setTestEnvValue("OPENCLAW_STATE_DIR", stateDir); - const bundleMcp = backendResolved?.bundleMcp === true; + const bundleMcp = backendResolved?.bundleMcp === true && !resumeContinuityProbe; const bootstrapWorkspace = await createBootstrapWorkspace(tempDir); const disableMcpConfig = process.env.OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0"; let cliArgs = baseCliArgs; @@ -495,6 +522,41 @@ describeLive("gateway live (cli backend)", () => { controlUiEnabled: false, }); logCliBackendLiveStep("server-started"); + if (resumeContinuityProbe) { + const continuityHookRegistry = createMockPluginRegistry([ + { + pluginId: CLI_CONTINUITY_PROBE_PLUGIN_ID, + hookName: "before_prompt_build", + handler: async (event: unknown, ctx: unknown) => { + const prompt = (event as { prompt?: unknown }).prompt; + const hookSessionKey = (ctx as { sessionKey?: unknown }).sessionKey; + if ( + hookSessionKey !== sessionKey || + typeof prompt !== "string" || + !prompt.includes(resumeContinuityProbe.firstTurnMarker) + ) { + return undefined; + } + return { prependContext: resumeContinuityProbe.injectedContext }; + }, + }, + ]); + initializeGlobalHookRunner(continuityHookRegistry); + // Bundled MCP capture intentionally retires a Claude child after each turn. This probe + // isolates the exact warm-session path while leaving production defaults untouched. + if (!backendResolved) { + throw new Error(`missing CLI backend metadata for ${providerId}`); + } + cliBackendsTesting.setDepsForTest({ + resolveRuntimeCliBackends: () => [ + { + ...backendResolved, + pluginId: backendResolved.pluginId ?? CLI_CONTINUITY_PROBE_PLUGIN_ID, + bundleMcp: false, + }, + ], + }); + } client = await connectTestGatewayClient({ url: `ws://127.0.0.1:${port}`, token, @@ -503,10 +565,6 @@ describeLive("gateway live (cli backend)", () => { logCliBackendLiveStep("client-connected"); const activeClient = client; - const sessionKey = "agent:dev:live-cli-backend"; - const nonce = randomBytes(3).toString("hex").toUpperCase(); - const memoryNonce = randomBytes(3).toString("hex").toUpperCase(); - const memoryToken = `CLI-MEM-${memoryNonce}`; logCliBackendLiveStep("agent-request:start", { sessionKey, nonce }); const payload = await requestWithCodexTimeoutRetry( providerId, @@ -520,11 +578,13 @@ describeLive("gateway live (cli backend)", () => { message: providerId === "codex-cli" ? `Do not inspect files or run tools. Reply with exactly: CLI-BACKEND-${nonce}.` - : enableCliModelSwitchProbe - ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + - ` Also remember this session note for later: ${memoryToken}.` + - " Do not include the note in your reply." - : `Please include the token CLI-BACKEND-${nonce} in your reply.`, + : resumeContinuityProbe + ? resumeContinuityProbe.firstTurnPrompt + : enableCliModelSwitchProbe + ? `Please include the token CLI-BACKEND-${nonce} in your reply.` + + ` Also remember this session note for later: ${memoryToken}.` + + " Do not include the note in your reply." + : `Please include the token CLI-BACKEND-${nonce} in your reply.`, deliver: false, timeout: timeouts.agentTimeoutSeconds, }, @@ -548,6 +608,11 @@ describeLive("gateway live (cli backend)", () => { }; if (enableCliModelSwitchProbe) { expect(text.trim().length).toBeGreaterThan(0); + } else if (resumeContinuityProbe) { + expect(matchesCliBackendReply(text, resumeContinuityProbe.expectedFirstReply)).toBe( + true, + ); + expect(text).not.toContain(memoryToken); } else { expect(text).toContain(`CLI-BACKEND-${nonce}`); } @@ -612,8 +677,31 @@ describeLive("gateway live (cli backend)", () => { ), ).toBe(true); } else if (CLI_RESUME) { - const resumeNonce = randomBytes(3).toString("hex").toUpperCase(); logCliBackendLiveStep("agent-resume:start", { sessionKey, resumeNonce }); + if (resumeContinuityProbe) { + const nativeHistory = await activeClient.request<{ messages?: unknown[] }>( + "chat.history", + { sessionKey }, + ); + const cliSessionId = resolveImportedClaudeCliSessionId(nativeHistory.messages ?? []); + expect(JSON.stringify(nativeHistory.messages ?? [])).toContain(memoryToken); + expect(cliSessionId).toBeTruthy(); + const cliSessionFile = cliSessionId + ? resolveClaudeCliSessionFilePath({ cliSessionId }) + : undefined; + expect(cliSessionFile).toBeTruthy(); + if (!cliSessionFile) { + throw new Error("Claude CLI continuity probe could not locate its native transcript"); + } + // The warm child keeps this turn in memory. Remove Claude's native transcript so + // --resume and raw-history reseed cannot recover the hidden note if that child is lost. + await fs.rm(cliSessionFile, { force: true }); + const rawHistory = await activeClient.request<{ messages?: unknown[] }>( + "chat.history", + { sessionKey }, + ); + expect(JSON.stringify(rawHistory.messages ?? [])).not.toContain(memoryToken); + } const resumePayload = await requestWithCodexTimeoutRetry( providerId, "agent resume request", @@ -626,7 +714,9 @@ describeLive("gateway live (cli backend)", () => { message: providerId === "codex-cli" ? `Do not inspect files or run tools. Reply with exactly: CLI-RESUME-${resumeNonce}.` - : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`, + : resumeContinuityProbe + ? resumeContinuityProbe.resumePrompt + : `Reply with exactly: CLI backend RESUME OK ${resumeNonce}.`, deliver: false, timeout: timeouts.agentTimeoutSeconds, }, @@ -643,6 +733,10 @@ describeLive("gateway live (cli backend)", () => { const resumeText = extractPayloadText(resumePayload?.result); if (providerId === "codex-cli") { expect(resumeText).toContain(`CLI-RESUME-${resumeNonce}`); + } else if (resumeContinuityProbe) { + expect( + matchesCliBackendReply(resumeText, resumeContinuityProbe.expectedResumeReply), + ).toBe(true); } else { expect( matchesCliBackendReply(resumeText, `CLI backend RESUME OK ${resumeNonce}.`), @@ -708,6 +802,8 @@ describeLive("gateway live (cli backend)", () => { await server?.close(); } } finally { + cliBackendsTesting.resetDepsForTest(); + resetGlobalHookRunner(); await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); restoreCliBackendLiveEnv(previousEnv); logCliBackendLiveStep("cleanup:done"); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 068d7f0a2e62..a9382bccddac 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -15,6 +15,7 @@ import { validateWakeParams, } from "../../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveCronJobConfigRevision } from "../../cron/config-revision.js"; import { assertValidCronAnnounceDelivery, assertValidCronCreateDelivery, @@ -73,9 +74,19 @@ type CronListCallerScopeContext = { }; }; +class CronJobConfigRevisionConflictError extends Error { + constructor( + readonly expectedConfigRevision: string, + readonly actualConfigRevision: string, + ) { + super("cron job definition no longer matches the loaded version"); + } +} + function cronJobReadView(job: CronJob) { return { ...job, + configRevision: resolveCronJobConfigRevision(job), nextRunAtMs: job.state.nextRunAtMs, lastRunAtMs: job.state.lastRunAtMs, lastRunStatus: job.state.lastRunStatus ?? job.state.lastStatus, @@ -620,6 +631,7 @@ export const cronHandlers: GatewayRequestHandlers = { id?: string; jobId?: string; patch: Record<string, unknown>; + expectedConfigRevision?: string; }; const callerScope = readCronCallerScope(client); const jobId = p.id ?? p.jobId; @@ -694,6 +706,15 @@ export const cronHandlers: GatewayRequestHandlers = { ) { throw new Error(`unknown cron job id: ${jobId}`); } + if (p.expectedConfigRevision !== undefined) { + const actualConfigRevision = resolveCronJobConfigRevision(lockedJob); + if (actualConfigRevision !== p.expectedConfigRevision) { + throw new CronJobConfigRevisionConflictError( + p.expectedConfigRevision, + actualConfigRevision, + ); + } + } await assertValidCronUpdatePatch({ cfg, defaultAgentId: context.cron.getDefaultAgentId(), @@ -702,6 +723,24 @@ export const cronHandlers: GatewayRequestHandlers = { }); }); } catch (err) { + if (err instanceof CronJobConfigRevisionConflictError) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "cron job definition no longer matches the loaded version; review the latest version before retrying", + { + details: { + code: "CRON_JOB_CHANGED", + expectedConfigRevision: err.expectedConfigRevision, + actualConfigRevision: err.actualConfigRevision, + }, + }, + ), + ); + return; + } if ( !(err instanceof TypeError) && !(err instanceof RangeError) && @@ -720,7 +759,7 @@ export const cronHandlers: GatewayRequestHandlers = { return; } context.logGateway.info("cron: job updated", { jobId }); - respond(true, job, undefined); + respond(true, cronJobReadView(job), undefined); }, "cron.remove": async ({ params, respond, context, client }) => { if (!validateCronRemoveParams(params)) { diff --git a/src/gateway/server-methods/cron.validation.test.ts b/src/gateway/server-methods/cron.validation.test.ts index d65132d8bbe7..866cdb4a5bf1 100644 --- a/src/gateway/server-methods/cron.validation.test.ts +++ b/src/gateway/server-methods/cron.validation.test.ts @@ -357,6 +357,14 @@ function expectCronSuccess(respond: ReturnType<typeof vi.fn>): void { expect(respond).toHaveBeenCalledWith(true, expect.objectContaining({ id: "cron-1" }), undefined); } +function expectCronReadSuccess(respond: ReturnType<typeof vi.fn>, job: CronJob): void { + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ ...job, configRevision: expect.stringMatching(/^sha256:/) }), + undefined, + ); +} + function requireRecord(value: unknown, label: string): Record<string, unknown> { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`expected ${label} to be an object`); @@ -501,7 +509,7 @@ describe("cron method validation", () => { const { context, respond } = await invokeCronGet({ id: "cron-42" }, job); expect(context.cron.readJob).toHaveBeenCalledWith("cron-42"); - expect(respond).toHaveBeenCalledWith(true, job, undefined); + expectCronReadSuccess(respond, job); }); it("allows caller-scoped cron.get for the same agent", async () => { @@ -511,7 +519,7 @@ describe("cron method validation", () => { client: callerClient("ops"), }); - expect(respond).toHaveBeenCalledWith(true, job, undefined); + expectCronReadSuccess(respond, job); }); it("hides caller-scoped cron.get for a foreign agent", async () => { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 951eda036604..f865e3a7e8ad 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -169,6 +169,12 @@ type DirectCronState = GatewayCronState & { type CronBroadcast = (event: string, payload: unknown) => void; +type DirectCronResponse = { + ok: boolean; + payload?: unknown; + error?: { code?: string; message?: string; details?: unknown }; +}; + async function createDirectCronState(params?: { broadcast?: CronBroadcast; }): Promise<DirectCronState> { @@ -243,12 +249,14 @@ async function directCronReq( cronState: DirectCronState, method: string, params: Record<string, unknown>, -): Promise<{ ok: boolean; payload?: unknown; error?: { code?: string; message?: string } }> { +): Promise<DirectCronResponse> { const { cronHandlers } = await import("./server-methods/cron.js"); - let result: - | { ok: boolean; payload?: unknown; error?: { code?: string; message?: string } } - | undefined; - const respond = (ok: boolean, payload?: unknown, error?: { code?: string; message?: string }) => { + let result: DirectCronResponse | undefined; + const respond = ( + ok: boolean, + payload?: unknown, + error?: { code?: string; message?: string; details?: unknown }, + ) => { result = { ok, payload, @@ -915,6 +923,75 @@ describe("gateway server cron", () => { } }); + test("atomically rejects stale config revisions without conflicting on runtime state", async () => { + const { prevSkipCron } = await setupCronTestRun({ + tempPrefix: "openclaw-gw-cron-update-revision-", + cronEnabled: false, + }); + const cronState = await createDirectCronState(); + + try { + const added = await directCronReq(cronState, "cron.add", { + name: "revision protected", + enabled: true, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "original" }, + }); + expect(added.ok).toBe(true); + const addedJob = added.payload as { id: string }; + const initial = await directCronReq(cronState, "cron.get", { id: addedJob.id }); + const initialJob = initial.payload as { + id: string; + configRevision: string; + updatedAtMs: number; + }; + + const runtimeOnly = await directCronReq(cronState, "cron.update", { + id: initialJob.id, + patch: { state: { lastRunAtMs: 1_700_000_000_000 } }, + }); + expect(runtimeOnly.ok).toBe(true); + expect(runtimeOnly.payload).toMatchObject({ + configRevision: initialJob.configRevision, + }); + + const first = await directCronReq(cronState, "cron.update", { + id: initialJob.id, + expectedConfigRevision: initialJob.configRevision, + patch: { description: "first writer" }, + }); + expect(first.ok).toBe(true); + const firstJob = first.payload as { configRevision: string; updatedAtMs: number }; + expect(firstJob.configRevision).not.toBe(initialJob.configRevision); + expect(firstJob.updatedAtMs).toBeGreaterThan(initialJob.updatedAtMs); + + const stale = await directCronReq(cronState, "cron.update", { + id: initialJob.id, + expectedConfigRevision: initialJob.configRevision, + patch: { description: "stale writer" }, + }); + expect(stale.ok).toBe(false); + expect(stale.error).toMatchObject({ + code: "INVALID_REQUEST", + details: { + code: "CRON_JOB_CHANGED", + expectedConfigRevision: initialJob.configRevision, + actualConfigRevision: firstJob.configRevision, + }, + }); + + const current = await directCronReq(cronState, "cron.get", { id: initialJob.id }); + expect(current.payload).toMatchObject({ + description: "first writer", + updatedAtMs: firstJob.updatedAtMs, + }); + } finally { + await cleanupCronTestRun({ cronState, prevSkipCron }); + } + }); + test("accepts opaque custom session ids on add and update", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-opaque-session-target-", diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index ad5040d8de55..e2e7469e4ed1 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -160,14 +160,17 @@ describe("package dist inventory", () => { const omittedRuntimeChunk = path.join(packageRoot, "dist", "qa-runtime-AbC123.js"); const omittedTopLevelMap = path.join(packageRoot, "dist", "runtime.js.map"); const omittedMap = path.join(packageRoot, "dist", "plugin-sdk", "runtime.js.map"); + const omittedAppBundle = path.join(packageRoot, "dist", "OpenClaw.app"); await fs.mkdir(path.dirname(packagedRuntime), { recursive: true }); await fs.mkdir(path.dirname(omittedNestedHelper), { recursive: true }); + await fs.mkdir(omittedAppBundle, { recursive: true }); await fs.writeFile( path.join(packageRoot, "package.json"), JSON.stringify({ files: [ "dist/", + "!dist/OpenClaw.app/**", "!dist/plugin-sdk/plugin-test-runtime.js", "!dist/plugin-sdk/plugin-test-runtime.d.ts", "!dist/plugin-sdk/src/test-utils/**", @@ -186,6 +189,7 @@ describe("package dist inventory", () => { await fs.writeFile(omittedRuntimeChunk, "export {};\n", "utf8"); await fs.writeFile(omittedTopLevelMap, "{}", "utf8"); await fs.writeFile(omittedMap, "{}", "utf8"); + await fs.symlink(packageRoot, path.join(omittedAppBundle, "Autoupdate")); await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([ "dist/plugin-sdk/runtime.js", diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index e53172254b73..a50a9bfbd6cf 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -327,9 +327,18 @@ function isPackagedDistPath(relativePath: string, rules: PackageDistInventoryRul return true; } +function isPackageFilesExcludedDistSubtree( + relativePath: string, + exclusions: PackageDistExclusionRules, +): boolean { + // Directory exclusions end in "/"; match the root before inspecting excluded symlinks below it. + return isPackageFilesExcludedDistPath(`${relativePath}/`, exclusions); +} + function isOmittedDistSubtree(relativePath: string, rules: PackageDistInventoryRules): boolean { return ( isExternalizedBundledExtensionDistPath(relativePath, rules.externalizedExtensionIds) || + isPackageFilesExcludedDistSubtree(relativePath, rules.exclusions) || isLegacyPluginDependencyDirPath(relativePath) || isOmittedPluginSdkTestPath(relativePath) || OMITTED_DIST_SUBTREE_PATTERNS.some((pattern) => pattern.test(relativePath)) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index d9c0d2a4c43a..9fd39cde5f3d 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -960,6 +960,7 @@ describe("runGatewayUpdate", () => { expect(result.status).toBe("ok"); expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); + expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1"); @@ -2688,6 +2689,7 @@ describe("runGatewayUpdate", () => { expect(calls).toContain(doctorCommand); expect(result.steps.map((step) => step.name)).toContain("openclaw doctor"); expect(doctorEnv?.OPENCLAW_UPDATE_IN_PROGRESS).toBe("1"); + expect(doctorEnv?.OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART).toBe("1"); expect(doctorEnv?.OPENCLAW_UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR).toBe("1"); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index f34463775efb..5aede59dd0ed 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -6,6 +6,7 @@ import { normalizeStringEntries, uniqueStrings, } from "@openclaw/normalization-core/string-normalization"; +import { DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV } from "../commands/doctor-invocation.js"; import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { @@ -1628,6 +1629,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< const doctorStep = await runStep( step("openclaw doctor", doctorArgv, gitRoot, { OPENCLAW_UPDATE_IN_PROGRESS: "1", + [DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1", ...(opts.deferConfiguredPluginInstallRepair ? { [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1" } : {}), @@ -1823,6 +1825,7 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< timeoutMs, env: { OPENCLAW_UPDATE_IN_PROGRESS: "1", + [DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS_ENV]: "1", [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", [UPDATE_PARENT_SUPPORTS_GATEWAY_RESTART_ENV]: "1", [UPDATE_PARENT_ALLOWS_GATEWAY_SERVICE_REPAIR_ENV]: allowGatewayServiceRepair diff --git a/src/infra/watch-node.test.ts b/src/infra/watch-node.test.ts index d572a58f4c43..04ee6f29e342 100644 --- a/src/infra/watch-node.test.ts +++ b/src/infra/watch-node.test.ts @@ -343,7 +343,7 @@ describe("watch-node script", () => { .mockReturnValueOnce(gatewayA) .mockReturnValueOnce(doctor) .mockReturnValueOnce(gatewayB); - const { watcher, fakeProcess, runPromise } = startWatchRun({ spawn }); + const { watcher, fakeProcess, runPromise } = startWatchRun({ env: {}, spawn }); gatewayA.emit("exit", 1, null); await new Promise((resolve) => { @@ -360,6 +360,7 @@ describe("watch-node script", () => { "--non-interactive", ]); expect(requireSpawnOptions(spawn, 1).stdio).toBe("inherit"); + expect(requireSpawnEnv(spawn, 1).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS).toBe("1"); doctor.emit("exit", 0, null); await new Promise((resolve) => { @@ -371,6 +372,9 @@ describe("watch-node script", () => { expect(restartedGatewaySpawnCall[0]).toBe("/usr/local/bin/node"); expect(restartedGatewaySpawnCall[1]).toEqual(["scripts/run-node.mjs", "gateway", "--force"]); expect(requireSpawnOptions(spawn, 2).stdio).toBe("inherit"); + expect( + requireSpawnEnv(spawn, 2).OPENCLAW_DOCTOR_DISABLE_CROSS_STATE_DIR_IMPORTS, + ).toBeUndefined(); fakeProcess.emit("SIGINT"); const exitCode = await runPromise; diff --git a/src/plugin-sdk/video-generation-core.ts b/src/plugin-sdk/video-generation-core.ts index 63385ee3dbfe..c5577d08612c 100644 --- a/src/plugin-sdk/video-generation-core.ts +++ b/src/plugin-sdk/video-generation-core.ts @@ -5,6 +5,7 @@ export type { FallbackAttempt } from "../agents/model-fallback.types.js"; export type { VideoGenerationProviderPlugin } from "../plugins/types.js"; export type { GeneratedVideoAsset, + VideoGenerationCatalogModelEntry, VideoGenerationIgnoredOverride, VideoGenerationMode, VideoGenerationModeCapabilities, diff --git a/src/plugin-sdk/video-generation.ts b/src/plugin-sdk/video-generation.ts index 024683e630f0..fa0e9ca0d6ed 100644 --- a/src/plugin-sdk/video-generation.ts +++ b/src/plugin-sdk/video-generation.ts @@ -8,6 +8,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GeneratedVideoAsset as CoreGeneratedVideoAsset, VideoGenerationAssetRole as CoreVideoGenerationAssetRole, + VideoGenerationCatalogModelEntry as CoreVideoGenerationCatalogModelEntry, VideoGenerationMode as CoreVideoGenerationMode, VideoGenerationModeCapabilities as CoreVideoGenerationModeCapabilities, VideoGenerationModelCapabilitiesContext as CoreVideoGenerationModelCapabilitiesContext, @@ -171,6 +172,12 @@ export type VideoGenerationProviderCapabilities = VideoGenerationModeCapabilitie videoToVideo?: VideoGenerationTransformCapabilities; }; +/** Static catalog metadata that overrides provider defaults for one video model. */ +export type VideoGenerationCatalogModelEntry = { + capabilities?: VideoGenerationProviderCapabilities; + modes?: readonly VideoGenerationMode[]; +}; + /** Video generation provider contract implemented by provider plugins. */ export type VideoGenerationProvider = { id: string; @@ -181,6 +188,7 @@ export type VideoGenerationProvider = { defaultTimeoutMs?: number; models?: string[]; capabilities: VideoGenerationProviderCapabilities; + catalogByModel?: Readonly<Record<string, VideoGenerationCatalogModelEntry>>; isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean; resolveModelCapabilities?: ( ctx: VideoGenerationModelCapabilitiesContext, @@ -201,6 +209,8 @@ const videoGenerationSdkCompat: [ AssertAssignable<CoreVideoGenerationProviderOptionType, VideoGenerationProviderOptionType>, AssertAssignable<VideoGenerationMode, CoreVideoGenerationMode>, AssertAssignable<CoreVideoGenerationMode, VideoGenerationMode>, + AssertAssignable<VideoGenerationCatalogModelEntry, CoreVideoGenerationCatalogModelEntry>, + AssertAssignable<CoreVideoGenerationCatalogModelEntry, VideoGenerationCatalogModelEntry>, AssertAssignable<VideoGenerationModeCapabilities, CoreVideoGenerationModeCapabilities>, AssertAssignable<CoreVideoGenerationModeCapabilities, VideoGenerationModeCapabilities>, AssertAssignable<VideoGenerationProvider, CoreVideoGenerationProvider>, diff --git a/src/plugins/active-runtime-registry.test.ts b/src/plugins/active-runtime-registry.test.ts index 7a60680efae7..a74c47357223 100644 --- a/src/plugins/active-runtime-registry.test.ts +++ b/src/plugins/active-runtime-registry.test.ts @@ -1,6 +1,9 @@ // Covers active runtime plugin registry state and reset behavior. import { afterEach, describe, expect, it } from "vitest"; -import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; +import { + getLoadedRuntimePluginRegistry, + listLoadedRuntimePluginIdsAcrossSurfaces, +} from "./active-runtime-registry.js"; import { testing, clearPluginLoaderCache } from "./loader.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry-types.js"; @@ -98,6 +101,44 @@ describe("getLoadedRuntimePluginRegistry", () => { ).toBeUndefined(); }); + it("does not treat deferred plugin metadata as a loaded runtime", () => { + const deferredRegistry = createEmptyPluginRegistry(); + deferredRegistry.plugins.push({ + id: "deferred", + format: "openclaw", + imported: false, + status: "loaded", + } as never); + setActivePluginRegistry(deferredRegistry, "deferred", "default", "/tmp/ws"); + + expect( + getLoadedRuntimePluginRegistry({ + workspaceDir: "/tmp/ws", + requiredPluginIds: ["deferred"], + }), + ).toBeUndefined(); + expect(listLoadedRuntimePluginIdsAcrossSurfaces()).not.toContain("deferred"); + }); + + it("accepts metadata-only bundle plugins as loaded runtimes", () => { + const bundleRegistry = createEmptyPluginRegistry(); + bundleRegistry.plugins.push({ + id: "bundle", + format: "bundle", + imported: false, + status: "loaded", + } as never); + setActivePluginRegistry(bundleRegistry, "bundle", "default", "/tmp/ws"); + + expect( + getLoadedRuntimePluginRegistry({ + workspaceDir: "/tmp/ws", + requiredPluginIds: ["bundle"], + }), + ).toBe(bundleRegistry); + expect(listLoadedRuntimePluginIdsAcrossSurfaces()).toContain("bundle"); + }); + it("does not reuse workspace-agnostic registries for workspace-specific requests", () => { setActivePluginRegistry(createRegistryWithPlugin("demo"), "demo"); diff --git a/src/plugins/active-runtime-registry.ts b/src/plugins/active-runtime-registry.ts index 073031afe121..f076ffb28380 100644 --- a/src/plugins/active-runtime-registry.ts +++ b/src/plugins/active-runtime-registry.ts @@ -1,7 +1,7 @@ // Stores active runtime plugin registry state and activation metadata. import { normalizeSortedUniqueStringEntries } from "@openclaw/normalization-core/string-normalization"; import { resolveCompatibleRuntimePluginRegistry, type PluginLoadOptions } from "./loader.js"; -import type { PluginRegistry } from "./registry-types.js"; +import type { PluginRecord, PluginRegistry } from "./registry-types.js"; import { collectLivePluginRegistries, getActivePluginChannelRegistry, @@ -16,6 +16,10 @@ export function getActiveRuntimePluginRegistry(): PluginRegistry | null { return getActivePluginRegistry(); } +function isRuntimePluginRecordLoaded(plugin: PluginRecord): boolean { + return plugin.status === "loaded" && (plugin.format === "bundle" || plugin.imported !== false); +} + // Plugin ids confirmed loaded across every live runtime registry surface // (active plus any pinned http-route/channel/session-extension registry), via // the canonical collectLivePluginRegistries() set. A plugin can stay live via a @@ -26,7 +30,7 @@ export function listLoadedRuntimePluginIdsAcrossSurfaces(): string[] { const loaded: string[] = []; for (const registry of collectLivePluginRegistries()) { for (const plugin of registry.plugins ?? []) { - if (plugin.status === "loaded") { + if (isRuntimePluginRecordLoaded(plugin)) { loaded.push(plugin.id); } } @@ -51,10 +55,15 @@ export function registryContainsRuntimePluginIds( const present = new Set<string>(); const loaded = new Set<string>(); const pluginStatusById = new Map<string, string | undefined>(); + const pluginRuntimeLoadedById = new Map<string, boolean>(); for (const plugin of registry.plugins ?? []) { present.add(plugin.id); pluginStatusById.set(plugin.id, plugin.status); - if (plugin.status === undefined || plugin.status === "loaded") { + pluginRuntimeLoadedById.set(plugin.id, isRuntimePluginRecordLoaded(plugin)); + // Deferred manifest records are metadata-only until their runtime module is + // imported. Reusing them here would skip the scoped load that registers the + // requested harness/provider/tool capabilities. + if (plugin.status === undefined || isRuntimePluginRecordLoaded(plugin)) { loaded.add(plugin.id); } } @@ -71,7 +80,7 @@ export function registryContainsRuntimePluginIds( if (typeof pluginId === "string" && pluginId.length > 0) { present.add(pluginId); const status = pluginStatusById.get(pluginId); - if (status === undefined || status === "loaded") { + if (status === undefined || pluginRuntimeLoadedById.get(pluginId) === true) { loaded.add(pluginId); } } diff --git a/src/plugins/memory-state.test.ts b/src/plugins/memory-state.test.ts index cf3973ed2c30..9e9d218c8ca3 100644 --- a/src/plugins/memory-state.test.ts +++ b/src/plugins/memory-state.test.ts @@ -1,5 +1,5 @@ // Covers plugin-backed memory state registration and reset behavior. -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildMemoryPromptSection, clearMemoryPluginState, @@ -339,6 +339,33 @@ describe("memory plugin state", () => { ).toEqual(["citations: off"]); }); + it("passes agent context through the primary and supplemental prompt builders", () => { + const primary = vi.fn(() => ["primary"]); + const supplemental = vi.fn(() => ["supplemental"]); + registerMemoryPromptSection(primary); + registerMemoryPromptSupplement("memory-wiki", supplemental); + + const availableTools = new Set(["memory_search", "memory_get"]); + expect( + buildMemoryPromptSection({ + availableTools, + citationsMode: "on", + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }), + ).toEqual(["primary", "supplemental"]); + const expectedContext = { + availableTools, + citationsMode: "on", + agentId: "marketing-agent", + agentSessionKey: "agent:marketing-agent:main", + sandboxed: true, + }; + expect(primary).toHaveBeenCalledWith(expectedContext); + expect(supplemental).toHaveBeenCalledWith(expectedContext); + }); + it("appends prompt supplements in plugin-id order", () => { registerMemoryPromptSection(() => ["primary"]); registerMemoryPromptSupplement("memory-wiki", () => ["wiki"]); diff --git a/src/plugins/memory-state.ts b/src/plugins/memory-state.ts index 121c7914f6cb..52d0bed6500b 100644 --- a/src/plugins/memory-state.ts +++ b/src/plugins/memory-state.ts @@ -9,6 +9,9 @@ const log = createSubsystemLogger("plugins/memory-state"); export type MemoryPromptSectionBuilder = (params: { availableTools: Set<string>; citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; }) => string[]; export type MemoryCorpusSearchResult = { @@ -48,13 +51,17 @@ export type MemoryCorpusSupplement = { search(params: { query: string; maxResults?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }): Promise<MemoryCorpusSearchResult[]>; get(params: { lookup: string; fromLine?: number; lineCount?: number; + agentId?: string; agentSessionKey?: string; + sandboxed?: boolean; }): Promise<MemoryCorpusGetResult | null>; }; @@ -249,6 +256,9 @@ export function registerMemoryPromptSupplement( export function buildMemoryPromptSection(params: { availableTools: Set<string>; citationsMode?: MemoryCitationsMode; + agentId?: string; + agentSessionKey?: string; + sandboxed?: boolean; }): string[] { const primary = normalizeMemoryPromptLines( memoryPluginState.capability?.capability.promptBuilder?.(params) ?? [], diff --git a/src/secrets/audit.test.ts b/src/secrets/audit.test.ts index 22c56b598192..dfe2670457a1 100644 --- a/src/secrets/audit.test.ts +++ b/src/secrets/audit.test.ts @@ -7,8 +7,6 @@ import { resolveAuthProfileDatabasePath, writePersistedAuthProfileStoreRaw, } from "../agents/auth-profiles/sqlite.js"; -import { saveAuthProfileStore } from "../agents/auth-profiles/store.js"; -import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { runSecretsAudit } from "./audit.js"; @@ -38,19 +36,11 @@ function countNonEmptyLines(value: string): number { } async function writeJsonFile(filePath: string, value: unknown): Promise<void> { - if (path.basename(filePath) === "openclaw-agent.sqlite") { - saveAuthProfileStore(value as AuthProfileStore, path.dirname(filePath), { - filterExternalAuthProfiles: false, - syncExternalCli: false, - }); - return; - } await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } -async function removeAuthStore(fixture: AuditFixture): Promise<void> { - closeOpenClawAgentDatabasesForTest(); - await fs.rm(fixture.authStorePath, { force: true }); +function writeAuthStore(fixture: AuditFixture, value: unknown): void { + writePersistedAuthProfileStoreRaw(value, fixture.agentDir); } async function writeExecResolverShellScript(params: { @@ -198,7 +188,7 @@ async function seedAuditFixture(fixture: AuditFixture): Promise<void> { await writeJsonFile(fixture.configPath, { models: { providers: seededProvider }, }); - await writeJsonFile(fixture.authStorePath, { + writeAuthStore(fixture, { version: 1, profiles: Object.fromEntries(seededProfiles), }); @@ -225,7 +215,7 @@ describe("secrets audit", () => { beforeAll(async () => { const warmFixture = await createAuditFixture(); try { - await seedAuditFixture(warmFixture); + await writeJsonFile(warmFixture.configPath, {}); await runSecretsAudit({ env: warmFixture.env }); } finally { closeOpenClawAgentDatabasesForTest(); @@ -269,7 +259,7 @@ describe("secrets audit", () => { beforeEach(async () => { fixture = await createAuditFixture(); - await seedAuditFixture(fixture); + await writeJsonFile(fixture.configPath, {}); }); afterEach(async () => { @@ -278,6 +268,7 @@ describe("secrets audit", () => { }); it("reports plaintext + shadowing findings", async () => { + await seedAuditFixture(fixture); const report = await runSecretsAudit({ env: fixture.env }); expect(report.status).toBe("findings"); expect(report.summary.plaintextCount).toBeGreaterThan(0); @@ -287,7 +278,6 @@ describe("secrets audit", () => { }); it("does not mutate legacy auth.json during audit", async () => { - await removeAuthStore(fixture); await writeJsonFile(fixture.authJsonPath, { openai: { type: "api_key", @@ -335,8 +325,6 @@ describe("secrets audit", () => { }, ], }); - await removeAuthStore(fixture); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); expect(report.resolution.resolvabilityComplete).toBe(false); @@ -377,8 +365,6 @@ describe("secrets audit", () => { }, ], }); - await removeAuthStore(fixture); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env, allowExec: true }); expect(report.summary.unresolvedRefCount).toBe(0); @@ -441,8 +427,6 @@ describe("secrets audit", () => { )}\n`, "utf8", ); - await removeAuthStore(fixture); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env, allowExec: true }); expect(report.summary.unresolvedRefCount).toBeGreaterThanOrEqual(2); @@ -566,17 +550,9 @@ describe("secrets audit", () => { }); it("reports oversized models.json as unresolved findings", async () => { - const oversizedApiKey = "a".repeat(MAX_AUDIT_MODELS_JSON_BYTES + 256); - await writeJsonFile(fixture.modelsPath, { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - api: "openai-completions", - apiKey: oversizedApiKey, - models: [{ id: "gpt-5", name: "gpt-5" }], - }, - }, - }); + // The audit rejects by stat before reading, so a sparse file proves the size bound cheaply. + await fs.writeFile(fixture.modelsPath, "", "utf8"); + await fs.truncate(fixture.modelsPath, MAX_AUDIT_MODELS_JSON_BYTES + 256); const report = await runSecretsAudit({ env: fixture.env }); expectModelsFinding(report, { code: "REF_UNRESOLVED" }); @@ -616,7 +592,7 @@ describe("secrets audit", () => { }); it("does not flag $VAR shorthand env refs in auth profiles as plaintext", async () => { - await writeJsonFile(fixture.authStorePath, { + writeAuthStore(fixture, { version: 1, profiles: { "openai:default": { @@ -637,7 +613,7 @@ describe("secrets audit", () => { }); it("does not flag ${VAR} env refs in auth profiles as plaintext", async () => { - await writeJsonFile(fixture.authStorePath, { + writeAuthStore(fixture, { version: 1, profiles: { "openai:default": { @@ -658,20 +634,17 @@ describe("secrets audit", () => { }); it("still flags auth profile plaintext when an explicit ref is also configured", async () => { - writePersistedAuthProfileStoreRaw( - { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-leftover-plaintext", // pragma: allowlist secret - keyRef: { source: "env", id: "OPENAI_API_KEY" }, - }, + writeAuthStore(fixture, { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-leftover-plaintext", // pragma: allowlist secret + keyRef: { source: "env", id: "OPENAI_API_KEY" }, }, }, - fixture.agentDir, - ); + }); const report = await runSecretsAudit({ env: fixture.env }); expect( @@ -688,7 +661,7 @@ describe("secrets audit", () => { it.each(["$OPENAI_API_KEY", "${OPENAI_API_KEY}"])( "does not flag %s auth profile env refs when an explicit ref is also configured", async (value) => { - await writeJsonFile(fixture.authStorePath, { + writeAuthStore(fixture, { version: 1, profiles: { "openai:default": { @@ -729,11 +702,6 @@ describe("secrets audit", () => { }, }, }); - await writeJsonFile(fixture.authStorePath, { - version: 1, - profiles: {}, - }); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); expect( @@ -765,11 +733,6 @@ describe("secrets audit", () => { }, }, }); - await writeJsonFile(fixture.authStorePath, { - version: 1, - profiles: {}, - }); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); expect( @@ -808,11 +771,6 @@ describe("secrets audit", () => { }, }, }); - await writeJsonFile(fixture.authStorePath, { - version: 1, - profiles: {}, - }); - await fs.writeFile(fixture.envPath, "", "utf8"); const report = await runSecretsAudit({ env: fixture.env }); expect( diff --git a/src/secrets/runtime.coverage.test.ts b/src/secrets/runtime.coverage.test.ts index b46031a5a98b..2fac1ea63164 100644 --- a/src/secrets/runtime.coverage.test.ts +++ b/src/secrets/runtime.coverage.test.ts @@ -13,10 +13,48 @@ import type { import { getPath, setPathCreateStrict } from "./path-utils.js"; import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpers.js"; +const COVERAGE_WEB_PROVIDER_PLUGIN_IDS = vi.hoisted(() => ({ + search: [ + "brave", + "exa", + "firecrawl", + "google", + "minimax", + "moonshot", + "parallel", + "perplexity", + "tavily", + "xai", + ], + fetch: ["firecrawl"], +})); + +vi.mock("../plugins/capability-provider-runtime.js", () => ({ + resolvePluginCapabilityProviders: () => [], +})); + vi.mock("../plugins/installed-plugin-index-records.js", () => ({ loadInstalledPluginIndexInstallRecordsSync: () => ({}), })); +vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: () => { + throw new Error("runtime coverage expects bundled channel secret contracts"); + }, +})); + +vi.mock("./runtime-web-tools-manifest.runtime.js", () => ({ + resolveManifestContractPluginIds: ({ contract }: { contract: string }) => { + if (contract === "webSearchProviders") { + return [...COVERAGE_WEB_PROVIDER_PLUGIN_IDS.search]; + } + if (contract === "webFetchProviders") { + return [...COVERAGE_WEB_PROVIDER_PLUGIN_IDS.fetch]; + } + return []; + }, +})); + function createCoverageWebSearchProvider(params: { pluginId: string; id: string; diff --git a/src/video-generation/types.ts b/src/video-generation/types.ts index ce9b5e790529..828260b22199 100644 --- a/src/video-generation/types.ts +++ b/src/video-generation/types.ts @@ -156,6 +156,12 @@ export type VideoGenerationProviderCapabilities = VideoGenerationModeCapabilitie videoToVideo?: VideoGenerationTransformCapabilities; }; +/** Static catalog metadata that overrides provider defaults for one video model. */ +export type VideoGenerationCatalogModelEntry = { + capabilities?: VideoGenerationProviderCapabilities; + modes?: readonly VideoGenerationMode[]; +}; + export type VideoGenerationNormalization = { size?: MediaNormalizationEntry<string>; aspectRatio?: MediaNormalizationEntry<string>; @@ -172,6 +178,7 @@ export type VideoGenerationProvider = { defaultTimeoutMs?: number; models?: string[]; capabilities: VideoGenerationProviderCapabilities; + catalogByModel?: Readonly<Record<string, VideoGenerationCatalogModelEntry>>; isConfigured?: (ctx: VideoGenerationProviderConfiguredContext) => boolean; resolveModelCapabilities?: ( ctx: VideoGenerationModelCapabilitiesContext, diff --git a/test/clawrouter-managed-gateway.e2e.test.ts b/test/clawrouter-managed-gateway.e2e.test.ts new file mode 100644 index 000000000000..8c468d1af8f0 --- /dev/null +++ b/test/clawrouter-managed-gateway.e2e.test.ts @@ -0,0 +1,409 @@ +import fs from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { setTimeout as delay } from "node:timers/promises"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createOpenClawTestInstance, + type OpenClawTestInstance, +} from "./helpers/openclaw-test-instance.js"; + +const API_KEY = "clawrouter-e2e-secret"; +const MODEL_ID = "openai/gpt-5.5"; +const MODEL_REF = `clawrouter/${MODEL_ID}`; +const SUCCESS_MARKER = "CLAWROUTER_E2E_OK"; + +type CapturedRequest = { + method: string; + path: string; + authorization?: string; + headers: Record<string, string | string[] | undefined>; + body?: Record<string, unknown>; +}; + +type FakeClawRouter = { + baseUrl: string; + requests: CapturedRequest[]; + close: () => Promise<void>; +}; + +const instances: OpenClawTestInstance[] = []; +const routers: FakeClawRouter[] = []; + +afterEach(async () => { + await Promise.allSettled(instances.splice(0).map((instance) => instance.cleanup())); + await Promise.allSettled(routers.splice(0).map((router) => router.close())); +}); + +describe("ClawRouter managed gateway contract", () => { + it("boots from a SecretRef, reports truthful readiness, and routes an attributed agent turn", async () => { + const router = await startFakeClawRouter(); + routers.push(router); + const instance = await createOpenClawTestInstance({ + name: "clawrouter-managed-gateway", + env: { + CLAWROUTER_API_KEY: API_KEY, + OPENCLAW_SKIP_PROVIDERS: undefined, + OPENCLAW_TEST_FAST: "1", + OPENCLAW_TEST_MINIMAL_GATEWAY: undefined, + }, + }); + instances.push(instance); + + const patchPath = await instance.state.writeText( + "clawrouter.patch.json5", + JSON.stringify( + { + plugins: { + allow: ["clawrouter"], + entries: { clawrouter: { enabled: true } }, + }, + models: { + providers: { + clawrouter: { + baseUrl: router.baseUrl, + apiKey: { source: "env", provider: "default", id: "CLAWROUTER_API_KEY" }, + headers: { "X-ClawRouter-Project-Id": "fakeco-e2e" }, + }, + }, + }, + agents: { defaults: { model: { primary: MODEL_REF } } }, + }, + null, + 2, + ), + ); + const dryRun = await instance.cli( + ["config", "patch", "--file", patchPath, "--dry-run", "--json"], + { timeoutMs: 120_000 }, + ); + expect(dryRun.code, dryRun.stderr).toBe(0); + expect(dryRun.stdout).toMatch(/"ok"\s*:\s*true/u); + + const bootstrap = await instance.cli(["config", "patch", "--file", patchPath], { + timeoutMs: 120_000, + }); + expect(bootstrap.code, bootstrap.stderr).toBe(0); + + const configText = await fs.readFile(instance.configPath, "utf8"); + const config = JSON.parse(configText) as { + agents?: { defaults?: { model?: { primary?: string } } }; + models?: { + providers?: Record< + string, + { + apiKey?: unknown; + baseUrl?: string; + headers?: Record<string, string>; + } + >; + }; + plugins?: { allow?: string[]; entries?: Record<string, { enabled?: boolean }> }; + }; + expect(config.models?.providers?.clawrouter).toMatchObject({ + apiKey: { source: "env", provider: "default", id: "CLAWROUTER_API_KEY" }, + baseUrl: router.baseUrl, + headers: { "X-ClawRouter-Project-Id": "fakeco-e2e" }, + }); + expect(config.agents?.defaults?.model?.primary).toBe(MODEL_REF); + expect(config.plugins?.allow).toContain("clawrouter"); + expect(config.plugins?.entries?.clawrouter?.enabled).toBe(true); + expect(configText).not.toContain(API_KEY); + + const routerHealth = await fetch(`${router.baseUrl}/v1/health`); + expect(routerHealth.status).toBe(200); + await expect(routerHealth.json()).resolves.toMatchObject({ + ok: true, + environment: "fakeco", + observability: { + mode: "metadata_only", + requestContentRetentionDefault: false, + }, + }); + const rejectedCatalog = await fetch(`${router.baseUrl}/v1/catalog`, { + headers: { Authorization: "Bearer wrong-secret" }, + }); + expect(rejectedCatalog.status).toBe(401); + + await instance.startGateway(); + const gatewayReadiness = await waitForGatewayReadiness(instance); + expect(gatewayReadiness).toMatchObject({ ready: true, failing: [] }); + + const catalog = await instance.cli( + ["models", "list", "--all", "--provider", "clawrouter", "--json"], + { timeoutMs: 120_000 }, + ); + expect(catalog.code, catalog.stderr).toBe(0); + expect(catalog.stdout).toContain(MODEL_REF); + + const probe = await instance.cli( + [ + "models", + "status", + "--probe", + "--probe-provider", + "clawrouter", + "--probe-max-tokens", + "8", + "--json", + ], + { timeoutMs: 120_000 }, + ); + expect(probe.code, probe.stderr).toBe(0); + expect(probe.stdout).toMatch(/"provider"\s*:\s*"clawrouter"/u); + expect(probe.stdout).toMatch(/"status"\s*:\s*"ok"/u); + + const agent = await instance.cli( + [ + "agent", + "--agent", + "main", + "--model", + MODEL_REF, + "--message", + `Reply exactly: ${SUCCESS_MARKER}`, + "--json", + ], + { timeoutMs: 120_000 }, + ); + expect(agent.code, agent.stderr).toBe(0); + expect(agent.stdout).toContain(SUCCESS_MARKER); + + const inferenceRequests = router.requests.filter( + (request) => request.method === "POST" && request.path === "/v1/responses", + ); + expect(inferenceRequests.length).toBeGreaterThanOrEqual(2); + expect(inferenceRequests.at(-1)).toMatchObject({ + authorization: `Bearer ${API_KEY}`, + body: { model: MODEL_ID, stream: true }, + headers: { + "x-clawrouter-agent-id": "main", + "x-clawrouter-client": "openclaw", + "x-clawrouter-project-id": "fakeco-e2e", + }, + }); + const sessionId = inferenceRequests.at(-1)?.headers["x-clawrouter-session-id"]; + expect(JSON.stringify(inferenceRequests.at(-1)?.body)).toContain(SUCCESS_MARKER); + expect(typeof sessionId).toBe("string"); + expect(String(sessionId).length).toBeGreaterThan(0); + expect(String(sessionId).length).toBeLessThanOrEqual(256); + + expect(instance.logs()).toContain( + `[model-fetch] start provider=clawrouter api=openai-responses model=${MODEL_ID} method=POST url=${router.baseUrl}/v1/responses`, + ); + expect(instance.logs()).toContain( + `[model-fetch] response provider=clawrouter api=openai-responses model=${MODEL_ID} status=200`, + ); + expect( + [ + bootstrap.stdout, + bootstrap.stderr, + dryRun.stdout, + dryRun.stderr, + catalog.stdout, + catalog.stderr, + probe.stdout, + probe.stderr, + agent.stdout, + agent.stderr, + instance.logs(), + ].join("\n"), + ).not.toContain(API_KEY); + }, 240_000); +}); + +async function waitForGatewayReadiness( + instance: OpenClawTestInstance, +): Promise<{ ready: boolean; failing: string[] }> { + const url = `http://127.0.0.1:${instance.port}/readyz`; + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + const response = await fetch(url); + if (response.ok) { + return (await response.json()) as { ready: boolean; failing: string[] }; + } + } catch { + // The listener can open before startup readiness settles. + } + await delay(50); + } + throw new Error(`gateway did not become ready: ${instance.logs()}`); +} + +async function startFakeClawRouter(): Promise<FakeClawRouter> { + const requests: CapturedRequest[] = []; + const server = createServer((req, res) => { + void handleClawRouterRequest(req, res, requests).catch((error) => { + res.writeHead(500, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: String(error) } })); + }); + }); + await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + throw new Error("fake ClawRouter did not bind a TCP port"); + } + return { + baseUrl: `http://127.0.0.1:${(address as AddressInfo).port}`, + requests, + close: async () => { + server.closeAllConnections(); + await new Promise<void>((resolve) => server.close(() => resolve())); + }, + }; +} + +async function handleClawRouterRequest( + req: IncomingMessage, + res: ServerResponse, + requests: CapturedRequest[], +): Promise<void> { + const method = req.method ?? "GET"; + const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + const bodyText = await readRequestBody(req); + const body = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : undefined; + const authorization = req.headers.authorization; + requests.push({ method, path, authorization, headers: { ...req.headers }, body }); + + if (method === "GET" && path === "/v1/health") { + writeJson(res, 200, { + ok: true, + environment: "fakeco", + observability: { + mode: "metadata_only", + requestContentRetentionDefault: false, + }, + }); + return; + } + + if (authorization !== `Bearer ${API_KEY}`) { + writeJson(res, 401, { error: { message: "unauthorized" } }); + return; + } + + if (method === "GET" && path === "/v1/catalog") { + writeJson(res, 200, { + providers: [ + { + id: "openai", + displayName: "OpenAI", + openaiCompatible: true, + nativeBaseUrl: "/v1/native/openai", + routes: [], + models: [ + { + id: MODEL_ID, + upstream: "gpt-5.5", + capabilities: ["llm.responses"], + }, + ], + }, + ], + }); + return; + } + + if (method === "GET" && path === "/v1/usage") { + writeJson(res, 200, { + budget: { configured: false, ledger: "unmetered" }, + usage: { summary: { requestCount: 0, totalTokens: 0, actualCostMicros: 0 } }, + }); + return; + } + + if (method === "POST" && path === "/v1/responses") { + writeResponsesStream(res, resolveResponseText(body)); + return; + } + + writeJson(res, 404, { error: { message: `unexpected ${method} ${path}` } }); +} + +function resolveResponseText(body: Record<string, unknown> | undefined): string { + const matches = JSON.stringify(body ?? {}).match(/CLAWROUTER_[A-Z0-9_]+/gu); + return matches?.at(-1) ?? "CLAWROUTER_PROBE_OK"; +} + +async function readRequestBody(req: IncomingMessage): Promise<string> { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function writeResponsesStream(res: ServerResponse, text: string): void { + const itemId = "msg_clawrouter_e2e"; + const events = [ + { + type: "response.output_item.added", + item: { type: "message", id: itemId, role: "assistant", content: [], status: "in_progress" }, + }, + { + type: "response.output_text.delta", + item_id: itemId, + output_index: 0, + content_index: 0, + delta: text, + }, + { + type: "response.output_text.done", + item_id: itemId, + output_index: 0, + content_index: 0, + text, + }, + { + type: "response.output_item.done", + item: { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }, + }, + { + type: "response.completed", + response: { + id: "resp_clawrouter_e2e", + status: "completed", + output: [ + { + type: "message", + id: itemId, + role: "assistant", + status: "completed", + content: [{ type: "output_text", text, annotations: [] }], + }, + ], + usage: { + input_tokens: 11, + output_tokens: 7, + total_tokens: 18, + input_tokens_details: { cached_tokens: 0 }, + }, + }, + }, + ]; + res.writeHead(200, { + "cache-control": "no-store", + connection: "keep-alive", + "content-type": "text/event-stream", + "x-clawrouter-content-retention": "off", + "x-clawrouter-upstream-provider": "openai", + "x-request-id": "clawrouter-e2e-request", + }); + for (const event of events) { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } + res.end("data: [DONE]\n\n"); +} diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index b3d26f3b418a..362de8ce4f59 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -96,6 +96,7 @@ describe("package-openclaw-for-docker", () => { outputDir: ".artifacts/docker", outputName: "openclaw-current.tgz", packJson: ".artifacts/docker/pack.json", + pnpmPack: false, skipBuild: true, sourceDir: "/repo", }); @@ -116,6 +117,7 @@ describe("package-openclaw-for-docker", () => { ["--output-dir", ["--output-dir", "one", "--output-dir=two"]], ["--output-name", ["--output-name", "one.tgz", "--output-name=two.tgz"]], ["--pack-json", ["--pack-json", "one.json", "--pack-json=two.json"]], + ["--pnpm-pack", ["--pnpm-pack", "--pnpm-pack"]], ["--source-dir", ["--source-dir", "/repo-a", "--source-dir=/repo-b"]], ["--skip-build", ["--skip-build", "--skip-build"]], ] satisfies Array<[string, string[]]>; @@ -125,6 +127,13 @@ describe("package-openclaw-for-docker", () => { } }); + it("rejects pnpm pack with npm metadata output", () => { + expect(parseArgs(["--pnpm-pack"]).pnpmPack).toBe(true); + expect(() => parseArgs(["--pnpm-pack", "--pack-json", "pack.json"])).toThrow( + "--pack-json cannot be combined with --pnpm-pack", + ); + }); + it("rejects package artifact output names that escape the output directory", () => { for (const outputName of [ "../openclaw-current.tgz", @@ -387,6 +396,33 @@ describe("package-openclaw-for-docker", () => { ]); }); + it("uses pnpm pack when requested", async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-pack-")); + const calls: string[] = []; + const packedPath = path.join(outputDir, "openclaw-2026.5.28.tgz"); + + try { + const tarball = await packOpenClawPackageForDocker("/repo", outputDir, { + pnpmPack: true, + prepareBundledAiRuntime: skipBundledAiRuntime, + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async (command: string, args: string[], cwd: string) => { + calls.push(`${command}:${args.join(" ")}:${cwd}`); + fs.writeFileSync(packedPath, "package"); + return `${packedPath}\n`; + }, + }); + + expect(tarball).toBe(packedPath); + expect(calls).toEqual([ + `pnpm:pack --silent --config.ignore-scripts=true --pack-destination ${outputDir}:/repo`, + ]); + } finally { + fs.rmSync(outputDir, { force: true, recursive: true }); + } + }); + it("writes npm pack metadata for renamed package artifacts", async () => { const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-pack-json-")); const packJsonPath = path.join(outputDir, "pack.json"); diff --git a/test/scripts/lint-suppressions.test.ts b/test/scripts/lint-suppressions.test.ts index 300a19dd3f6f..8a927f593fa7 100644 --- a/test/scripts/lint-suppressions.test.ts +++ b/test/scripts/lint-suppressions.test.ts @@ -203,6 +203,7 @@ describe("production lint suppressions", () => { "src/cli/command-options.ts|typescript/no-unnecessary-type-parameters|1", "src/cli/plugins-cli-test-helpers.ts|typescript/no-unnecessary-type-parameters|1", "src/cli/test-runtime-capture.ts|typescript/no-unnecessary-type-parameters|1", + "src/crestodian/setup-inference.ts|preserve-caught-error|1", "src/gateway/test-helpers.server.ts|typescript/no-unnecessary-type-parameters|1", "src/hooks/module-loader.ts|typescript/no-unnecessary-type-parameters|1", "src/infra/device-pairing-store.ts|typescript/no-unnecessary-type-parameters|1", diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 14ab7a1e85b6..4a4434ab027a 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1,4 +1,5 @@ // Package Acceptance Workflow tests cover package acceptance workflow script behavior. +import { spawnSync } from "node:child_process"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; @@ -55,6 +56,13 @@ type WorkflowStep = { with?: Record<string, string>; }; +type WorkflowMatrixEntry = { + advisory?: boolean; + command?: string; + profiles?: string; + suite_id?: string; +}; + type WorkflowJob = { concurrency?: { group?: string; @@ -66,6 +74,11 @@ type WorkflowJob = { needs?: string | string[]; permissions?: Record<string, string>; "runs-on"?: string; + strategy?: { + matrix?: { + include?: WorkflowMatrixEntry[]; + }; + }; "timeout-minutes"?: number | string; steps?: WorkflowStep[]; }; @@ -105,6 +118,16 @@ function workflowStep(job: WorkflowJob, stepName: string): WorkflowStep { return step; } +function workflowMatrixEntry(path: string, jobName: string, suiteId: string): WorkflowMatrixEntry { + const entry = workflowJob(path, jobName).strategy?.matrix?.include?.find( + (candidate) => candidate.suite_id === suiteId, + ); + if (!entry) { + throw new Error(`Expected workflow matrix entry ${suiteId} in ${jobName}`); + } + return entry; +} + function expectTextToIncludeAll(text: string | undefined, snippets: string[]): void { if (text === undefined) { throw new Error("Expected text to be defined before checking snippets"); @@ -114,6 +137,30 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v } } +function runPackageAcceptanceSummary(params: { + advisory?: boolean; + telegramEnabled: boolean; + telegramResult: string; +}) { + const summary = workflowJob(PACKAGE_ACCEPTANCE_WORKFLOW, "summary"); + const script = workflowStep(summary, "Verify package acceptance results").run; + if (!script) { + throw new Error("Expected package acceptance summary script"); + } + return spawnSync("bash", ["-c", script], { + encoding: "utf8", + env: { + ADVISORY: String(params.advisory ?? false), + DOCKER_RESULT: "success", + PACKAGE_INTEGRITY_RESULT: "success", + PACKAGE_TELEGRAM_RESULT: params.telegramResult, + PATH: process.env.PATH, + RESOLVE_RESULT: "success", + TELEGRAM_ENABLED: String(params.telegramEnabled), + }, + }); +} + describe("package acceptance workflow", () => { it("verifies immutable postpublish evidence before stable closeout reads it", () => { const workflow = readFileSync(STABLE_MAIN_CLOSEOUT_WORKFLOW, "utf8"); @@ -1055,6 +1102,29 @@ describe("package artifact reuse", () => { ).toHaveLength(2); }); + it("pins DeepSeek live profiles to both current V4 model refs", () => { + const deepSeek = workflowMatrixEntry( + LIVE_E2E_WORKFLOW, + "validate_live_provider_suites", + "native-live-src-gateway-profiles-deepseek", + ); + const openCodeGo = workflowMatrixEntry( + LIVE_E2E_WORKFLOW, + "validate_live_provider_suites", + "native-live-src-gateway-profiles-opencode-go-deepseek-glm", + ); + + expect(deepSeek).toMatchObject({ + advisory: true, + command: + "OPENCLAW_LIVE_GATEWAY_PROVIDERS=deepseek OPENCLAW_LIVE_GATEWAY_MODELS=deepseek/deepseek-v4-flash,deepseek/deepseek-v4-pro node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles", + profiles: "full", + }); + expect(openCodeGo.command).toContain( + "OPENCLAW_LIVE_GATEWAY_MODELS=opencode-go/deepseek-v4-flash,opencode-go/deepseek-v4-pro", + ); + }); + it("runs Docker live harnesses from trusted helper scripts", () => { const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8"); const scenarios = readFileSync("scripts/lib/docker-e2e-scenarios.mjs", "utf8"); @@ -1773,6 +1843,42 @@ describe("package artifact reuse", () => { expect(workflow).not.toContain("npm_telegram:"); }); + it.each([ + { telegramEnabled: true, telegramResult: "success" }, + { telegramEnabled: false, telegramResult: "skipped" }, + ])( + "accepts Telegram result $telegramResult when enabled=$telegramEnabled", + ({ telegramEnabled, telegramResult }) => { + const result = runPackageAcceptanceSummary({ telegramEnabled, telegramResult }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + }, + ); + + it("rejects a skipped Telegram lane when package acceptance enabled it", () => { + const result = runPackageAcceptanceSummary({ + telegramEnabled: true, + telegramResult: "skipped", + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("::error::package_telegram ended with skipped"); + }); + + it("preserves advisory handling for an unexpectedly skipped Telegram lane", () => { + const result = runPackageAcceptanceSummary({ + advisory: true, + telegramEnabled: true, + telegramResult: "skipped", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain( + "::warning::package_telegram ended with skipped; package acceptance is advisory for this caller.", + ); + }); + it("gives release build steps enough Node heap", () => { for (const workflowPath of [LIVE_E2E_WORKFLOW, RELEASE_CHECKS_WORKFLOW]) { const jobs = readWorkflow(workflowPath).jobs ?? {}; diff --git a/test/scripts/parallels-smoke-model.test.ts b/test/scripts/parallels-smoke-model.test.ts index 16e6c08a8e6a..a499cae8819a 100644 --- a/test/scripts/parallels-smoke-model.test.ts +++ b/test/scripts/parallels-smoke-model.test.ts @@ -438,6 +438,19 @@ describe("Parallels smoke model selection", () => { expect(retained).toBe(`${"a".repeat(2)}${"b".repeat(10)}`); }); + it("accepts npm 10/11 array and npm 12 workspace result shapes", () => { + expect( + packageArtifactTesting.resolveNpmPackTarballFilename([ + { filename: "openclaw-2026.6.11.tgz" }, + ]), + ).toBe("openclaw-2026.6.11.tgz"); + expect( + packageArtifactTesting.resolveNpmPackTarballFilename({ + openclaw: { filename: "openclaw-2026.6.11.tgz" }, + }), + ).toBe("openclaw-2026.6.11.tgz"); + }); + it("keeps fresh package locks with malformed owner pids", async () => { const lockDir = makeTempDir(tempDirs, "openclaw-parallels-package-lock-"); mkdirSync(lockDir, { recursive: true }); diff --git a/ui/src/components/agent-select.test.ts b/ui/src/components/agent-select.test.ts index 08f032d125b7..ca4776fad7c1 100644 --- a/ui/src/components/agent-select.test.ts +++ b/ui/src/components/agent-select.test.ts @@ -3,7 +3,15 @@ import { expect, it, vi } from "vitest"; import type { AgentIdentityResult, GatewayAgentRow } from "../api/types.ts"; import { i18n, t } from "../i18n/index.ts"; -import "./agent-select.ts"; +import { AgentSelect } from "./agent-select.ts"; + +const AGENT_SELECT_TEST_TAG = "test-openclaw-agent-select"; + +// The shared jsdom registry outlives Vitest's per-file module reset. Use the +// freshly imported class so locale state and the element controller stay paired. +if (!customElements.get(AGENT_SELECT_TEST_TAG)) { + customElements.define(AGENT_SELECT_TEST_TAG, class extends AgentSelect {}); +} type AgentSelectElement = HTMLElement & { agents: GatewayAgentRow[]; @@ -36,7 +44,7 @@ function createIdentity( async function createAgentSelect( overrides: Partial<Omit<AgentSelectElement, keyof HTMLElement>> = {}, ): Promise<AgentSelectElement> { - const element = document.createElement("openclaw-agent-select") as AgentSelectElement; + const element = document.createElement(AGENT_SELECT_TEST_TAG) as AgentSelectElement; element.agents = agents; element.selectedId = "alpha"; Object.assign(element, overrides); @@ -336,8 +344,9 @@ it("refreshes translated labels when the locale changes while mounted", async () await i18n.setLocale("zh-CN"); await element.updateComplete; - expect(label?.textContent?.trim()).toBe(t("agents.noAgents")); - expect(label?.textContent?.trim()).not.toBe(englishLabel); + const translatedLabel = element.querySelector(".agent-select__label"); + expect(translatedLabel?.textContent?.trim()).toBe(t("agents.noAgents")); + expect(translatedLabel?.textContent?.trim()).not.toBe(englishLabel); } finally { element.remove(); await i18n.setLocale("en"); diff --git a/ui/src/components/agent-select.ts b/ui/src/components/agent-select.ts index 3a7359449655..5ca9933f5b38 100644 --- a/ui/src/components/agent-select.ts +++ b/ui/src/components/agent-select.ts @@ -11,7 +11,7 @@ import { resolveAgentAvatarUrl } from "../lib/avatar.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { icons } from "./icons.ts"; -class AgentSelect extends OpenClawLightDomElement { +export class AgentSelect extends OpenClawLightDomElement { @property({ attribute: false }) agents: GatewayAgentRow[] = []; @property({ attribute: false }) selectedId: string | null = null; @property({ attribute: false }) defaultId: string | null = null; diff --git a/ui/src/components/native-link-menu.test.ts b/ui/src/components/native-link-menu.test.ts index 6f1829c4d78b..1645d520912a 100644 --- a/ui/src/components/native-link-menu.test.ts +++ b/ui/src/components/native-link-menu.test.ts @@ -5,8 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; import { NativeLinkMenu, type NativeLinkMenuAction } from "./native-link-menu.ts"; +const NATIVE_LINK_MENU_ELEMENT_NAME = "test-openclaw-native-link-menu"; const containers: HTMLElement[] = []; +// The non-isolated UI runner resets modules but not customElements. Register +// the current class graph so instanceof and locale updates share one module. +class TestNativeLinkMenu extends NativeLinkMenu {} + +if (!customElements.get(NATIVE_LINK_MENU_ELEMENT_NAME)) { + customElements.define(NATIVE_LINK_MENU_ELEMENT_NAME, TestNativeLinkMenu); +} + beforeEach(async () => { await i18n.setLocale("en"); }); @@ -27,16 +36,16 @@ async function mountMenu(options: { containers.push(container); document.body.append(container); render( - html`<openclaw-native-link-menu + html`<test-openclaw-native-link-menu .x=${100} .y=${100} .trigger=${options.trigger ?? null} .onAction=${options.onAction ?? (() => {})} .onClose=${options.onClose ?? (() => {})} - ></openclaw-native-link-menu>`, + ></test-openclaw-native-link-menu>`, container, ); - const menu = container.querySelector("openclaw-native-link-menu"); + const menu = container.querySelector(NATIVE_LINK_MENU_ELEMENT_NAME); if (!(menu instanceof NativeLinkMenu)) { throw new Error("Expected native link menu"); } diff --git a/ui/src/components/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts index 6f643ff7bd45..5c2f53682111 100644 --- a/ui/src/components/terminal/terminal-panel.test.ts +++ b/ui/src/components/terminal/terminal-panel.test.ts @@ -52,6 +52,16 @@ vi.mock("./terminal-runtime.ts", () => { import { OpenClawTerminalPanel } from "./terminal-panel.ts"; +const TERMINAL_PANEL_ELEMENT_NAME = "test-openclaw-terminal-panel"; + +// Keep the mounted panel and i18n manager in the current module graph when +// the non-isolated runner has retained an earlier production registration. +class TestTerminalPanel extends OpenClawTerminalPanel {} + +if (!customElements.get(TERMINAL_PANEL_ELEMENT_NAME)) { + customElements.define(TERMINAL_PANEL_ELEMENT_NAME, TestTerminalPanel); +} + describe("OpenClawTerminalPanel", () => { beforeEach(async () => { await i18n.setLocale("en"); @@ -96,7 +106,7 @@ describe("OpenClawTerminalPanel", () => { }, addEventListener: () => () => {}, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.agentId = "ops"; panel.available = true; @@ -164,7 +174,7 @@ describe("OpenClawTerminalPanel", () => { }, addEventListener: () => () => {}, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.available = true; panel.fullscreen = true; @@ -233,7 +243,7 @@ describe("OpenClawTerminalPanel", () => { }; }, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.available = true; document.body.append(panel); @@ -296,7 +306,7 @@ describe("OpenClawTerminalPanel", () => { }, addEventListener: () => () => {}, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = oldClient; panel.available = true; document.body.append(panel); @@ -332,7 +342,7 @@ describe("OpenClawTerminalPanel", () => { }, addEventListener: () => () => {}, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.available = true; document.body.append(panel); @@ -367,7 +377,7 @@ describe("OpenClawTerminalPanel", () => { (method === "terminal.open" ? terminalOpenResult("session-1") : {}) as T, addEventListener: () => () => {}, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.available = true; document.body.append(panel); @@ -389,7 +399,7 @@ describe("OpenClawTerminalPanel", () => { }); it("removes a tab host even when controller disposal throws", () => { - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; const host = document.createElement("div"); document.body.append(host); const dispose = vi.fn(() => { @@ -419,7 +429,7 @@ describe("OpenClawTerminalPanel", () => { }; }, }; - const panel = document.createElement("openclaw-terminal-panel") as OpenClawTerminalPanel; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; panel.client = client; panel.available = true; document.body.append(panel); diff --git a/ui/src/pages/dreams/dreaming.test.ts b/ui/src/pages/dreams/dreaming.test.ts index f80481279b0f..3245cf038697 100644 --- a/ui/src/pages/dreams/dreaming.test.ts +++ b/ui/src/pages/dreams/dreaming.test.ts @@ -389,6 +389,57 @@ describe("dreaming controller", () => { expect(state.wikiImportInsightsLoading).toBe(false); }); + it("loads wiki import insights for the selected agent", async () => { + const { state, request } = createState(); + state.selectedAgentId = "support"; + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: ["wiki.importInsights"] }, + }; + request.mockResolvedValue({ sourceType: "chatgpt", totalItems: 1, clusters: [] }); + + await loadWikiImportInsights(state); + + expect(request).toHaveBeenCalledWith("wiki.importInsights", { agentId: "support" }); + }); + + it("starts a new selected-agent import load and ignores stale completions", async () => { + const { state, request } = createState(); + const agentA = createDeferred<unknown>(); + const agentB = createDeferred<unknown>(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: ["wiki.importInsights"] }, + }; + request.mockImplementation(async (_method: string, payload?: unknown) => { + const agentId = + typeof payload === "object" && payload !== null && "agentId" in payload + ? payload.agentId + : undefined; + return agentId === "agent-b" ? agentB.promise : agentA.promise; + }); + + state.selectedAgentId = "agent-a"; + const firstLoad = loadWikiImportInsights(state); + state.selectedAgentId = "agent-b"; + const secondLoad = loadWikiImportInsights(state); + + agentB.resolve({ sourceType: "chatgpt", totalItems: 2, clusters: [] }); + await secondLoad; + agentA.resolve({ sourceType: "chatgpt", totalItems: 1, clusters: [] }); + await firstLoad; + + expect(request).toHaveBeenCalledWith("wiki.importInsights", { agentId: "agent-a" }); + expect(request).toHaveBeenCalledWith("wiki.importInsights", { agentId: "agent-b" }); + expect(state.wikiImportInsights?.totalItems).toBe(2); + expect(state.wikiImportInsightsLoading).toBe(false); + expect(state.wikiImportInsightsError).toBeNull(); + }); + it("falls back to config gating for wiki import insights when methods are not advertised", async () => { const { state, request } = createState(); state.configSnapshot = { @@ -558,6 +609,57 @@ describe("dreaming controller", () => { expect(state.wikiMemoryPalaceLoading).toBe(false); }); + it("loads the wiki memory palace for the selected agent", async () => { + const { state, request } = createState(); + state.selectedAgentId = "marketing"; + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: ["wiki.palace"] }, + }; + request.mockResolvedValue({ totalItems: 1, clusters: [] }); + + await loadWikiMemoryPalace(state); + + expect(request).toHaveBeenCalledWith("wiki.palace", { agentId: "marketing" }); + }); + + it("starts a new selected-agent palace load and ignores stale completions", async () => { + const { state, request } = createState(); + const agentA = createDeferred<unknown>(); + const agentB = createDeferred<unknown>(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: ["wiki.palace"] }, + }; + request.mockImplementation(async (_method: string, payload?: unknown) => { + const agentId = + typeof payload === "object" && payload !== null && "agentId" in payload + ? payload.agentId + : undefined; + return agentId === "agent-b" ? agentB.promise : agentA.promise; + }); + + state.selectedAgentId = "agent-a"; + const firstLoad = loadWikiMemoryPalace(state); + state.selectedAgentId = "agent-b"; + const secondLoad = loadWikiMemoryPalace(state); + + agentB.resolve({ totalItems: 2, clusters: [] }); + await secondLoad; + agentA.resolve({ totalItems: 1, clusters: [] }); + await firstLoad; + + expect(request).toHaveBeenCalledWith("wiki.palace", { agentId: "agent-a" }); + expect(request).toHaveBeenCalledWith("wiki.palace", { agentId: "agent-b" }); + expect(state.wikiMemoryPalace?.totalItems).toBe(2); + expect(state.wikiMemoryPalaceLoading).toBe(false); + expect(state.wikiMemoryPalaceError).toBeNull(); + }); + it("derives legacy wiki memory palace page counts from clusters", async () => { const { state, request } = createState(); state.hello = { diff --git a/ui/src/pages/dreams/dreaming.ts b/ui/src/pages/dreams/dreaming.ts index c6a11fb14020..3d868213cb47 100644 --- a/ui/src/pages/dreams/dreaming.ts +++ b/ui/src/pages/dreams/dreaming.ts @@ -232,9 +232,19 @@ export type DreamingState = { dreamDiaryError: string | null; dreamDiaryPath: string | null; dreamDiaryContent: string | null; + // Agent switches can overlap RPCs; generations keep an old A -> B -> A response + // from replacing the current agent's wiki data. + wikiImportInsightsRequestAgentId?: string | null; + wikiImportInsightsRequestGeneration?: number; + wikiImportInsightsActiveRequestGeneration?: number | null; + wikiImportInsightsAgentId?: string | null; wikiImportInsightsLoading: boolean; wikiImportInsightsError: string | null; wikiImportInsights: WikiImportInsights | null; + wikiMemoryPalaceRequestAgentId?: string | null; + wikiMemoryPalaceRequestGeneration?: number; + wikiMemoryPalaceActiveRequestGeneration?: number | null; + wikiMemoryPalaceAgentId?: string | null; wikiMemoryPalaceLoading: boolean; wikiMemoryPalaceError: string | null; wikiMemoryPalace: WikiMemoryPalace | null; @@ -939,47 +949,114 @@ export async function loadDreamDiary(state: DreamingState): Promise<void> { } export async function loadWikiImportInsights(state: DreamingState): Promise<void> { - if (!state.client || !state.connected || state.wikiImportInsightsLoading) { + if (!state.client || !state.connected) { return; } + const agentId = resolveSelectedAgentId(state); + if (state.wikiImportInsightsLoading && state.wikiImportInsightsRequestAgentId === agentId) { + return; + } + if (state.wikiImportInsightsAgentId !== agentId) { + state.wikiImportInsights = null; + } if (!canCallMemoryWikiMethod(state, "wiki.importInsights")) { + state.wikiImportInsightsActiveRequestGeneration = null; + state.wikiImportInsightsRequestAgentId = null; + state.wikiImportInsightsLoading = false; state.wikiImportInsights = null; state.wikiImportInsightsError = null; return; } + const requestGeneration = (state.wikiImportInsightsRequestGeneration ?? 0) + 1; + state.wikiImportInsightsRequestGeneration = requestGeneration; + state.wikiImportInsightsActiveRequestGeneration = requestGeneration; + state.wikiImportInsightsRequestAgentId = agentId; state.wikiImportInsightsLoading = true; state.wikiImportInsightsError = null; try { const payload = await state.client.request<WikiImportInsightsPayload>( "wiki.importInsights", - {}, + buildSelectedAgentPayloadForAgentId(agentId), ); + if ( + state.wikiImportInsightsActiveRequestGeneration !== requestGeneration || + state.wikiImportInsightsRequestAgentId !== agentId || + resolveSelectedAgentId(state) !== agentId + ) { + return; + } state.wikiImportInsights = normalizeWikiImportInsights(payload); + state.wikiImportInsightsAgentId = agentId; } catch (err) { - state.wikiImportInsightsError = String(err); + if ( + state.wikiImportInsightsActiveRequestGeneration === requestGeneration && + state.wikiImportInsightsRequestAgentId === agentId && + resolveSelectedAgentId(state) === agentId + ) { + state.wikiImportInsightsError = String(err); + } } finally { - state.wikiImportInsightsLoading = false; + if (state.wikiImportInsightsActiveRequestGeneration === requestGeneration) { + state.wikiImportInsightsLoading = false; + state.wikiImportInsightsRequestAgentId = null; + state.wikiImportInsightsActiveRequestGeneration = null; + } } } export async function loadWikiMemoryPalace(state: DreamingState): Promise<void> { - if (!state.client || !state.connected || state.wikiMemoryPalaceLoading) { + if (!state.client || !state.connected) { return; } + const agentId = resolveSelectedAgentId(state); + if (state.wikiMemoryPalaceLoading && state.wikiMemoryPalaceRequestAgentId === agentId) { + return; + } + if (state.wikiMemoryPalaceAgentId !== agentId) { + state.wikiMemoryPalace = null; + } if (!canCallMemoryWikiMethod(state, "wiki.palace")) { + state.wikiMemoryPalaceActiveRequestGeneration = null; + state.wikiMemoryPalaceRequestAgentId = null; + state.wikiMemoryPalaceLoading = false; state.wikiMemoryPalace = null; state.wikiMemoryPalaceError = null; return; } + const requestGeneration = (state.wikiMemoryPalaceRequestGeneration ?? 0) + 1; + state.wikiMemoryPalaceRequestGeneration = requestGeneration; + state.wikiMemoryPalaceActiveRequestGeneration = requestGeneration; + state.wikiMemoryPalaceRequestAgentId = agentId; state.wikiMemoryPalaceLoading = true; state.wikiMemoryPalaceError = null; try { - const payload = await state.client.request<WikiMemoryPalacePayload>("wiki.palace", {}); + const payload = await state.client.request<WikiMemoryPalacePayload>( + "wiki.palace", + buildSelectedAgentPayloadForAgentId(agentId), + ); + if ( + state.wikiMemoryPalaceActiveRequestGeneration !== requestGeneration || + state.wikiMemoryPalaceRequestAgentId !== agentId || + resolveSelectedAgentId(state) !== agentId + ) { + return; + } state.wikiMemoryPalace = normalizeWikiMemoryPalace(payload); + state.wikiMemoryPalaceAgentId = agentId; } catch (err) { - state.wikiMemoryPalaceError = String(err); + if ( + state.wikiMemoryPalaceActiveRequestGeneration === requestGeneration && + state.wikiMemoryPalaceRequestAgentId === agentId && + resolveSelectedAgentId(state) === agentId + ) { + state.wikiMemoryPalaceError = String(err); + } } finally { - state.wikiMemoryPalaceLoading = false; + if (state.wikiMemoryPalaceActiveRequestGeneration === requestGeneration) { + state.wikiMemoryPalaceLoading = false; + state.wikiMemoryPalaceRequestAgentId = null; + state.wikiMemoryPalaceActiveRequestGeneration = null; + } } } diff --git a/ui/src/pages/dreams/dreams-page.test.ts b/ui/src/pages/dreams/dreams-page.test.ts index 9df278a81643..c19002678131 100644 --- a/ui/src/pages/dreams/dreams-page.test.ts +++ b/ui/src/pages/dreams/dreams-page.test.ts @@ -21,6 +21,7 @@ type TestDreamsPage = HTMLElement & { applyGatewaySnapshot: (snapshot: ApplicationGatewaySnapshot) => void; loadAll: () => Promise<void>; openWikiPage: (lookup: string) => Promise<unknown>; + selectAgent: (agentId: string) => void; render: () => unknown; requestUpdate: () => void; readonly updateComplete: Promise<boolean>; @@ -196,4 +197,62 @@ describe("DreamsPage gateway lifecycle", () => { expect(page.dreaming).not.toBe(previousState); expect(page.viewState.wikiPreviewContent).toBe(""); }); + + it("loads wiki previews for the selected agent", async () => { + const request = vi.fn(async () => ({ + title: "Support", + path: "support.md", + content: "support-only", + })); + const client = { request } as unknown as GatewayBrowserClient; + const page = createPage(contextWithGateway(client, true)); + document.body.append(page); + await page.updateComplete; + page.dreaming.selectedAgentId = "support"; + + await page.openWikiPage("support.md"); + + expect(request).toHaveBeenCalledWith("wiki.get", { + lookup: "support.md", + fromLine: 1, + lineCount: 5000, + agentId: "support", + }); + }); + + it("discards a wiki preview after the selected agent changes", async () => { + const pending = deferred<unknown>(); + const client = { + request: vi.fn(() => pending.promise), + } as unknown as GatewayBrowserClient; + const page = createPage(contextWithGateway(client, true)); + document.body.append(page); + await page.updateComplete; + page.dreaming.selectedAgentId = "support"; + + const preview = page.openWikiPage("support.md"); + page.dreaming.selectedAgentId = "marketing"; + pending.resolve({ title: "Support", path: "support.md", content: "stale" }); + + await expect(preview).resolves.toBeNull(); + }); + + it("closes an open wiki preview when the selected agent changes", async () => { + const client = { + request: vi.fn(async () => ({})), + } as unknown as GatewayBrowserClient; + const page = createPage(contextWithGateway(client, true)); + document.body.append(page); + await page.updateComplete; + page.dreaming.selectedAgentId = "support"; + page.viewState.wikiPreviewOpen = true; + page.viewState.wikiPreviewLoading = true; + page.viewState.wikiPreviewContent = "support-only"; + + page.selectAgent("marketing"); + + expect(page.viewState.wikiPreviewOpen).toBe(false); + expect(page.viewState.wikiPreviewLoading).toBe(false); + expect(page.viewState.wikiPreviewContent).toBe(""); + }); }); diff --git a/ui/src/pages/dreams/dreams-page.ts b/ui/src/pages/dreams/dreams-page.ts index 218bc9abc18a..2bb5501889ef 100644 --- a/ui/src/pages/dreams/dreams-page.ts +++ b/ui/src/pages/dreams/dreams-page.ts @@ -206,6 +206,13 @@ class DreamsPage extends OpenClawLightDomElement { } private resetTransientState() { + this.resetWikiPreview(); + this.restartConfirmOpen = false; + this.restartConfirmLoading = false; + this.pendingEnabled = null; + } + + private resetWikiPreview() { this.viewState.wikiPreviewRequestId += 1; this.viewState.wikiPreviewOpen = false; this.viewState.wikiPreviewLoading = false; @@ -216,9 +223,6 @@ class DreamsPage extends OpenClawLightDomElement { this.viewState.wikiPreviewTotalLines = null; this.viewState.wikiPreviewTruncated = false; this.viewState.wikiPreviewError = null; - this.restartConfirmOpen = false; - this.restartConfirmLoading = false; - this.pendingEnabled = null; } private createGatewayState(snapshot = this.context.gateway.snapshot): DreamingState { @@ -265,6 +269,7 @@ class DreamsPage extends OpenClawLightDomElement { const agentsList = this.context.agents.state.agentsList; const selected = this.dreaming.selectedAgentId; if (agentsList && (!selected || !agentsList.agents.some((agent) => agent.id === selected))) { + this.resetWikiPreview(); this.dreaming.selectedAgentId = this.resolveSelectedAgentId(); if (!this.awaitingRouteData) { this.routeDataEnabled = false; @@ -366,6 +371,8 @@ class DreamsPage extends OpenClawLightDomElement { void Promise.all([ this.runDreamingTask(loadDreamingStatus, scope), this.runDreamingTask(loadDreamDiary, scope), + this.runDreamingTask(loadWikiImportInsights, scope), + this.runDreamingTask(loadWikiMemoryPalace, scope), ]); } @@ -374,6 +381,7 @@ class DreamsPage extends OpenClawLightDomElement { return; } this.routeDataEnabled = false; + this.resetWikiPreview(); this.dreaming.selectedAgentId = agentId; this.loadSelectedAgentData(); } @@ -451,12 +459,17 @@ class DreamsPage extends OpenClawLightDomElement { if (!scope || !client || !scope.state.connected) { return null; } + const agentId = scope.state.selectedAgentId?.trim() || null; const payload = await client.request("wiki.get", { lookup, fromLine: 1, lineCount: 5000, + ...(agentId ? { agentId } : {}), }); - if (!this.isTaskScopeCurrent(scope)) { + if ( + !this.isTaskScopeCurrent(scope) || + (scope.state.selectedAgentId?.trim() || null) !== agentId + ) { return null; } return readWikiPagePreview(payload, lookup); diff --git a/ui/src/pages/profile/profile-page.test.ts b/ui/src/pages/profile/profile-page.test.ts index 75b96c81698e..7deef8fb4922 100644 --- a/ui/src/pages/profile/profile-page.test.ts +++ b/ui/src/pages/profile/profile-page.test.ts @@ -10,9 +10,10 @@ import { type ApplicationGatewaySnapshot, } from "../../app/context.ts"; import { i18n, t } from "../../i18n/index.ts"; -import "./profile-page.ts"; +import { ProfilePage } from "./profile-page.ts"; const PROVIDER_ELEMENT_NAME = "test-profile-page-context-provider"; +const PROFILE_PAGE_TEST_TAG = "test-openclaw-profile-page"; class ProfilePageContextProvider extends LitElement { private readonly contextProvider = new ContextProvider(this, { @@ -27,6 +28,10 @@ class ProfilePageContextProvider extends LitElement { if (!customElements.get(PROVIDER_ELEMENT_NAME)) { customElements.define(PROVIDER_ELEMENT_NAME, ProfilePageContextProvider); } +// Keep the element class on the same post-reset i18n module as this test. +if (!customElements.get(PROFILE_PAGE_TEST_TAG)) { + customElements.define(PROFILE_PAGE_TEST_TAG, class extends ProfilePage {}); +} type ProfilePageElement = HTMLElement & { updateComplete: Promise<boolean>; @@ -62,7 +67,7 @@ afterEach(async () => { it("refreshes translated copy when the locale changes while mounted", async () => { const provider = document.createElement(PROVIDER_ELEMENT_NAME) as ProfilePageContextProvider; - const page = document.createElement("openclaw-profile-page") as ProfilePageElement; + const page = document.createElement(PROFILE_PAGE_TEST_TAG) as ProfilePageElement; provider.setContext(createContext()); provider.append(page); document.body.append(provider); diff --git a/ui/src/pages/profile/profile-page.ts b/ui/src/pages/profile/profile-page.ts index bc2b37c9e784..a4f7ff77fb58 100644 --- a/ui/src/pages/profile/profile-page.ts +++ b/ui/src/pages/profile/profile-page.ts @@ -85,7 +85,7 @@ function toErrorMessage(error: unknown): string { return typeof error === "string" ? error : "request failed"; } -class ProfilePage extends OpenClawLightDomElement { +export class ProfilePage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: false }) private context!: ApplicationContext; @@ -540,4 +540,6 @@ class ProfilePage extends OpenClawLightDomElement { } } -customElements.define("openclaw-profile-page", ProfilePage); +if (!customElements.get("openclaw-profile-page")) { + customElements.define("openclaw-profile-page", ProfilePage); +} diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 5c37c76b1bd2..8f5f202d426b 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -6403,7 +6403,6 @@ details[open] > .ov-expandable-toggle::after { border-radius: var(--radius-sm); background: transparent; color: var(--muted); - cursor: pointer; opacity: 0; pointer-events: none; transition: diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 0ea90b06f5a3..8e37be617358 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -801,7 +801,6 @@ border-radius: var(--radius-md); background: transparent; color: var(--accent); - cursor: pointer; font: inherit; font-size: 13px; font-weight: 600; @@ -869,6 +868,9 @@ min-height: 0; overflow-y: auto; scrollbar-width: thin; + /* Sessions are app chrome, not hyperlinks: the whole region uses the + default arrow cursor; the pointer hand is reserved for real links. */ + cursor: default; } .sidebar-recent-sessions__group { @@ -926,7 +928,6 @@ background: transparent; color: inherit; text-align: left; - cursor: pointer; } .sidebar-session-group-toggle__icon { @@ -979,7 +980,6 @@ border-radius: var(--radius-md); background: transparent; color: var(--muted); - cursor: pointer; transition: background var(--duration-fast) ease, color var(--duration-fast) ease; @@ -1014,7 +1014,6 @@ border-radius: var(--radius-sm); background: transparent; color: var(--muted); - cursor: pointer; opacity: 0; pointer-events: none; transition: @@ -1077,7 +1076,6 @@ font-size: 11px; font-weight: 600; text-overflow: ellipsis; - cursor: pointer; transition: background var(--duration-fast) ease, color var(--duration-fast) ease; @@ -1130,6 +1128,8 @@ opacity: 0.45; } +/* Rows and row links are anchors (for middle-click/new-tab), but read as app + controls: override the UA link pointer back to the default arrow. */ .sidebar-recent-session { display: flex; align-items: center; @@ -1139,6 +1139,7 @@ border: 1px solid transparent; border-radius: var(--radius-md); color: var(--muted); + cursor: default; transition: background var(--duration-fast) ease, border-color var(--duration-fast) ease, @@ -1164,6 +1165,7 @@ min-width: 0; padding: 5px 4px 5px 9px; color: inherit; + cursor: default; text-decoration: none; }