diff --git a/.agents/skills/openclaw-pr-maintainer/SKILL.md b/.agents/skills/openclaw-pr-maintainer/SKILL.md index 921f7b01f8b3..e1e091d290be 100644 --- a/.agents/skills/openclaw-pr-maintainer/SKILL.md +++ b/.agents/skills/openclaw-pr-maintainer/SKILL.md @@ -365,7 +365,9 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \ - Stacked branches over a squash-merged parent: rebase with `git rebase --onto origin/main `; a plain `git rebase origin/main` replays the parent's already-squashed commits and manufactures conflicts. - Non-main PRs: do not run `scripts/pr prepare-run` or `merge-run`; they diff against `main`. Use review artifacts, exact base-head CI, revalidate `headRefOid`, then `gh pr merge --match-head-commit `. - PR-create merge-ref race recognition: the dropped/killed pull_request CI run appears as `startup_failure`/`BuildFailed` (`(Unknown event)`) and is not rerunnable — close/reopen or wait for the hourly `pr-ci-sweeper`; rerun attempts are wasted. -- PR/issue media upload: 422 = unsupported type; 404 = bad repo id/no push. Video: `content_type` `video/mp4` or `video/webm`; embed the returned URL on its own bare line — GitHub renders a player, `![]()` does not; transcode Playwright webm via `ffmpeg -i in.webm -c:v libx264 -pix_fmt yuv420p out.mp4` for broad playback. Non-media artifacts or endpoint failure: Crabbox artifact publishing plus the manifest URL. +- Preferred PR/issue media upload: when the command help exposes `--attach`, use the repeatable flag on `gh issue create`, `gh issue edit`, `gh issue comment`, and the matching `gh pr` commands. Example: `gh pr comment --repo openclaw/openclaw --body-file --attach `. +- `gh --attach` video rules: accepted extensions are `.mp4`, `.mov`, and `.webm`; the local maximum is 100 MB, while GitHub's account limit may be lower. Do not add `#alt` to a video path. `gh` inserts a bare URL so GitHub renders a player, and the uploaded asset cannot be deleted. +- Compatibility fallback: if the installed `gh` lacks `--attach`, use the raw user-attachments upload command in root `AGENTS.md`. For that endpoint, 422 = unsupported type and 404 = bad repo id/no push. Use `content_type` `video/mp4`, `video/quicktime`, or `video/webm`, and put the returned URL on its own bare line; `![]()` does not render the player. Transcode Playwright webm via `ffmpeg -i in.webm -c:v libx264 -pix_fmt yuv420p out.mp4` for broad playback. Non-media artifacts or endpoint failure: Crabbox artifact publishing plus the manifest URL. - Use standard Git commands and stage only the files intended for each commit. - Keep commit messages concise and action-oriented. - Group related changes; avoid bundling unrelated refactors. diff --git a/.agents/skills/openclaw-testing/SKILL.md b/.agents/skills/openclaw-testing/SKILL.md index 6875624d919f..3f5899457966 100644 --- a/.agents/skills/openclaw-testing/SKILL.md +++ b/.agents/skills/openclaw-testing/SKILL.md @@ -340,16 +340,34 @@ lanes are intentionally reserved for the separate `Plugin Prerelease` child so PRs, main pushes, and ad hoc broad CI checks do not spend Docker/package time or all-plugin runtime time on release-only product coverage. -Use one operator, one transition-only watcher, and at most one investigator for -the current failed surface. Parent timeout or cancellation leaves adopted exact -children running; cancel an exact child only by explicit operator action or the -workflow's identity-mismatch/fail-fast path. +Use one operator, one foreground owner, and at most one investigator for the +current failed surface. Do not start `release-ci-summary --watch` while the +SHA-pinned helper is already watching the same parent. Parent timeout or +cancellation leaves adopted exact children running; cancel an exact child only +by explicit operator action or by `fail_fast=true` after Release Decision binds +the failure to that exact active run. -The child-dispatch jobs record child run ids, and `Verify full validation` -re-queries them during that parent attempt. A later narrow green run is useful -recovery evidence but is not publish authorization by itself and there is no -standalone finalizer. The release owner must reassess the recorded evidence and -current publish gate. +The child-dispatch jobs record run ID, run attempt, and URL, then finish. The +parent seals those tuples, original dispatch titles, gate coverage, reuse +policy, and original parent attempt in one immutable +`full-release-execution-plan-` artifact. Collector retries restore that +artifact and adopt its children; they never reconstruct the plan or redispatch +tests. +`Release Decision` polls those exact identities and can report +`blocked_diagnostics_running` before unrelated children finish. +For reused evidence, it also repeats the canonical target, policy, changed-path, +selected-run, root-run, and exact-child validation before it can pass. +`Diagnostic Drain` continues every selected child to terminal with +`fail_fast=false` unless the collector itself is cancelled or loses API +access. `orchestration_error` permits collector recovery against the same +exact children, never test redispatch. Diagnose `blocked_diagnostics_running` +immediately, but wait for a terminal drain before retrying the failed surface. +The final `Verify full validation` job consumes and validates the immutable +execution plan plus the exact Decision and Drain artifacts instead of +reclassifying child results. A +later narrow green run is useful recovery evidence but is not publish +authorization by itself and there is no standalone finalizer. The release owner +must reassess the recorded evidence and current publish gate. Once the Code SHA is green, generate and commit only `CHANGELOG.md`. The new **Release SHA** is eligible for product-evidence reuse only when GitHub proves diff --git a/.agents/skills/release-openclaw-ci/SKILL.md b/.agents/skills/release-openclaw-ci/SKILL.md index d42d3a49c892..6ccf9e1d8ef9 100644 --- a/.agents/skills/release-openclaw-ci/SKILL.md +++ b/.agents/skills/release-openclaw-ci/SKILL.md @@ -48,9 +48,25 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele entitlement. Mandatory live providers must pass a real completion probe before release dispatch. Fix the credential first; do not add an alternate auth path merely to bypass a failed release credential. -- Full Release Validation collects independent child failures to terminal - completion by default. Pass `fail_fast=true` only when the shorter - first-failure cancellation path is preferable. +- Full Release Validation separates exact-child dispatch, Release Decision, + and Diagnostic Drain. With `fail_fast=false`, it makes zero child + cancellation calls; Diagnostic Drain follows every selected child to + terminal unless the collector itself is cancelled or loses GitHub API + access. With + `fail_fast=true`, Release Decision may cancel only the exact still-active + child that owns a blocking failure. +- After dispatch, one immutable execution-plan artifact records the original + parent attempt, exact child tuples and titles, selected coverage, gates, and + reuse identity. Decision, Drain, manifest writing, evidence validation, and + final verification consume that plan. A collector retry restores it and + adopts the same children; missing plan state is an orchestration failure, not + permission to redispatch. +- Reused evidence is not trusted merely because plan sealing found it. Release + Decision repeats the sealed target SHA, evidence SHA, policy, changed paths, + selected run, root run, source manifest, trusted tooling identity, and + exact-child checks before returning `passed`. +- Parent retries select the newest Decision and Drain artifacts independently; + both must bind the same immutable plan even when their source attempts differ. - Use one release operator, one transition-only watcher, and at most one investigator for the current failed surface. Do not build audit-review-plan trees around a single workflow transition. @@ -106,9 +122,10 @@ until their dependent enforcement changes land. - `stable-publish`: `release_profile=stable` - Keep at most one active parent for the same Validation SHA + Tooling SHA + rerun group. Concurrency does not cancel an older exact child automatically. -- Parent cancellation or timeout leaves an adopted identity-checked child - running. The operator must cancel that exact child explicitly when it is no - longer useful. +- Parent cancellation or timeout leaves adopted identity-checked children + running. The operator must cancel an exact child explicitly when it is no + longer useful. Do not infer a child identity from branch, title prefix, or + latest-run order. - Recover one failed surface with one diagnosis, one fix when needed, and one narrow retry. Then reassess the release decision. Do not automatically dispatch `rerun_group=all`. @@ -268,6 +285,13 @@ Use the transition-only summary watcher instead of repeated raw polling: node scripts/release-ci-summary.mjs --watch ``` +Do not start this watcher when the SHA-pinned helper is still the foreground +owner. The helper reads the exact Release Decision artifact itself. On +`blocked_diagnostics_running`, it exits nonzero immediately, keeps the temporary +refs, and leaves Diagnostic Drain collecting the remaining terminal evidence. +The watcher behaves the same way for separately dispatched parents: it reports +the Release Decision blocker once and exits while the drain continues. + For a one-shot snapshot: ```bash @@ -278,6 +302,27 @@ node scripts/release-ci-summary.mjs Diverged release-branch logs: `--first-parent` plus a bounded count. Stop watchers before ending the turn or switching strategy. +Interpret state precisely: + +- `qualifying`: no decisive blocker yet; selected children are still active. +- `blocked_diagnostics_running`: publication is blocked; Diagnostic Drain is + still collecting independent failures. Diagnose now, but do not retry until + the drain is terminal. +- `passed`: all required policy and exact-child evidence passed. +- `blocked_complete`: publication is blocked and all selected diagnostics are + terminal. +- `orchestration_error`: GitHub API or collector failure prevented a verdict. + This is not a provenance mismatch. Recover the collector against the same + exact children; never redispatch tests to repair collection. +- `cancelled_with_children`: the collector was cancelled while exact children + remained active. + +The `full-release-diagnostics--` artifact is the terminal +failure and timing manifest. Use it after an early blocker instead of +restarting `all` merely to discover what the still-running children found. +The stable `full-release-execution-plan-` artifact is the identity +source for every collector attempt. + ## Failure Triage 1. Confirm parent SHA and child run IDs. diff --git a/.github/codex/prompts/mantis-telegram-desktop-proof.md b/.github/codex/prompts/mantis-telegram-desktop-proof.md index 9f78f5c56071..f343a37af154 100644 --- a/.github/codex/prompts/mantis-telegram-desktop-proof.md +++ b/.github/codex/prompts/mantis-telegram-desktop-proof.md @@ -63,15 +63,22 @@ Use `$OPENCLAW_TELEGRAM_MANTIS_LANE_CMD` with `--lane baseline|candidate`: - `requests` (redacted provider requests; zero is a valid recorded fact) - `press --message-id ID --button INDEX` - `delete --message-id ID` (only user messages sent in this session) +- `desktop --actions-file [--timeout-seconds N]` (run an + agent-authored click/key/type/sleep action sequence in the recorded desktop) - `view --message-id ID` (scroll Desktop to the exact Telegram server message) - `screenshot` (returns a public inspection PNG) - `finish [--focus-message-id ID]` (focus the named message or the latest sent message, stop, capture, publish facts) - `block --reason TEXT [--missing-primitive NAME]` (clean stop-report) - `abort` (cleanup after scenario failure) -`start` returns the exact command/budget list. No generic exec/eval or raw -Telegram API exists. If the comparison cannot prove the PR's visible behavior, -use `block` and say why. +`start` returns the exact command/budget list. When the listed primitives cannot +exercise the behavior, extend the harness: write a focused JSON action sequence +under `MANTIS_OUTPUT_DIR` and run it with `desktop`. Actions use Telegram-window +coordinates: `{"command":"click","x":N,"y":N,"button":1}`, +`{"command":"key","keys":["ctrl+a"]}`, `{"command":"type","text":"..."}`, +or `{"command":"sleep","milliseconds":N}`. Inspect a screenshot, adjust the +sequence, and continue the proof. Use `block` only when the ephemeral desktop +itself cannot exercise the behavior. Raw response events must form a complete provider response; deltas alone do not produce a final answer. Copy the terminal item and completed-response structure from `responseEvents` in `scripts/e2e/mock-openai-server.mjs`, and use diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index dc1a82b8494d..c846b1ad6181 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -66,7 +66,7 @@ on: default: false type: boolean fail_fast: - description: Cancel each child workflow after its first failed job; false collects independent failures to completion + description: Cancel only an exact active child after its first blocking job; false drains all children to completion required: false default: false type: boolean @@ -563,7 +563,7 @@ jobs: docker_runtime_assets_preflight: name: Verify Docker runtime image assets needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && inputs.rerun_group == 'all' && needs.evidence_reuse.outputs.reuse != 'true' }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && inputs.rerun_group == 'all' && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -589,7 +589,7 @@ jobs: prepare_release_candidate: name: Prepare shared release candidate needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && needs.evidence_reuse.outputs.reuse != 'true' && inputs.release_package_spec == '' && inputs.package_acceptance_package_spec == '' && (contains(fromJSON('["all","plugin-prerelease","cross-os","package"]'), inputs.rerun_group) || (inputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')) }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && needs.evidence_reuse.outputs.reuse != 'true' && inputs.release_package_spec == '' && inputs.package_acceptance_package_spec == '' && (contains(fromJSON('["all","plugin-prerelease","cross-os","package"]'), inputs.rerun_group) || (inputs.rerun_group == 'live-e2e' && needs.resolve_target.outputs.live_suite_filter == '')) }} permissions: actions: read contents: read @@ -614,16 +614,15 @@ jobs: normal_ci: name: Run normal full CI needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 - # The child owns lane timeouts; this monitor also covers queue delay and iOS. - timeout-minutes: 240 + timeout-minutes: 15 outputs: run_id: ${{ steps.dispatch.outputs.run_id }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} url: ${{ steps.dispatch.outputs.url }} - conclusion: ${{ steps.dispatch.outputs.conclusion }} steps: - - name: Dispatch and monitor CI + - name: Dispatch CI id: dispatch env: GH_TOKEN: ${{ github.token }} @@ -633,10 +632,8 @@ jobs: TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} - FAIL_FAST: ${{ inputs.fail_fast }} run: &full_release_child_dispatch | set -euo pipefail - FAIL_FAST="${FAIL_FAST:-false}" gh_with_retry() { local output status attempt @@ -661,10 +658,6 @@ jobs: return "$status" } - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - validate_child_run() { local candidate_run_id="$1" local candidate_run_json attempt @@ -711,71 +704,11 @@ jobs: return 1 } - fetch_child_jobs() { - if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[]' - return - fi - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - read_child_run_field() { - local field="$1" - if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then - case "$field" in - html_url) field=url ;; - esac - gh_with_retry run view "$run_id" --json "$field" --jq ".${field} // \"\"" - return - fi - fetch_child_run_json | jq -r ".${field} // \"\"" - } - - release_check_blocking_job() { - if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then - return 1 - fi - case "$1" in - "resolve_target" | \ - "Prepare release package artifact" | \ - "install_smoke_release_checks / "* | \ - "Run package acceptance" | \ - "Run package acceptance / "*) - return 0 - ;; - esac - return 1 - } - - release_checks_advisory_only() { - local run_json="$1" - local verifier_conclusion name saw_advisory failed - verifier_conclusion="$( - jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | - tail -n 1 - )" - if [[ "$verifier_conclusion" != "success" ]]; then - return 1 - fi - saw_advisory=0 - failed=0 - while IFS= read -r name; do - [[ -z "${name// }" ]] && continue - if release_check_blocking_job "$name"; then - echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." - failed=1 - else - saw_advisory=1 - fi - done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") - [[ "$saw_advisory" == "1" && "$failed" == "0" ]] - } - - dispatch_and_wait() { + dispatch_child() { local workflow="$1" local dispatch_run_name="$2" shift 2 - local dispatch_output dispatch_status dispatch_run_ids matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha expected_workflow_id started_epoch elapsed_seconds elapsed_minutes + local dispatch_output dispatch_status dispatch_run_ids matches_json match_count run_id run_json child_head_sha child_run_attempt url encoded_workflow_ref current_workflow_sha expected_workflow_id encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" current_workflow_sha="$( @@ -838,147 +771,31 @@ jobs: exit 1 fi - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } run_json="$(validate_child_run "$run_id")" - # Generic monitor failures and parent cancellation leave an - # identity-checked child running for explicit operator recovery. { echo "- Adopted child: \`${workflow}\` run \`${run_id}\`" - echo "- Parent cancellation leaves this child running; cancel it explicitly if no longer needed." + echo "- Release Decision owns blocking policy; Diagnostic Drain owns terminal collection." } >> "$GITHUB_STEP_SUMMARY" child_head_sha="$(jq -r '.head_sha // ""' <<< "$run_json")" if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." >&2 - cancel_child exit 1 fi if [[ "$dispatch_status" -ne 0 ]]; then echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 fi - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + child_run_attempt="$(jq -r '.run_attempt // ""' <<< "$run_json")" + url="$(jq -r '.html_url // ""' <<< "$run_json")" + if [[ ! "$child_run_attempt" =~ ^[1-9][0-9]*$ || -z "${url// }" ]]; then + echo "::error::${workflow} child run omitted its attempt or URL." >&2 + exit 1 + fi + echo "Dispatched ${workflow}: ${url} (attempt ${child_run_attempt})" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - started_epoch="$(date +%s)" - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - return 0 - fi - if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then - failed_jobs_json="$( - gh_with_retry run view "$run_id" --json jobs \ - --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )" - elif ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - # Advisory QA jobs are owned by the child's status-artifact verifier. - failed_jobs_json="$( - jq '[.[] | select( - ((.name | startswith("Run QA Lab parity lane (")) - or .name == "Run QA Lab parity report" - or (.name | startswith("Run QA Lab runtime-pair lane (")) - or .name == "Verify QA Lab runtime-pair lanes" - or .name == "Run QA Lab live Discord lane" - or .name == "Run QA Lab live WhatsApp lane" - or .name == "Run QA Lab live Slack lane") - | not)]' <<< "$failed_jobs_json" - )" - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - # Beta live-provider and Telegram package checks are advisory; repo E2E is blocking. - failed_jobs_json="$( - jq '[.[] | select( - (((.name | startswith("Run repo/live E2E validation / ")) - and ((.name | contains("Docker live")) - or (.name | contains("Live media suites")) - or (.name | contains("validate_live_provider_suites")) - or (.name | contains("validate_release_live_cache")) - or (.name | contains("prepare_live_test_image")))) - or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) - | not)]' <<< "$failed_jobs_json" - )" - fi - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then - echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." - else - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - fi - jq '.[] | {name, conclusion, url: (.url // .html_url)}' <<< "$failed_jobs_json" - cancel_child - exit 1 - fi - } - - poll_count=0 - while true; do - status="$(read_child_run_field status)" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - elapsed_seconds=$(( $(date +%s) - started_epoch )) - elapsed_minutes=$(( elapsed_seconds / 60 )) - echo "Still waiting on ${workflow} after ${elapsed_minutes}m: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: (.url // .html_url)}' || true - fi - sleep 60 - done - - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" - run_json="$( - jq -s '.[0] + .[1]' \ - <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ - <(printf '%s\n' "$jobs_json") - )" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - url="$(jq -r '.url' <<< "$run_json")" - else - conclusion="$(read_child_run_field conclusion)" - url="$(read_child_run_field html_url)" - fi - echo "${workflow} finished with ${conclusion}: ${url}" + echo "run_attempt=${child_run_attempt}" >> "$GITHUB_OUTPUT" echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" == "success" ]]; then - return 0 - fi - if [[ "$workflow" == "openclaw-performance.yml" && "$RELEASE_PROFILE" == "beta" ]]; then - echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" - return 0 - fi - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true - if [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]] && release_checks_advisory_only "$run_json"; then - echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." - return 0 - fi - else - if [[ "$workflow" == "openclaw-performance.yml" ]]; then - echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" - fi - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: (.url // .html_url)}' || true - fi - exit 1 } case "$CHILD_WORKFLOW_KIND" in @@ -999,7 +816,7 @@ jobs: elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then args+=(-f target_context_ref="$TARGET_CONTEXT_REF") fi - dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + dispatch_child ci.yml "$dispatch_run_name" "${args[@]}" ;; plugin-prerelease) plugin_prerelease_node_exclusions="$( @@ -1018,7 +835,7 @@ jobs: if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") fi - dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + dispatch_child plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" ;; release-checks) { @@ -1085,7 +902,7 @@ jobs: dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" args+=(-f dispatch_id="$dispatch_id") - dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + dispatch_child openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" ;; npm-telegram) args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") @@ -1095,7 +912,7 @@ jobs: dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" args+=(-f dispatch_id="$dispatch_id") - dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}" + dispatch_child npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}" ;; performance) fail_on_regression=true @@ -1130,7 +947,7 @@ jobs: -f publish_reports=false -f dispatch_id="$dispatch_id" ) - dispatch_and_wait openclaw-performance.yml "$dispatch_run_name" "${args[@]}" + dispatch_child openclaw-performance.yml "$dispatch_run_name" "${args[@]}" ;; *) echo "::error::Unsupported full-release child workflow kind ${CHILD_WORKFLOW_KIND}." >&2 @@ -1141,16 +958,15 @@ jobs: plugin_prerelease: name: Run plugin prerelease validation needs: [resolve_target, evidence_reuse, prepare_release_candidate] - if: ${{ always() && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: ubuntu-24.04 - # The child owns lane timeouts; this monitor also covers queue delay. - timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 240 }} + timeout-minutes: 15 outputs: run_id: ${{ steps.dispatch.outputs.run_id }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} url: ${{ steps.dispatch.outputs.url }} - conclusion: ${{ steps.dispatch.outputs.conclusion }} steps: - - name: Dispatch and monitor plugin prerelease + - name: Dispatch plugin prerelease id: dispatch env: GH_TOKEN: ${{ github.token }} @@ -1161,23 +977,20 @@ jobs: PARENT_WORKFLOW_SHA: ${{ github.sha }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} PLUGIN_PRERELEASE_NODE_EXCLUDE_PATTERNS_JSON: ${{ inputs.plugin_prerelease_node_exclude_patterns_json }} - FAIL_FAST: ${{ inputs.fail_fast }} run: *full_release_child_dispatch release_checks: name: Run release/live/Docker/QA validation needs: [resolve_target, evidence_reuse, prepare_release_candidate] - if: ${{ always() && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","install-smoke","cross-os","live-e2e","package","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && (needs.prepare_release_candidate.result == 'success' || needs.prepare_release_candidate.result == 'skipped') && contains(fromJSON('["all","install-smoke","cross-os","live-e2e","package","qa-parity","qa-live"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: blacksmith-4vcpu-ubuntu-2404 - # The bounded package critical path tops out at 310 minutes; 420 leaves - # queue/API margin. Parent timeout preserves the adopted child for exact cancellation. - timeout-minutes: 420 + timeout-minutes: 15 outputs: run_id: ${{ steps.dispatch.outputs.run_id }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} url: ${{ steps.dispatch.outputs.url }} - conclusion: ${{ steps.dispatch.outputs.conclusion }} steps: - - name: Dispatch and monitor release checks + - name: Dispatch release checks id: dispatch env: GH_TOKEN: ${{ github.token }} @@ -1206,17 +1019,16 @@ jobs: npm_telegram: name: Run package Telegram E2E needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') && needs.evidence_reuse.outputs.reuse != 'true' }} + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '') && needs.evidence_reuse.outputs.reuse != 'true' }} continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }} runs-on: ubuntu-24.04 - # The child owns lane timeouts; this monitor also covers queue delay. - timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 120 }} + timeout-minutes: 15 outputs: run_id: ${{ steps.dispatch.outputs.run_id }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} url: ${{ steps.dispatch.outputs.url }} - conclusion: ${{ steps.dispatch.outputs.conclusion }} steps: - - name: Dispatch and monitor npm Telegram E2E + - name: Dispatch npm Telegram E2E id: dispatch env: GH_TOKEN: ${{ github.token }} @@ -1227,25 +1039,20 @@ jobs: PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec || inputs.release_package_spec }} PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} SCENARIO: ${{ inputs.npm_telegram_scenario }} - FAIL_FAST: ${{ inputs.fail_fast }} run: *full_release_child_dispatch performance: name: Run product performance evidence needs: [resolve_target, evidence_reuse] - if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} - # Keep this monitor off the four-slot GitHub-hosted pool so performance starts - # with the other child workflows instead of extending the critical path. + if: ${{ always() && github.run_attempt == 1 && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && needs.evidence_reuse.outputs.reuse != 'true' }} runs-on: blacksmith-4vcpu-ubuntu-2404 - # Artifact-only and publish paths top out at 255 and 280 minutes; 360 leaves - # queue/API margin. Parent timeout preserves the adopted child for exact cancellation. - timeout-minutes: 360 + timeout-minutes: 15 outputs: run_id: ${{ steps.dispatch.outputs.run_id }} + run_attempt: ${{ steps.dispatch.outputs.run_attempt }} url: ${{ steps.dispatch.outputs.url }} - conclusion: ${{ steps.dispatch.outputs.conclusion }} steps: - - name: Dispatch and monitor OpenClaw Performance + - name: Dispatch OpenClaw Performance id: dispatch env: GH_TOKEN: ${{ github.token }} @@ -1255,8 +1062,9 @@ jobs: CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} run: *full_release_child_dispatch - summary: - name: Verify full validation + + release_execution_plan: + name: Seal release execution plan needs: [ resolve_target, @@ -1270,442 +1078,365 @@ jobs: performance, ] if: always() - runs-on: ubuntu-24.04 - timeout-minutes: 5 + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 15 + outputs: + sha256: ${{ steps.plan.outputs.sha256 }} + source_parent_attempt: ${{ steps.plan.outputs.source_parent_attempt }} steps: - - name: Verify child workflow results + - name: Checkout release execution plan tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: | + scripts/full-release-validation-state.mjs + scripts/full-release-validation-policy.mjs + scripts/release-ci-summary.mjs + scripts/lib/plain-gh.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Restore immutable release execution plan + if: ${{ github.run_attempt != 1 }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: full-release-execution-plan-${{ github.run_id }} + path: ${{ runner.temp }}/full-release-execution-plan + + - name: Seal immutable release execution plan + id: plan env: GH_TOKEN: ${{ github.token }} - NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }} - PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }} - RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }} - NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} - PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} - NORMAL_CI_RESULT: ${{ needs.normal_ci.result }} - PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }} - RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }} - NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }} - PERFORMANCE_RESULT: ${{ needs.performance.result }} RELEASE_PROFILE: ${{ inputs.release_profile }} - DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }} - PREPARE_RELEASE_CANDIDATE_RESULT: ${{ needs.prepare_release_candidate.result }} - RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} - NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }} - PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} - LIVE_SUITE_FILTER: ${{ needs.resolve_target.outputs.live_suite_filter }} - SKIP_PACKAGE_TELEGRAM_E2E: ${{ inputs.skip_package_telegram_e2e }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} + EVIDENCE_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_run_id }} EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} EVIDENCE_RUN_URL: ${{ needs.evidence_reuse.outputs.evidence_run_url }} EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }} EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }} - EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }} - RERUN_GROUP: ${{ inputs.rerun_group }} - TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} - CHILD_WORKFLOW_REF: ${{ github.ref_name }} - PARENT_WORKFLOW_SHA: ${{ github.sha }} + EVIDENCE_CHANGED_PATHS: ${{ needs.evidence_reuse.outputs.changed_paths || '[]' }} + TRUSTED_WORKFLOW_JSON: ${{ needs.resolve_target.outputs.trusted_workflow_json }} + RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }} + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} + NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.npm_telegram_package_spec }} + LIVE_SUITE_FILTER: ${{ needs.resolve_target.outputs.live_suite_filter }} + NORMAL_CI_RESULT: ${{ needs.normal_ci.result }} + NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }} + NORMAL_CI_RUN_ATTEMPT: ${{ needs.normal_ci.outputs.run_attempt }} + NORMAL_CI_URL: ${{ needs.normal_ci.outputs.url }} + PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }} + PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }} + PLUGIN_PRERELEASE_RUN_ATTEMPT: ${{ needs.plugin_prerelease.outputs.run_attempt }} + PLUGIN_PRERELEASE_URL: ${{ needs.plugin_prerelease.outputs.url }} + RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }} + RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }} + RELEASE_CHECKS_RUN_ATTEMPT: ${{ needs.release_checks.outputs.run_attempt }} + RELEASE_CHECKS_URL: ${{ needs.release_checks.outputs.url }} + NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }} + NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} + NPM_TELEGRAM_RUN_ATTEMPT: ${{ needs.npm_telegram.outputs.run_attempt }} + NPM_TELEGRAM_URL: ${{ needs.npm_telegram.outputs.url }} + PERFORMANCE_RESULT: ${{ needs.performance.result }} + PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} + PERFORMANCE_RUN_ATTEMPT: ${{ needs.performance.outputs.run_attempt }} + PERFORMANCE_URL: ${{ needs.performance.outputs.url }} + RESOLVE_TARGET_RESULT: ${{ needs.resolve_target.result }} + DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }} + PREPARE_RELEASE_CANDIDATE_RESULT: ${{ needs.prepare_release_candidate.result }} + FULL_RELEASE_RESTORE_PLAN: ${{ github.run_attempt != 1 }} + FULL_RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json run: | set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - release_check_blocking_job() { - case "$1" in - "resolve_target" | \ - "Prepare release package artifact" | \ - "install_smoke_release_checks / "* | \ - "Run package acceptance" | \ - "Run package acceptance / "*) - return 0 - ;; - esac - return 1 - } - - release_checks_advisory_only() { - local run_json="$1" - local verifier_conclusion name saw_advisory failed - - verifier_conclusion="$( - jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | - tail -n 1 + if [[ "$FULL_RELEASE_RESTORE_PLAN" != "true" ]]; then + export FULL_RELEASE_PLAN_INPUTS_JSON="$( + jq -cn \ + --arg parentRunId "$GITHUB_RUN_ID" \ + --arg parentRunAttempt "$GITHUB_RUN_ATTEMPT" \ + --arg workflowRef "$GITHUB_REF_NAME" \ + --arg workflowSha "$GITHUB_SHA" \ + --argjson trustedWorkflow "$TRUSTED_WORKFLOW_JSON" \ + --arg evidenceReuse "$EVIDENCE_REUSE" \ + --arg evidenceRunId "$EVIDENCE_RUN_ID" \ + --arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \ + --arg evidenceRunUrl "$EVIDENCE_RUN_URL" \ + --arg evidenceSha "$EVIDENCE_SHA" \ + --arg evidencePolicy "$EVIDENCE_POLICY" \ + --argjson evidenceChangedPaths "$EVIDENCE_CHANGED_PATHS" \ + --arg rerunGroup "$RERUN_GROUP" \ + --arg releasePackageSpec "$RELEASE_PACKAGE_SPEC" \ + --arg packageAcceptancePackageSpec "$PACKAGE_ACCEPTANCE_PACKAGE_SPEC" \ + --arg npmTelegramPackageSpec "$NPM_TELEGRAM_PACKAGE_SPEC" \ + --arg liveSuiteFilter "$LIVE_SUITE_FILTER" \ + --arg resolveTargetResult "$RESOLVE_TARGET_RESULT" \ + --arg dockerPreflightResult "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" \ + --arg prepareCandidateResult "$PREPARE_RELEASE_CANDIDATE_RESULT" \ + --arg normalCiResult "$NORMAL_CI_RESULT" \ + --arg normalCiRunId "$NORMAL_CI_RUN_ID" \ + --arg normalCiRunAttempt "$NORMAL_CI_RUN_ATTEMPT" \ + --arg normalCiUrl "$NORMAL_CI_URL" \ + --arg pluginPrereleaseResult "$PLUGIN_PRERELEASE_RESULT" \ + --arg pluginPrereleaseRunId "$PLUGIN_PRERELEASE_RUN_ID" \ + --arg pluginPrereleaseRunAttempt "$PLUGIN_PRERELEASE_RUN_ATTEMPT" \ + --arg pluginPrereleaseUrl "$PLUGIN_PRERELEASE_URL" \ + --arg releaseChecksResult "$RELEASE_CHECKS_RESULT" \ + --arg releaseChecksRunId "$RELEASE_CHECKS_RUN_ID" \ + --arg releaseChecksRunAttempt "$RELEASE_CHECKS_RUN_ATTEMPT" \ + --arg releaseChecksUrl "$RELEASE_CHECKS_URL" \ + --arg npmTelegramResult "$NPM_TELEGRAM_RESULT" \ + --arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \ + --arg npmTelegramRunAttempt "$NPM_TELEGRAM_RUN_ATTEMPT" \ + --arg npmTelegramUrl "$NPM_TELEGRAM_URL" \ + --arg performanceResult "$PERFORMANCE_RESULT" \ + --arg performanceRunId "$PERFORMANCE_RUN_ID" \ + --arg performanceRunAttempt "$PERFORMANCE_RUN_ATTEMPT" \ + --arg performanceUrl "$PERFORMANCE_URL" \ + '{ + parentRunId: $parentRunId, + parentRunAttempt: $parentRunAttempt, + workflowRef: $workflowRef, + workflowSha: $workflowSha, + trustedWorkflow: $trustedWorkflow, + evidenceReuse: $evidenceReuse, + evidenceRunId: $evidenceRunId, + evidenceRootRunId: $evidenceRootRunId, + evidenceRunUrl: $evidenceRunUrl, + evidenceSha: $evidenceSha, + evidencePolicy: $evidencePolicy, + evidenceChangedPaths: $evidenceChangedPaths, + rerunGroup: $rerunGroup, + releasePackageSpec: $releasePackageSpec, + packageAcceptancePackageSpec: $packageAcceptancePackageSpec, + npmTelegramPackageSpec: $npmTelegramPackageSpec, + liveSuiteFilter: $liveSuiteFilter, + resolveTargetResult: $resolveTargetResult, + dockerPreflightResult: $dockerPreflightResult, + prepareCandidateResult: $prepareCandidateResult, + children: { + normalCi: {result: $normalCiResult, runId: $normalCiRunId, runAttempt: $normalCiRunAttempt, url: $normalCiUrl}, + pluginPrerelease: {result: $pluginPrereleaseResult, runId: $pluginPrereleaseRunId, runAttempt: $pluginPrereleaseRunAttempt, url: $pluginPrereleaseUrl}, + releaseChecks: {result: $releaseChecksResult, runId: $releaseChecksRunId, runAttempt: $releaseChecksRunAttempt, url: $releaseChecksUrl}, + npmTelegram: {result: $npmTelegramResult, runId: $npmTelegramRunId, runAttempt: $npmTelegramRunAttempt, url: $npmTelegramUrl}, + productPerformance: {result: $performanceResult, runId: $performanceRunId, runAttempt: $performanceRunAttempt, url: $performanceUrl} + } + }' )" - if [[ "$verifier_conclusion" != "success" ]]; then - return 1 - fi - - saw_advisory=0 - failed=0 - while IFS= read -r name; do - [[ -z "${name// }" ]] && continue - if release_check_blocking_job "$name"; then - echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." - failed=1 - else - saw_advisory=1 - fi - done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") - - [[ "$saw_advisory" == "1" && "$failed" == "0" ]] - } - - check_child() { - local label="$1" - local run_id="$2" - local required="$3" - local advisory_ok="${4:-0}" - - if [[ -z "${run_id// }" ]]; then - if [[ "$required" == "0" ]]; then - echo "${label}: skipped" - return 0 - fi - echo "::error::${label} did not record a child run id." - return 1 - fi - - local run_json status conclusion url attempt head_sha - run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,attempt,headSha,jobs)" - status="$(jq -r '.status' <<< "$run_json")" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - url="$(jq -r '.url' <<< "$run_json")" - attempt="$(jq -r '.attempt' <<< "$run_json")" - head_sha="$(jq -r '.headSha // ""' <<< "$run_json")" - echo "${label}: ${status}/${conclusion} attempt ${attempt} head ${head_sha}: ${url}" - - if [[ "$head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${label} child run used workflow SHA ${head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}. Use the SHA-pinned release helper when a moving branch cannot stay fixed." - return 1 - fi - - if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then - if [[ "$advisory_ok" == "1" && "$label" == "product_performance" && "$status" == "completed" ]]; then - echo "::warning::${label} ended with ${conclusion}; advisory for beta: ${url}" - return 0 - fi - if [[ "$advisory_ok" == "1" && "$label" == "release_checks" ]]; then - if release_checks_advisory_only "$run_json"; then - echo "::warning::${label} child run ended with ${status}/${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes: ${url}" - return 0 - fi - fi - echo "::error::${label} child run ended with ${status}/${conclusion}: ${url}" - jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, status, conclusion, url}' <<< "$run_json" || true - return 1 - fi - } - - append_child_overview() { - { - echo - echo "### Child workflow overview" - echo - echo "| Child | Result | Minutes | Head SHA | Run |" - echo "| --- | --- | ---: | --- | --- |" - echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |" - } >> "$GITHUB_STEP_SUMMARY" - - append_child_row() { - local label="$1" - local run_id="$2" - local result="$3" - - if [[ -z "${run_id// }" ]]; then - echo "| \`${label}\` | \`${result}\` | | skipped |" >> "$GITHUB_STEP_SUMMARY" - return 0 - fi - - local run_json row - run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,createdAt,updatedAt,headSha)" - row="$( - jq -r --arg label "$label" ' - def ts: fromdateiso8601; - . as $run | - ($run.createdAt // "") as $created | - ($run.updatedAt // "") as $updated | - (if ($created | length) > 0 and ($updated | length) > 0 - then (((($updated | ts) - ($created | ts)) / 60) * 10 | round / 10 | tostring) - else "" - end) as $minutes | - ($run.headSha // "") as $head | - "| `" + $label + "` | `" + ($run.status // "") + "/" + ($run.conclusion // "") + "` | " + $minutes + " | `" + $head + "` | [run](" + ($run.url // "") + ") |" - ' <<< "$run_json" - )" - echo "$row" >> "$GITHUB_STEP_SUMMARY" - } - - append_child_row "normal_ci" "$NORMAL_CI_RUN_ID" "$NORMAL_CI_RESULT" - append_child_row "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" "$PLUGIN_PRERELEASE_RESULT" - append_child_row "release_checks" "$RELEASE_CHECKS_RUN_ID" "$RELEASE_CHECKS_RESULT" - append_child_row "npm_telegram" "$NPM_TELEGRAM_RUN_ID" "$NPM_TELEGRAM_RESULT" - append_child_row "product_performance" "$PERFORMANCE_RUN_ID" "$PERFORMANCE_RESULT" - } - - summarize_child_timing() { - local label="$1" - local run_id="$2" - if [[ -z "${run_id// }" ]]; then - return 0 - fi - - { - echo - echo "### Slowest jobs: ${label}" - echo - gh_with_retry run view "$run_id" --json jobs --jq ' - def ts: fromdateiso8601; - "| Job | Result | Minutes |", - "| --- | --- | ---: |", - ([.jobs[] - | select(.startedAt != "0001-01-01T00:00:00Z" and .completedAt != "0001-01-01T00:00:00Z") - | . + {durationMin: ((((.completedAt | ts) - (.startedAt | ts)) / 60) * 10 | round / 10)} - | {name, conclusion, durationMin}] - | sort_by(.durationMin) - | reverse - | .[0:10] - | map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.durationMin | tostring) + " |") - | .[]) - ' || echo "_Unable to summarize jobs for run ${run_id}._" - echo - echo "### Longest start delays: ${label}" - echo - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq ".jobs[] | @json" | jq -sr ' - def ts: fromdateiso8601; - "| Job | Result | Start delay minutes | Run minutes |", - "| --- | --- | ---: | ---: |", - ([.[] - | select(.created_at != null and .started_at != null) - | . + { - startDelayMin: ((((.started_at | ts) - (.created_at | ts)) / 60) * 10 | round / 10), - durationMin: (if .completed_at == null then null else ((((.completed_at | ts) - (.started_at | ts)) / 60) * 10 | round / 10) end) - } - | select(.startDelayMin > 0) - | {name, conclusion, startDelayMin, durationMin}] - | sort_by(.startDelayMin) - | reverse - | .[0:10] - | map("| `" + (.name | gsub("\\|"; "\\|")) + "` | `" + ((.conclusion // "") | tostring) + "` | " + (.startDelayMin | tostring) + " | " + ((.durationMin // "") | tostring) + " |") - | .[]) - ' || echo "_Unable to summarize start delays for run ${run_id}._" - } >> "$GITHUB_STEP_SUMMARY" - } - - summarize_failed_child() { - local label="$1" - local run_id="$2" - if [[ -z "${run_id// }" ]]; then - return 0 - fi - - local run_json status conclusion artifacts_json - run_json="$(gh_with_retry run view "$run_id" --json status,conclusion,url,jobs)" - status="$(jq -r '.status' <<< "$run_json")" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then - return 0 - fi - - { - echo - echo "### Failed child detail: ${label}" - echo - jq -r ' - "- Run: " + (.url // ""), - "- Result: `" + (.status // "") + "/" + (.conclusion // "") + "`", - "", - "Failed jobs:", - (.jobs[] - | select(.conclusion != "success" and .conclusion != "skipped") - | "- `" + (.name | gsub("`"; "\\`")) + "`: `" + ((.conclusion // .status // "") | tostring) + "` " + (.url // "")) - ' <<< "$run_json" || true - echo - echo "Artifacts:" - artifacts_json="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100" 2>/dev/null || true - )" - if [[ -n "${artifacts_json// }" ]]; then - jq -r ' - if ((.artifacts // []) | length) == 0 then - "- none" - else - (.artifacts[] - | "- `" + (.name | gsub("`"; "\\`")) + "` (" + ((.size_in_bytes // 0) | tostring) + " bytes)") - end - ' <<< "$artifacts_json" || echo "- unable to list artifacts" - else - echo "- unable to list artifacts" - fi - } >> "$GITHUB_STEP_SUMMARY" - } - - failed=0 - normal_ci_required=0 - plugin_prerelease_required=0 - release_checks_required=0 - npm_telegram_required=0 - performance_required=0 - candidate_required=0 - if [[ "$RERUN_GROUP" == "npm-telegram" || ( "$RERUN_GROUP" == "all" && ( -n "${NPM_TELEGRAM_PACKAGE_SPEC// }" || -n "${RELEASE_PACKAGE_SPEC// }" ) ) ]]; then - npm_telegram_required=1 - fi - echo "- Package Telegram E2E deferred: \`${SKIP_PACKAGE_TELEGRAM_E2E}\`" >> "$GITHUB_STEP_SUMMARY" - if [[ -z "${RELEASE_PACKAGE_SPEC// }" && -z "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - case "$RERUN_GROUP" in - all|plugin-prerelease|cross-os|package) - candidate_required=1 - ;; - live-e2e) - [[ -n "${LIVE_SUITE_FILTER// }" ]] || candidate_required=1 - ;; - esac - fi - if [[ "$candidate_required" == "1" && "$EVIDENCE_REUSE" != "true" && "$PREPARE_RELEASE_CANDIDATE_RESULT" != "success" ]]; then - echo "::error::Shared release candidate preparation ended with ${PREPARE_RELEASE_CANDIDATE_RESULT}." - failed=1 - fi - if [[ "$RERUN_GROUP" == "all" && "$EVIDENCE_REUSE" == "true" ]]; then - # Lanes were skipped because a prior green validation covers this - # target; re-verify the chain-root run and its recorded child runs - # so evidence that went stale after resolution cannot pass. - reused_npm_telegram_run_id="$(jq -r '.childRuns.npmTelegram // ""' <<< "$EVIDENCE_MANIFEST")" - if [[ "$npm_telegram_required" == "1" && -z "${reused_npm_telegram_run_id// }" ]]; then - echo "::error::Reused evidence did not record the required npm Telegram child run." - failed=1 - fi - evidence_state="$(gh_with_retry run view "$EVIDENCE_ROOT_RUN_ID" --json status,conclusion --jq '(.status // "") + "/" + (.conclusion // "")')" - if [[ "$evidence_state" != "completed/success" ]]; then - echo "::error::Reused evidence run ${EVIDENCE_ROOT_RUN_ID} is ${evidence_state}; evidence is no longer valid." - failed=1 - fi - while IFS= read -r evidence_child_run_id; do - [[ -n "$evidence_child_run_id" ]] || continue - evidence_child_state="$(gh_with_retry run view "$evidence_child_run_id" --json status,conclusion --jq '(.status // "") + "/" + (.conclusion // "")')" - if [[ "$evidence_child_state" != "completed/success" ]]; then - echo "::error::Reused evidence child run ${evidence_child_run_id} is ${evidence_child_state}; evidence is no longer valid." - failed=1 - fi - done < <(jq -r '[.childRuns.normalCi // "", .childRuns.pluginPrerelease // "", .childRuns.releaseChecks // "", .childRuns.npmTelegram // "", (.childRuns.productPerformance.runId // "")] | map(select(. != "")) | .[]' <<< "$EVIDENCE_MANIFEST") - if [[ "$failed" == "0" ]]; then - emit_reused_child_dispatch() { - local workflow="$1" - local run_id="$2" - if [[ -n "${run_id// }" ]]; then - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fi - } - emit_reused_child_dispatch "ci.yml" "$(jq -r '.childRuns.normalCi // ""' <<< "$EVIDENCE_MANIFEST")" - emit_reused_child_dispatch "plugin-prerelease.yml" "$(jq -r '.childRuns.pluginPrerelease // ""' <<< "$EVIDENCE_MANIFEST")" - emit_reused_child_dispatch "openclaw-release-checks.yml" "$(jq -r '.childRuns.releaseChecks // ""' <<< "$EVIDENCE_MANIFEST")" - emit_reused_child_dispatch "npm-telegram-beta-e2e.yml" "$(jq -r '.childRuns.npmTelegram // ""' <<< "$EVIDENCE_MANIFEST")" - emit_reused_child_dispatch "openclaw-performance.yml" "$(jq -r '.childRuns.productPerformance.runId // ""' <<< "$EVIDENCE_MANIFEST")" - { - echo "### Reused validation evidence" - echo - echo "- Evidence run: ${EVIDENCE_RUN_URL}" - echo "- Evidence SHA: \`${EVIDENCE_SHA}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Reuse policy: \`${EVIDENCE_POLICY}\`" - } >> "$GITHUB_STEP_SUMMARY" - fi - elif [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then - echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}." - failed=1 - elif [[ "$RERUN_GROUP" == "all" ]]; then - normal_ci_required=1 - plugin_prerelease_required=1 - release_checks_required=1 - performance_required=1 - else - case "$RERUN_GROUP" in - ci) - normal_ci_required=1 - ;; - plugin-prerelease) - plugin_prerelease_required=1 - ;; - install-smoke|cross-os|live-e2e|package|qa-parity|qa-live) - release_checks_required=1 - ;; - performance) - performance_required=1 - ;; - esac fi + node scripts/full-release-validation-state.mjs plan - append_child_overview + - name: Upload immutable release execution plan + if: ${{ always() && github.run_attempt == 1 }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: full-release-execution-plan-${{ github.run_id }} + path: ${{ runner.temp }}/full-release-execution-plan + if-no-files-found: error - if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then - check_child "normal_ci" "" "$normal_ci_required" || failed=1 - else - check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1 + release_decision: + name: Release Decision + needs: [resolve_target, release_execution_plan] + if: always() + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 720 + outputs: + state: ${{ steps.state.outputs.state }} + steps: + - name: Checkout release decision tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: | + scripts/full-release-validation-state.mjs + scripts/full-release-validation-policy.mjs + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Download immutable release execution plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: full-release-execution-plan-${{ github.run_id }} + path: ${{ runner.temp }}/full-release-execution-plan + + - name: Evaluate release decision + id: state + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + FAIL_FAST: ${{ inputs.fail_fast }} + RELEASE_PROFILE: ${{ inputs.release_profile }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + FULL_RELEASE_STATE_MODE: decision + FULL_RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + FULL_RELEASE_STATE_PATH: ${{ runner.temp }}/full-release-decision/full-release-decision.json + run: &full_release_state | + set -euo pipefail + node scripts/full-release-validation-state.mjs "$FULL_RELEASE_STATE_MODE" + + - name: Upload release decision + if: always() && steps.state.outputs.state != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: full-release-decision-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/full-release-decision + if-no-files-found: error + + - name: Enforce release decision + if: always() + env: + RELEASE_DECISION_STATE: ${{ steps.state.outputs.state }} + run: | + set -euo pipefail + if [[ "$RELEASE_DECISION_STATE" == "passed" ]]; then + exit 0 fi + echo "::error::Release Decision ended in ${RELEASE_DECISION_STATE:-orchestration_error}." + exit 1 - if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then - check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1 - else - check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1 - fi + diagnostic_drain: + name: Diagnostic Drain + needs: [resolve_target, release_execution_plan] + if: always() + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 720 + outputs: + state: ${{ steps.state.outputs.state }} + normal_ci_conclusion: ${{ steps.state.outputs.normalCi_conclusion }} + plugin_prerelease_conclusion: ${{ steps.state.outputs.pluginPrerelease_conclusion }} + release_checks_conclusion: ${{ steps.state.outputs.releaseChecks_conclusion }} + npm_telegram_conclusion: ${{ steps.state.outputs.npmTelegram_conclusion }} + performance_conclusion: ${{ steps.state.outputs.productPerformance_conclusion }} + steps: + - name: Checkout diagnostic drain tooling + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: | + scripts/full-release-validation-state.mjs + scripts/full-release-validation-policy.mjs + scripts/release-ci-summary.mjs + scripts/lib/plain-gh.mjs + sparse-checkout-cone-mode: false + persist-credentials: false - if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then - check_child "release_checks" "" "$release_checks_required" || failed=1 - elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 1 || failed=1 - else - check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1 - fi + - name: Download immutable release execution plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: full-release-execution-plan-${{ github.run_id }} + path: ${{ runner.temp }}/full-release-execution-plan - if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then - check_child "npm_telegram" "" "$npm_telegram_required" || failed=1 - elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation." - else - check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1 - fi + - name: Drain child diagnostics + id: state + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + FAIL_FAST: "false" + RELEASE_PROFILE: ${{ inputs.release_profile }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + FULL_RELEASE_STATE_MODE: drain + FULL_RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + FULL_RELEASE_STATE_PATH: ${{ runner.temp }}/full-release-diagnostics/full-release-diagnostic-manifest.json + run: *full_release_state - if [[ "$PERFORMANCE_RESULT" == "skipped" && -z "${PERFORMANCE_RUN_ID// }" ]]; then - check_child "product_performance" "" "$performance_required" || failed=1 - else - performance_advisory=0 - [[ "$RELEASE_PROFILE" == "beta" ]] && performance_advisory=1 - check_child "product_performance" "$PERFORMANCE_RUN_ID" "$performance_required" "$performance_advisory" || failed=1 - fi + - name: Upload diagnostic drain manifest + if: always() && steps.state.outputs.state != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: full-release-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/full-release-diagnostics + if-no-files-found: error - summarize_child_timing "normal_ci" "$NORMAL_CI_RUN_ID" - summarize_child_timing "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" - summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID" - summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID" - summarize_child_timing "product_performance" "$PERFORMANCE_RUN_ID" + - name: Enforce diagnostic drain integrity + if: always() + env: + DIAGNOSTIC_DRAIN_STATE: ${{ steps.state.outputs.state }} + run: | + set -euo pipefail + case "$DIAGNOSTIC_DRAIN_STATE" in + passed|blocked_complete) + exit 0 + ;; + *) + echo "::error::Diagnostic Drain ended in ${DIAGNOSTIC_DRAIN_STATE:-orchestration_error}." + exit 1 + ;; + esac - if [[ "$failed" != "0" ]]; then - summarize_failed_child "normal_ci" "$NORMAL_CI_RUN_ID" - summarize_failed_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" - summarize_failed_child "release_checks" "$RELEASE_CHECKS_RUN_ID" - summarize_failed_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" - summarize_failed_child "product_performance" "$PERFORMANCE_RUN_ID" - fi + summary: + name: Verify full validation + needs: + [ + resolve_target, + evidence_reuse, + docker_runtime_assets_preflight, + prepare_release_candidate, + normal_ci, + plugin_prerelease, + release_checks, + npm_telegram, + performance, + release_execution_plan, + release_decision, + diagnostic_drain, + ] + if: always() + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout release state verifier + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.sha }} + sparse-checkout: | + scripts/full-release-validation-state.mjs + scripts/full-release-validation-policy.mjs + scripts/release-ci-summary.mjs + scripts/lib/plain-gh.mjs + sparse-checkout-cone-mode: false + persist-credentials: false - exit "$failed" + - name: Download immutable release execution plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: full-release-execution-plan-${{ github.run_id }} + path: ${{ runner.temp }}/full-release-execution-plan + + - name: Download release decision attempts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: full-release-decision-${{ github.run_id }}-* + path: ${{ runner.temp }}/full-release-decision-attempts + + - name: Download diagnostic drain attempts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + pattern: full-release-diagnostics-${{ github.run_id }}-* + path: ${{ runner.temp }}/full-release-diagnostic-attempts + + - name: Select newest compatible release state artifacts + id: selected_state + env: + RELEASE_PROFILE: ${{ inputs.release_profile }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + RELEASE_DECISION_ATTEMPTS_PATH: ${{ runner.temp }}/full-release-decision-attempts + DIAGNOSTIC_DRAIN_ATTEMPTS_PATH: ${{ runner.temp }}/full-release-diagnostic-attempts + RELEASE_DECISION_PATH: ${{ runner.temp }}/full-release-decision/full-release-decision.json + DIAGNOSTIC_DRAIN_PATH: ${{ runner.temp }}/full-release-diagnostics/full-release-diagnostic-manifest.json + run: node scripts/full-release-validation-state.mjs select + + - name: Verify exact release state artifacts + env: + RELEASE_PROFILE: ${{ inputs.release_profile }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + RELEASE_DECISION_PATH: ${{ runner.temp }}/full-release-decision/full-release-decision.json + DIAGNOSTIC_DRAIN_PATH: ${{ runner.temp }}/full-release-diagnostics/full-release-diagnostic-manifest.json + run: node scripts/full-release-validation-state.mjs verify - name: Request release evidence update if: ${{ inputs.dispatch_release_evidence }} @@ -1714,17 +1445,18 @@ jobs: TARGET_REF: ${{ inputs.ref }} PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }} GITHUB_RUN_ID_VALUE: ${{ github.run_id }} - RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }} - EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} - EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} - EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }} + RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json run: | set -euo pipefail - if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && "$EVIDENCE_REUSE" != "true" ]]; then + EVIDENCE_REUSE="$(jq -r '.evidenceReuse.requested' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_ROOT_RUN_ID="$(jq -r '.evidenceReuse.rootRunId // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_POLICY="$(jq -r '.evidenceReuse.policy // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + RELEASE_CHECKS_SELECTED="$(jq -r '.children[] | select(.key == "releaseChecks") | .selected' "$RELEASE_EXECUTION_PLAN_PATH")" + if [[ "$RELEASE_CHECKS_SELECTED" != "true" && "$EVIDENCE_REUSE" != "true" ]]; then echo "Release checks were skipped by rerun group; skipping automatic release evidence update." exit 0 fi - notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." + notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after exact Release Decision and Diagnostic Drain artifacts passed shared policy verification." if [[ "$EVIDENCE_REUSE" == "true" && -n "${EVIDENCE_ROOT_RUN_ID// }" ]]; then notes="Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE}, which reused green product evidence from chain-root run ${EVIDENCE_ROOT_RUN_ID} under policy ${EVIDENCE_POLICY}." fi @@ -1796,23 +1528,9 @@ jobs: if: ${{ success() }} env: TARGET_REF: ${{ startsWith(github.ref, 'refs/heads/release-ci/') && needs.resolve_target.outputs.sha || inputs.ref }} - TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} RELEASE_PROFILE: ${{ inputs.release_profile }} RERUN_GROUP: ${{ inputs.rerun_group }} RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} - NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }} - PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }} - RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }} - NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }} - PERFORMANCE_RUN_ID: ${{ needs.performance.outputs.run_id }} - PERFORMANCE_CONCLUSION: ${{ needs.performance.outputs.conclusion }} - EVIDENCE_REUSE: ${{ needs.evidence_reuse.outputs.reuse }} - EVIDENCE_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_run_id }} - EVIDENCE_ROOT_RUN_ID: ${{ needs.evidence_reuse.outputs.evidence_root_run_id }} - EVIDENCE_SHA: ${{ needs.evidence_reuse.outputs.evidence_sha }} - EVIDENCE_POLICY: ${{ needs.evidence_reuse.outputs.evidence_policy }} - EVIDENCE_CHANGED_PATHS: ${{ needs.evidence_reuse.outputs.changed_paths }} - EVIDENCE_MANIFEST: ${{ needs.evidence_reuse.outputs.evidence_manifest }} PROVIDER: ${{ inputs.provider }} MODE: ${{ inputs.mode }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} @@ -1827,10 +1545,28 @@ jobs: SKIP_PACKAGE_TELEGRAM_E2E: ${{ inputs.skip_package_telegram_e2e }} ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.allow_unreleased_changelog || (inputs.target_context_ref == '' && (inputs.ref == 'main' || inputs.ref == 'refs/heads/main')) }} PLUGIN_PRERELEASE_NODE_EXCLUDE_PATTERNS_JSON: ${{ inputs.plugin_prerelease_node_exclude_patterns_json }} + RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + DIAGNOSTIC_DRAIN_PATH: ${{ runner.temp }}/full-release-diagnostics/full-release-diagnostic-manifest.json run: | set -euo pipefail manifest_dir="${RUNNER_TEMP}/full-release-validation" mkdir -p "$manifest_dir" + TARGET_SHA="$(jq -r '.targetSha' "$RELEASE_EXECUTION_PLAN_PATH")" + NORMAL_CI_RUN_ID="$(jq -r '.children[] | select(.key == "normalCi") | .runId' "$RELEASE_EXECUTION_PLAN_PATH")" + PLUGIN_PRERELEASE_RUN_ID="$(jq -r '.children[] | select(.key == "pluginPrerelease") | .runId' "$RELEASE_EXECUTION_PLAN_PATH")" + RELEASE_CHECKS_RUN_ID="$(jq -r '.children[] | select(.key == "releaseChecks") | .runId' "$RELEASE_EXECUTION_PLAN_PATH")" + NPM_TELEGRAM_RUN_ID="$(jq -r '.children[] | select(.key == "npmTelegram") | .runId' "$RELEASE_EXECUTION_PLAN_PATH")" + PERFORMANCE_RUN_ID="$(jq -r '.children[] | select(.key == "productPerformance") | .runId' "$RELEASE_EXECUTION_PLAN_PATH")" + EXECUTION_PLAN_SHA256="$(jq -r '.sha256' "$RELEASE_EXECUTION_PLAN_PATH")" + SOURCE_PARENT_RUN_ATTEMPT="$(jq -r '.parentRunAttempt' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_REUSE="$(jq -r '.evidenceReuse.requested' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_RUN_ID="$(jq -r '.evidenceReuse.selectedRunId // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_ROOT_RUN_ID="$(jq -r '.evidenceReuse.rootRunId // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_SHA="$(jq -r '.evidenceReuse.evidenceSha // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_POLICY="$(jq -r '.evidenceReuse.policy // ""' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_CHANGED_PATHS="$(jq -c '.evidenceReuse.changedPaths // []' "$RELEASE_EXECUTION_PLAN_PATH")" + EVIDENCE_MANIFEST="$(jq -c '.evidenceReuse.sourceManifest // empty' "$RELEASE_EXECUTION_PLAN_PATH")" + PERFORMANCE_CONCLUSION="$(jq -r '.children.productPerformance.conclusion // ""' "$DIAGNOSTIC_DRAIN_PATH")" if [[ "$EVIDENCE_REUSE" == "true" ]]; then # Inherit the evidence manifest (profile, soak, child runs) so future # reuse lookups and evidence consumers keep resolving the chain root. @@ -1847,6 +1583,8 @@ jobs: --arg evidenceRootRunId "$EVIDENCE_ROOT_RUN_ID" \ --arg evidenceSha "$EVIDENCE_SHA" \ --arg evidencePolicy "$EVIDENCE_POLICY" \ + --arg executionPlanSha256 "$EXECUTION_PLAN_SHA256" \ + --arg sourceParentRunAttempt "$SOURCE_PARENT_RUN_ATTEMPT" \ --argjson evidenceChangedPaths "$EVIDENCE_CHANGED_PATHS" \ '. + { version: 3, @@ -1867,7 +1605,9 @@ jobs: }, controls: ((.controls // {}) + { performanceReportPublication: "artifact-only" - }) + }), + executionPlanSha256: $executionPlanSha256, + sourceParentRunAttempt: ($sourceParentRunAttempt | tonumber) }' <<< "$EVIDENCE_MANIFEST" > "${manifest_dir}/full-release-validation-manifest.json" exit 0 fi @@ -1890,6 +1630,8 @@ jobs: --arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \ --arg performanceRunId "$PERFORMANCE_RUN_ID" \ --arg performanceConclusion "$PERFORMANCE_CONCLUSION" \ + --arg executionPlanSha256 "$EXECUTION_PLAN_SHA256" \ + --arg sourceParentRunAttempt "$SOURCE_PARENT_RUN_ATTEMPT" \ --arg provider "$PROVIDER" \ --arg mode "$MODE" \ --arg targetContextRef "$TARGET_CONTEXT_REF" \ @@ -1918,6 +1660,8 @@ jobs: releaseProfile: $releaseProfile, rerunGroup: $rerunGroup, runReleaseSoak: $runReleaseSoak, + executionPlanSha256: $executionPlanSha256, + sourceParentRunAttempt: ($sourceParentRunAttempt | tonumber), validationInputs: { provider: $provider, mode: $mode, @@ -1952,6 +1696,15 @@ jobs: } }' > "${manifest_dir}/full-release-validation-manifest.json" + - name: Validate release validation manifest + env: + RELEASE_PROFILE: ${{ inputs.release_profile }} + RERUN_GROUP: ${{ inputs.rerun_group }} + TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} + RELEASE_EXECUTION_PLAN_PATH: ${{ runner.temp }}/full-release-execution-plan/full-release-execution-plan.json + RELEASE_VALIDATION_MANIFEST_PATH: ${{ runner.temp }}/full-release-validation/full-release-validation-manifest.json + run: node scripts/full-release-validation-state.mjs validate-manifest + - name: Upload release validation manifest if: ${{ success() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 320421f2c168..000871a0ee70 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -516,6 +516,11 @@ jobs: fi fi PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt" + if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then + # Validation-only SHA runs package unreleased main; real release tags + # still require their exact versioned changelog section. + export OPENCLAW_PREPACK_ALLOW_UNRELEASED_CHANGELOG=1 + fi pnpm pack --json 2>&1 | tee "$PACK_OUTPUT" PACK_NAME="$(node - "$PACK_OUTPUT" <<'NODE' const fs = require("node:fs"); diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index e3b85def4865..81aa0897a810 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -93,6 +93,8 @@ jobs: kova_ref: ${{ steps.resolve.outputs.kova_ref }} kova_config_contract: ${{ steps.resolve.outputs.kova_config_contract }} kova_ref_trusted_for_live: ${{ steps.resolve.outputs.kova_ref_trusted_for_live }} + secret_eligible: ${{ steps.candidate_trust.outputs.secret_eligible }} + cache_write_allowed: ${{ steps.candidate_trust.outputs.cache_write_allowed }} steps: - name: Checkout target metadata uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -177,6 +179,34 @@ jobs: echo "kova_ref_trusted_for_live=false" >> "$GITHUB_OUTPUT" fi + - name: Classify performance candidate trust + id: candidate_trust + env: + CANDIDATE_SHA: ${{ steps.resolve.outputs.tested_sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WORKFLOW_SHA: ${{ github.workflow_sha }} + shell: bash + run: | + set -euo pipefail + + secret_eligible=false + cache_write_allowed=false + if [[ + "$GITHUB_EVENT_NAME" == "schedule" || + "$GITHUB_EVENT_NAME" == "workflow_dispatch" + ]] && [[ + "$GITHUB_REF" == "refs/heads/${DEFAULT_BRANCH}" && + "$CANDIDATE_SHA" == "$WORKFLOW_SHA" + ]]; then + secret_eligible=true + cache_write_allowed=true + fi + + { + echo "secret_eligible=$secret_eligible" + echo "cache_write_allowed=$cache_write_allowed" + } >> "$GITHUB_OUTPUT" + kova: name: ${{ matrix.title }} needs: resolve_target @@ -234,6 +264,7 @@ jobs: MATRIX_DEEP_PROFILE: ${{ matrix.deep_profile }} MATRIX_LIVE: ${{ matrix.live }} KOVA_REF_TRUSTED_FOR_LIVE: ${{ needs.resolve_target.outputs.kova_ref_trusted_for_live }} + SECRET_ELIGIBLE: ${{ needs.resolve_target.outputs.secret_eligible }} steps: - name: Decide lane id: lane @@ -250,6 +281,10 @@ jobs: run_lane=false reason="live_openai_candidate input is false" fi + if [[ "$LANE_ID" == "live-openai-candidate" && "$run_lane" == "true" && "$SECRET_ELIGIBLE" != "true" ]]; then + run_lane=false + reason="candidate is not eligible for live credentials" + fi if [[ "$LANE_ID" == "live-openai-candidate" && "$run_lane" == "true" && "$KOVA_REF_TRUSTED_FOR_LIVE" != "true" ]]; then echo "::error::The live OpenAI lane only executes a reviewed immutable Kova default. Omit kova_ref or update the pinned workflow defaults after review." exit 1 @@ -271,7 +306,7 @@ jobs: if: steps.lane.outputs.run == 'true' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-workflow fetch-depth: 1 persist-credentials: false @@ -295,11 +330,35 @@ jobs: echo "Workflow SHA: ${GITHUB_SHA}" } >> "$GITHUB_STEP_SUMMARY" + - name: Stage trusted setup action graph + if: steps.lane.outputs.run == 'true' + shell: bash + run: &stage_trusted_performance_setup | + set -euo pipefail + github_dir="$GITHUB_WORKSPACE/.github" + actions_dir="$github_dir/actions" + trusted_action="$PERFORMANCE_HELPER_DIR/.github/actions/setup-pnpm-store-cache" + + test -f "$trusted_action/action.yml" + test -f "$trusted_action/ensure-node.sh" + if [[ -L "$github_dir" || ( -e "$github_dir" && ! -d "$github_dir" ) ]]; then + rm -rf -- "$github_dir" + fi + mkdir -p "$github_dir" + if [[ -L "$actions_dir" || ( -e "$actions_dir" && ! -d "$actions_dir" ) ]]; then + rm -rf -- "$actions_dir" + fi + mkdir -p "$actions_dir" + rm -rf -- "$actions_dir/setup-pnpm-store-cache" + cp -R -- "$trusted_action" "$actions_dir/setup-pnpm-store-cache" + cmp "$trusted_action/action.yml" "$actions_dir/setup-pnpm-store-cache/action.yml" + cmp "$trusted_action/ensure-node.sh" "$actions_dir/setup-pnpm-store-cache/ensure-node.sh" + - name: Set up Node environment if: steps.lane.outputs.run == 'true' - uses: ./.github/actions/setup-node-env + uses: ./.artifacts/performance-workflow/.github/actions/setup-node-env with: - cache-mode: restore + cache-mode: ${{ needs.resolve_target.outputs.cache_write_allowed == 'true' && 'restore' || 'off' }} install-bun: "false" - name: Prepare systemd user session @@ -448,7 +507,7 @@ jobs: echo "KOVA_LANE_REPEAT=$repeat" >> "$GITHUB_ENV" - name: Configure live OpenAI auth - if: ${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' }} + if: ${{ steps.lane.outputs.run == 'true' && matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' }} env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} @@ -466,8 +525,8 @@ jobs: id: kova if: steps.lane.outputs.run == 'true' env: - OPENAI_API_KEY: ${{ matrix.live == 'true' && secrets.OPENAI_API_KEY || '' }} - OPENAI_BASE_URL: ${{ matrix.live == 'true' && secrets.OPENAI_BASE_URL || '' }} + OPENAI_API_KEY: ${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_API_KEY || '' }} + OPENAI_BASE_URL: ${{ matrix.live == 'true' && needs.resolve_target.outputs.secret_eligible == 'true' && secrets.OPENAI_BASE_URL || '' }} shell: bash run: | set -euo pipefail @@ -646,7 +705,7 @@ jobs: - name: Checkout source performance helpers uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-workflow fetch-depth: 1 persist-credentials: false @@ -667,10 +726,14 @@ jobs: echo "Workflow SHA: ${GITHUB_SHA}" } >> "$GITHUB_STEP_SUMMARY" + - name: Stage trusted source setup action graph + shell: bash + run: *stage_trusted_performance_setup + - name: Set up source performance environment - uses: ./.github/actions/setup-node-env + uses: ./.artifacts/performance-workflow/.github/actions/setup-node-env with: - cache-mode: restore + cache-mode: ${{ needs.resolve_target.outputs.cache_write_allowed == 'true' && 'restore' || 'off' }} install-bun: "false" - name: Fetch previous source performance baseline @@ -925,7 +988,7 @@ jobs: publish: name: Publish ${{ matrix.title }} report needs: [resolve_target, kova, source_performance] - if: ${{ always() && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }} + if: ${{ always() && needs.resolve_target.outputs.secret_eligible == 'true' && (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.publish_reports == true)) && needs.resolve_target.result == 'success' && needs.kova.result != 'cancelled' && needs.source_performance.result != 'cancelled' }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: @@ -966,7 +1029,7 @@ jobs: if: steps.lane.outputs.run == 'true' uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: - ref: ${{ github.sha }} + ref: ${{ github.workflow_sha }} path: .artifacts/performance-publisher sparse-checkout: | scripts/lib/kova-report-publish-files.mjs @@ -1246,7 +1309,7 @@ jobs: - name: Create clawgrit reports app token id: clawgrit_app_token - if: ${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} + if: ${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} continue-on-error: ${{ env.REPORT_PUBLISH_REQUIRED != 'true' }} uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -1257,7 +1320,7 @@ jobs: permission-contents: write - name: Publish to clawgrit reports - if: ${{ steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} + if: ${{ needs.resolve_target.outputs.secret_eligible == 'true' && steps.prepare.outputs.ready == 'true' && steps.prepare.outputs.already_published != 'true' }} continue-on-error: ${{ env.REPORT_PUBLISH_REQUIRED != 'true' }} env: CLAWGRIT_REPORTS_APP_TOKEN: ${{ steps.clawgrit_app_token.outputs.token }} diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index c9b07fe23f14..51168c8646a0 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -7,9 +7,12 @@ on: - main paths: - ".github/workflows/plugin-npm-release.yml" + - ".github/actions/setup-node-env/**" - "extensions/**" - "package.json" - - "packages/normalization-core/**" + - "pnpm-lock.yaml" + - "packages/normalization-core/src/**" + - "packages/plugin-package-contract/src/**" - "scripts/generate-npm-package-lock.mjs" - "scripts/generate-npm-package-lock.mts" - "scripts/lib/npm-publish-plan.mjs" @@ -19,6 +22,8 @@ on: - "scripts/lib/plugin-npm-package-manifest.mts" - "scripts/lib/tsx-cli-shim.mjs" - "scripts/lib/plugin-npm-release.ts" + - "scripts/lib/plugin-publication-candidates.ts" + - "scripts/lib/plugin-publication-collector.ts" - "scripts/lib/actions-artifact-archive.mjs" - "scripts/plugin-npm-publish.sh" - "scripts/plugin-publication-artifact.mjs" diff --git a/.github/workflows/test-performance-agent.yml b/.github/workflows/test-performance-agent.yml deleted file mode 100644 index c80c9aeb76aa..000000000000 --- a/.github/workflows/test-performance-agent.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Test Performance Agent - -on: - workflow_run: # zizmor: ignore[dangerous-triggers] main-only test optimization after trusted CI; job gates repository, event, branch, actor, conclusion, current main SHA, and daily cadence before using write token - workflows: - - CI - types: - - completed - workflow_dispatch: - -permissions: - actions: read - contents: write - -concurrency: - group: test-performance-agent-main - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - TEST_PERF_BEFORE: .artifacts/test-perf/baseline-before.json - TEST_PERF_AFTER: .artifacts/test-perf/after-agent.json - TEST_PERF_COMPARE: .artifacts/test-perf/agent-compare.json - -jobs: - optimize-tests: - if: > - github.repository == 'openclaw/openclaw' && - (github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' && - !endsWith(github.event.workflow_run.actor.login, '[bot]'))) - runs-on: ubuntu-24.04 - timeout-minutes: 240 - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: main - fetch-depth: 0 - persist-credentials: false - submodules: false - - - name: Gate trusted main activity and daily cadence - id: gate - env: - EVENT_NAME: ${{ github.event_name }} - GH_TOKEN: ${{ github.token }} - WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - - if [ "$EVENT_NAME" != "workflow_run" ]; then - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - exit 0 - fi - - for attempt in 1 2 3 4 5; do - if git fetch --no-tags origin main; then - break - fi - if [ "$attempt" = "5" ]; then - echo "Failed to fetch main after retries." >&2 - exit 1 - fi - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - remote_main="$(git rev-parse origin/main)" - if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then - echo "CI run is superseded by ${remote_main}; skipping test performance agent for ${WORKFLOW_HEAD_SHA}." - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - day_start="$(date -u +%Y-%m-%dT00:00:00Z)" - runs_json="$RUNNER_TEMP/test-performance-agent-runs.json" - gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/test-performance-agent.yml/runs" \ - -f branch=main \ - -f event=workflow_run \ - -f per_page=50 > "$runs_json" - - prior_runs="$( - jq -r \ - --argjson current_run_id "$GITHUB_RUN_ID" \ - --arg day_start "$day_start" \ - '.workflow_runs[] - | select(.database_id != $current_run_id) - | select(.created_at >= $day_start) - | select(.status != "cancelled") - | select((.conclusion // "") != "skipped") - | [.database_id, .status, (.conclusion // ""), .created_at, .head_sha] - | @tsv' "$runs_json" - )" - - if [ -n "$prior_runs" ]; then - echo "Test performance agent already ran or is running today; skipping." - printf '%s\n' "$prior_runs" - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=${remote_main}" >> "$GITHUB_OUTPUT" - - - name: Setup Node environment - if: steps.gate.outputs.run_agent == 'true' - uses: ./.github/actions/setup-node-env - with: - cache-mode: restore - install-bun: "false" - - - name: Ensure test performance agent key exists - if: steps.gate.outputs.run_agent == 'true' - env: - OPENAI_API_KEY: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - run: | - set -euo pipefail - if [ -z "${OPENAI_API_KEY:-}" ]; then - echo "Missing OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2 - exit 1 - fi - - - name: Build baseline full-suite performance report - if: steps.gate.outputs.run_agent == 'true' - run: pnpm test:perf:groups --full-suite --allow-failures --output "$TEST_PERF_BEFORE" --limit 20 --top-files 40 - - - name: Run Codex test performance agent - if: steps.gate.outputs.run_agent == 'true' - uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 - with: - openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - prompt-file: .github/codex/prompts/test-performance-agent.md - model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }} - effort: high - sandbox: workspace-write - safety-strategy: drop-sudo - codex-args: '["--full-auto"]' - - - name: Enforce focused test performance patch - if: steps.gate.outputs.run_agent == 'true' - id: patch - run: | - set -euo pipefail - - untracked="$(git ls-files --others --exclude-standard)" - if [ -n "$untracked" ]; then - echo "Test performance agent created untracked files; forbidden:" - printf '%s\n' "$untracked" - exit 1 - fi - - added_deleted_or_renamed="$(git diff --name-status --diff-filter=ADR)" - if [ -n "$added_deleted_or_renamed" ]; then - echo "Test performance agent added, deleted, or renamed tracked files; forbidden:" - printf '%s\n' "$added_deleted_or_renamed" - exit 1 - fi - - bad_paths="$( - git diff --name-only | while IFS= read -r path; do - case "$path" in - apps/*|extensions/*|packages/*|scripts/*|src/*|test/*|ui/*) ;; - *) printf '%s\n' "$path" ;; - esac - done - )" - if [ -n "$bad_paths" ]; then - echo "Test performance agent touched forbidden paths:" - printf '%s\n' "$bad_paths" - exit 1 - fi - - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Restore Node 24 path - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: - | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job - set -euo pipefail - export PATH="${NODE_BIN}:${PATH}" - echo "${NODE_BIN}" >> "$GITHUB_PATH" - node -v - corepack enable - pnpm -v - - - name: Run full-suite performance report after agent changes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups --full-suite --output "$TEST_PERF_AFTER" --limit 20 --top-files 40 - - - name: Compare test performance reports - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups:compare "$TEST_PERF_BEFORE" "$TEST_PERF_AFTER" --output "$TEST_PERF_COMPARE" --limit 20 --top-files 40 - - - name: Enforce coverage-preserving test count - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: | - set -euo pipefail - node <<'NODE' - const fs = require("node:fs"); - const before = JSON.parse(fs.readFileSync(process.env.TEST_PERF_BEFORE, "utf8")); - const after = JSON.parse(fs.readFileSync(process.env.TEST_PERF_AFTER, "utf8")); - - if (before.failed) { - console.log("Baseline had failing configs; skipping total test-count comparison against partial report."); - process.exit(0); - } - - const beforeTests = before.totals?.testCount ?? 0; - const afterTests = after.totals?.testCount ?? 0; - if (afterTests < beforeTests) { - console.error(`Test count decreased from ${beforeTests} to ${afterTests}; refusing coverage-reducing patch.`); - process.exit(1); - } - console.log(`Test count preserved: ${beforeTests} -> ${afterTests}.`); - NODE - - - name: Check changed lanes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm check:changed - - - name: Commit test performance updates - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - TARGET_BRANCH: main - run: | - set -euo pipefail - - if git diff --quiet; then - echo "No test performance changes." - exit 0 - fi - - git config user.name "openclaw-test-performance-agent[bot]" - git config user.email "openclaw-test-performance-agent[bot]@users.noreply.github.com" - git add apps extensions packages scripts src test ui - git commit --no-verify -m "test: optimize slow tests" - - for attempt in 1 2 3 4 5; do - if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - continue - fi - if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then - exit 0 - fi - remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")" - if [ "$remote_main" != "$(git rev-parse HEAD^)" ]; then - echo "main advanced; rebasing test performance update onto ${remote_main}." - if ! git rebase "origin/${TARGET_BRANCH}"; then - echo "Test performance update no longer applies cleanly; skipping stale update." - git rebase --abort || true - exit 0 - fi - pnpm check:changed - fi - echo "Test performance update attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - echo "Failed to push test performance updates after retries." >&2 - exit 1 - - - name: Upload test performance artifacts - if: steps.gate.outputs.run_agent == 'true' && always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-performance-agent-${{ github.run_id }} - path: .artifacts/test-perf/ - if-no-files-found: ignore - retention-days: 14 diff --git a/AGENTS.md b/AGENTS.md index 6c08ec46bd94..4c58997b3530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,7 @@ Review invariants; full doctrine: `docs/gateway/audit.md`. - Invoker evidence is tri-state: tagged principal-bearing input is `present`, tagged principal-less input is `unknown`, and omission alone is `absent`. Validate the closed raw variant before projection or field dropping; reject malformed, mixed, untagged, or extra-field input instead of normalizing it to `unknown` or absence. - Each outer admitted turn owns one immutable `executionId` and `contextId`; `runId` is non-unique correlation. Retries, fallbacks, and recovery reuse the original admission identity. Only byte-identical canonical replay is idempotent. - Decision receipts adapt owner-native durable decisions; `execution_decision_facts` is only for boundaries without an owner-native record, never duplicates approvals, and stays dormant until an explicit product-boundary producer with an operator retention opt-in exists — the 30-day retention bound does not authorize default collection. Receipt coverage `enforced` is diagnostic, not authority: emit it only when the owner changed the outcome and the exact context/execution/run tuple validates; otherwise `unknown`. +- `audit.run.inspect` exposes only the Gateway-owned `decisionDisplays` allowlist; display trust comes from owner-held call-path provenance, never receipt-controlled `source.owner` or prose. Pair every selected owner row or event with a required opaque selector from the same query or page result; never derive or requery selectors from private receipt, resolution, or event identifiers, or drop corrupt, oversized, or unlinked outcomes. - Admission may only validate, bound, freeze, and enqueue through the shared audit writer. Admission validates only a recursively owned, enumerable, accessor-free data snapshot constructed from descriptors before schema checks or ordinary property reads; inherited properties are absent and accessors never run. No synchronous SQLite, schema, filesystem, HMAC-key, or readiness work. Audit failure never delays or aborts execution. - Raw identity references are transient worker-message data. Never persist, export, inspect, or log them. Public Plugin SDK ingress must strip private recovery/admission authority, including JavaScript extra and inherited properties. - Channel participant evidence is host-minted only from an exact active registered native-plugin resolver result and redeemed once against the finalized context plus plugin record/lifecycle epoch. Missing, copied, substituted, replayed, stale, scope-changed, or mixed evidence becomes `unknown`. Mixed participants may remove sender-derived authority only; never widen or erase independent tools, grants, routing, or approval authority. @@ -232,7 +233,9 @@ Review invariants; full doctrine: `docs/gateway/audit.md`. - PR create races GitHub's merge-ref computation and can silently drop or kill the pull_request CI run. Prevention: `gh pr create --draft`, poll `mergeable` non-null, then `gh pr ready`; verify CI attached to the head SHA — if missing, the hourly `pr-ci-sweeper` re-fires it, or close/reopen. - PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe. - Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness. -- PR/issue images/video: `curl -s "https://uploads.github.com/user-attachments/assets?name=&content_type=&repository_id=" -X POST -H "Authorization: Bearer $(gh auth token)" -H "Accept: application/json" --data-binary @`; embed returned `.url` as markdown (video: bare line, not `![]()`). Same CDN as drag-drop; inherits repo visibility; no browser/computer use. Error semantics, video transcode, artifact fallback: `$openclaw-pr-maintainer`. Never push proof assets to any product repo branch; do not commit `.github/pr-assets`. +- PR/issue images/video: when the installed `gh` command exposes `--attach`, use the repeatable flag on `gh issue create`, `gh issue edit`, `gh issue comment`, and the matching `gh pr` commands. Example: `gh pr comment --repo openclaw/openclaw --body-file --attach `; repeat `--attach ` for more files. +- `gh --attach` accepts `.mp4`, `.mov`, and `.webm` videos up to 100 MB locally. GitHub's account limit may be lower. Do not add `#alt` to video paths; `gh` appends the uploaded URL as a bare line so GitHub renders a player. Uploaded assets cannot be deleted. +- If the installed `gh` lacks `--attach`, use `curl -s "https://uploads.github.com/user-attachments/assets?name=&content_type=&repository_id=" -X POST -H "Authorization: Bearer $(gh auth token)" -H "Accept: application/json" --data-binary @`; embed the returned `.url` as a bare line for video, not `![]()`. Both paths use the drag-drop CDN, inherit repository visibility, and require no browser/computer use. Error semantics, video transcode, artifact fallback: `$openclaw-pr-maintainer`. Never push proof assets to any product repo branch; do not commit `.github/pr-assets`. - CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need. Never `gh run watch`; its 3s polling exhausts API quota. Use sparse GraphQL rollups. Filter `gh run list` by workflow/branch/commit; broad JSON lists can exceed relay caps. Exact-SHA fallback dispatches require the full 40-character SHA. - CI waits: `node scripts/watch-pr-ci.mjs ` — prechecks mergeable (CONFLICTING = pull_request CI cannot attach) and run attachment before polling; watchers emit every terminal state; no unbounded polls. - Agent PR landing to `main`: only the repo-native `scripts/pr` wrapper — `review-init` -> `review-artifacts-init` -> `review-validate-artifacts` -> `OPENCLAW_TESTBOX=1 scripts/pr prepare-run` -> `merge-run`. The Testbox flag is mandatory for agents; invoke `prepare-run` only after exact-head CI is complete and green. Full mechanics (fork-code variant, drift policy, waits): `$openclaw-pr-maintainer`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 22cc4481d5e9..d3a1eff86560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **Control UI Codex compaction history:** preserve successful native context compactions as durable, model-excluded activity inside completed work traces after the composer status clears or the session reloads. Fixes #127206. Thanks @shakkernerd. - **Control UI Codex steering:** preserve pre-steer commentary and tool activity in durable transcript order, keep it visible while active, and collapse it before the steering message after completion. Fixes #126938. Thanks @shakkernerd. - **Onboarding migration menu:** group Claude, Codex, Hermes, and plugin-provided imports under a single **Import from another agent** setup choice while preserving detected source hints, manual paths, and Back navigation before import begins. Fixes #126440. Thanks @shakkernerd. - **Onboarding provider hook loading:** scope selected-model hook fallback to the chosen provider so metadata-only setup providers do not load unrelated plugins before configuration completes. Fixes #126408. Thanks @shakkernerd. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 23810ce0d2aa..d0790a611b60 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -961,6 +961,17 @@ } ] }, + { + "id": "native.android.adedee12882a5e48", + "source": "%1$s: %2$s", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.5b1f512f56214522", "source": "+${diff.added}", @@ -1433,6 +1444,17 @@ } ] }, + { + "id": "native.android.4e2d71e58e9b5d58", + "source": "Active on phone", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.ccd226a2c0e85242", "source": "Active task list is at its limit", @@ -3558,6 +3580,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -6479,6 +6505,28 @@ } ] }, + { + "id": "native.android.df6908aa0d65572b", + "source": "Find a model", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, + { + "id": "native.android.98780014fd21c179", + "source": "Find a session", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.e889efed8c081333", "source": "Find on ClawHub", @@ -8338,6 +8386,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/WorkspaceFilesScreen.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -9344,17 +9396,6 @@ } ] }, - { - "id": "native.android.2e7778c3617afdb8", - "source": "Next %1$s", - "surface": "android", - "sites": [ - { - "kind": "resource-string", - "path": "apps/android/wear/src/main/res/values/strings.xml" - } - ] - }, { "id": "native.android.56de778dd64c4cba", "source": "Next Cycle", @@ -9697,6 +9738,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatDictation.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -10925,6 +10970,17 @@ } ] }, + { + "id": "native.android.86743582a5bdbdca", + "source": "Open on watch", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.bd4741b9b157a8b9", "source": "Open profile", @@ -12291,17 +12347,6 @@ } ] }, - { - "id": "native.android.ab710f2d490e510d", - "source": "Previous %1$s", - "surface": "android", - "sites": [ - { - "kind": "resource-string", - "path": "apps/android/wear/src/main/res/values/strings.xml" - } - ] - }, { "id": "native.android.f24b4fe4204360d9", "source": "Prioritizes connected Bluetooth microphones.", @@ -14411,6 +14456,10 @@ { "kind": "ui-named-argument", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/design/ClawComponents.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, @@ -14469,6 +14518,17 @@ } ] }, + { + "id": "native.android.bcab8e57d5c3490d", + "source": "Search models", + "surface": "android", + "sites": [ + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" + } + ] + }, { "id": "native.android.8263e0efaec65c14", "source": "Search proposals", @@ -14499,6 +14559,10 @@ { "kind": "ui-call", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarComponents.kt" + }, + { + "kind": "resource-string", + "path": "apps/android/wear/src/main/res/values/strings.xml" } ] }, diff --git a/apps/android/app/lint.xml b/apps/android/app/lint.xml index e0fee5d8c7ca..be2c631b686f 100644 --- a/apps/android/app/lint.xml +++ b/apps/android/app/lint.xml @@ -8,6 +8,14 @@ + + + + + + + + diff --git a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt index dfd44ef55648..57091fc7d95a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/wear/WearProxyController.kt @@ -140,8 +140,14 @@ internal class WearProxyController( "capabilities", buildJsonArray { WearProxyCapability.entries - .filter { capability -> capability != WearProxyCapability.ModelControls || hasOperatorAdminScope() } - .forEach { capability -> add(JsonPrimitive(capability.wireValue)) } + .filter { capability -> + when (capability) { + WearProxyCapability.ModelControls, + WearProxyCapability.ModelCatalogSearch, + -> hasOperatorAdminScope() + else -> true + } + }.forEach { capability -> add(JsonPrimitive(capability.wireValue)) } }, ) activeAgentId()?.takeIf(String::isNotBlank)?.let { put("activeAgentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } @@ -200,16 +206,24 @@ internal class WearProxyController( } private fun listModels(params: JsonObject): JsonObject { - params.requireOnly("selectedModelRef") + params.requireOnly("selectedModelRef", "query") + val query = params.optionalStringParam("query", MAX_SEARCH_QUERY_CHARS)?.trim().orEmpty() val selected = canonicalModelRef(params.optionalStringParam("selectedModelRef", MAX_MODEL_REF_CHARS)) ?: canonicalModelRef(selectedModelRef()) val availableModels = availableModels() - // The Watch picker moves one adjacent model at a time and reloads after each choice. + val matchingModels = + availableModels.filter { (ref, model) -> + query.isBlank() || model.name.contains(query, ignoreCase = true) || ref.contains(query, ignoreCase = true) + } + // Queries match the full catalog before the bounded transport response. + // Blank requests keep the selected model centered in the compact Watch list. // Centering keeps both directions reachable without exceeding the message cap. val selectedIndex = availableModels.indexOfFirst { (ref) -> ref == selected } val boundedModels = - if (availableModels.size <= MAX_MODEL_COUNT || selectedIndex < 0) { + if (query.isNotBlank()) { + matchingModels.take(MAX_MODEL_COUNT) + } else if (availableModels.size <= MAX_MODEL_COUNT || selectedIndex < 0) { availableModels.take(MAX_MODEL_COUNT) } else { val start = @@ -275,8 +289,10 @@ internal class WearProxyController( } private suspend fun listSessions(params: JsonObject): JsonObject { - params.requireOnly("limit", "selectedSessionKey") + params.requireOnly("limit", "offset", "search", "selectedSessionKey") val limit = params.intParam("limit", default = DEFAULT_SESSION_LIMIT, range = 1..MAX_SESSION_LIMIT) + val offset = params.optionalIntParam("offset", range = 0..MAX_SESSION_OFFSET) + val search = params.optionalStringParam("search", MAX_SEARCH_QUERY_CHARS)?.trim()?.takeIf(String::isNotEmpty) val selectedSessionKey = params.optionalStringParam("selectedSessionKey", MAX_SESSION_KEY_CHARS) val agentId = activeAgentId()?.trim()?.takeIf(String::isNotEmpty) val gatewayResult = @@ -284,6 +300,8 @@ internal class WearProxyController( "sessions.list", buildJsonObject { put("limit", limit) + offset?.let { put("offset", it) } + search?.let { put("search", it) } put("includeGlobal", false) put("includeUnknown", false) agentId?.let { put("agentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } @@ -320,6 +338,7 @@ internal class WearProxyController( put("sessions", JsonArray(sessions)) agentId?.let { put("activeAgentId", it.takeCodePoints(MAX_AGENT_ID_CHARS)) } if (selectedSessionKey != null) put("selectedSessionValid", selectedSessionValid) + gatewayResult["nextOffset"].longPrimitiveOrNull()?.let { put("nextOffset", it) } gatewayResult["hasMore"].booleanPrimitiveOrNull()?.let { put("hasMore", it) } gatewayResult["totalCount"].longPrimitiveOrNull()?.let { put("totalCount", it) } } @@ -386,6 +405,8 @@ internal class WearProxyController( private companion object { const val DEFAULT_SESSION_LIMIT = 20 const val MAX_SESSION_LIMIT = 50 + const val MAX_SESSION_OFFSET = 100_000 + const val MAX_SEARCH_QUERY_CHARS = 200 const val DEFAULT_HISTORY_LIMIT = 20 const val MAX_HISTORY_LIMIT = 20 const val DEFAULT_HISTORY_CHARS = 2_000 diff --git a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt index 7d995ac604da..90501829f603 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/wear/WearProxyControllerTest.kt @@ -61,7 +61,7 @@ class WearProxyControllerTest { } @Test - fun statusAdvertisesModelControlsOnlyWithOperatorAdminScope() = + fun statusAdvertisesModelCapabilitiesOnlyWithOperatorAdminScope() = runTest { var hasOperatorAdminScope = false val controller = @@ -88,8 +88,10 @@ class WearProxyControllerTest { assertEquals( WearProxyCapability.entries - .filter { it != WearProxyCapability.ModelControls } - .map(WearProxyCapability::wireValue), + .filter { + it != WearProxyCapability.ModelControls && + it != WearProxyCapability.ModelCatalogSearch + }.map(WearProxyCapability::wireValue), limitedCapabilities, ) assertEquals( @@ -413,6 +415,43 @@ class WearProxyControllerTest { assertEquals("openai/gpt-59", refs.last()) } + @Test + fun modelSearchFiltersTheFullCatalogBeforeApplyingTheTransportCap() = + runTest { + val controller = + WearProxyController( + requestGateway = { _, _ -> buildJsonObject {} }, + isGatewayConnected = { true }, + gatewayStatusText = { "Connected" }, + models = { + (0 until 80).map { index -> + WearProxyModel(ref = "provider/model-$index", name = "Model $index") + } + }, + ) + + val listed = + controller.handle( + request( + WearRpcMethod.ModelsList, + buildJsonObject { put("query", "model-79") }, + ), + ) + val refs = + checkNotNull(listed.result) + .jsonObject + .getValue("models") + .jsonArray + .map { model -> + model.jsonObject + .getValue("ref") + .jsonPrimitive + .content + } + + assertEquals(listOf("provider/model-79"), refs) + } + @Test fun modelListWindowKeepsAdjacentModelsReachableAcrossTheCap() = runTest { @@ -613,7 +652,7 @@ class WearProxyControllerTest { requestedMethod = method requestedParams = params json.parseToJsonElement( - """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"modelProvider":"openai","model":"gpt-test","lastMessage":"hidden"}],"hasMore":true,"totalCount":9}""", + """{"sessions":[{"key":"agent:main","displayName":"Main","updatedAt":7,"modelProvider":"openai","model":"gpt-test","lastMessage":"hidden"}],"hasMore":true,"nextOffset":10,"totalCount":9}""", ) }, isGatewayConnected = { true }, @@ -625,14 +664,18 @@ class WearProxyControllerTest { controller.handle( request( WearRpcMethod.SessionsList, - buildJsonObject { put("limit", 5) }, + buildJsonObject { + put("limit", 5) + put("offset", 5) + put("search", "older") + }, ), ) assertEquals("sessions.list", requestedMethod) assertEquals( json - .parseToJsonElement("""{"limit":5,"includeGlobal":false,"includeUnknown":false,"agentId":"main"}""") + .parseToJsonElement("""{"limit":5,"offset":5,"search":"older","includeGlobal":false,"includeUnknown":false,"agentId":"main"}""") .jsonObject, requestedParams, ) @@ -654,6 +697,14 @@ class WearProxyControllerTest { .content .toBoolean(), ) + assertEquals( + 10, + result + .getValue("nextOffset") + .jsonPrimitive + .content + .toInt(), + ) } @Test diff --git a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt index 24a2f2ac7f42..ff54943cb18b 100644 --- a/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt +++ b/apps/android/wear-shared/src/main/java/ai/openclaw/wear/shared/WearProtocol.kt @@ -74,7 +74,9 @@ enum class WearProxyCapability( AgentControls(wireValue = "agent-controls"), GatewayControls(wireValue = "gateway-controls"), ModelControls(wireValue = "model-controls"), + ModelCatalogSearch(wireValue = "model-catalog-search"), SessionSelectionLookup(wireValue = "session-selection-lookup"), + SessionSearchPagination(wireValue = "session-search-pagination"), AgentPulse(wireValue = "agent-pulse"), AttemptScopedRealtimeAudio(wireValue = "attempt-scoped-realtime-audio"), ; diff --git a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt index 86242ca5d0c5..276b05e9958b 100644 --- a/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt +++ b/apps/android/wear-shared/src/test/java/ai/openclaw/wear/shared/WearProtocolTest.kt @@ -143,7 +143,9 @@ class WearProtocolTest { assertEquals("agent-controls", WearProxyCapability.AgentControls.wireValue) assertEquals("gateway-controls", WearProxyCapability.GatewayControls.wireValue) assertEquals("model-controls", WearProxyCapability.ModelControls.wireValue) + assertEquals("model-catalog-search", WearProxyCapability.ModelCatalogSearch.wireValue) assertEquals("session-selection-lookup", WearProxyCapability.SessionSelectionLookup.wireValue) + assertEquals("session-search-pagination", WearProxyCapability.SessionSearchPagination.wireValue) assertEquals("agent-pulse", WearProxyCapability.AgentPulse.wireValue) assertEquals( "attempt-scoped-realtime-audio", diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt index a456a8f4ffde..650f1030d994 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.LocalActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.compose.runtime.Composable @@ -203,6 +204,9 @@ internal fun OpenClawWearApp( val messageLabel = stringResource(R.string.message) val messageTitle = stringResource(R.string.message_agent) val sendLabel = stringResource(R.string.send) + val sessionSearchTitle = stringResource(R.string.search_sessions_title) + val modelSearchTitle = stringResource(R.string.search_models_title) + val searchLabel = stringResource(R.string.search) fun submitMessage(rawMessage: String) { val message = rawMessage.trim() @@ -254,6 +258,42 @@ internal fun OpenClawWearApp( interaction = WearInteractionState.READY } } + val sessionSearchLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val query = + result.data + ?.let(RemoteInput::getResultsFromIntent) + ?.getCharSequence(REMOTE_INPUT_KEY) + ?.toString() + if (result.resultCode == Activity.RESULT_OK && !query.isNullOrBlank()) { + viewModel.searchSessions(query) + } + } + val modelSearchLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val query = + result.data + ?.let(RemoteInput::getResultsFromIntent) + ?.getCharSequence(REMOTE_INPUT_KEY) + ?.toString() + if (result.resultCode == Activity.RESULT_OK && !query.isNullOrBlank()) { + viewModel.searchModels(query) + } + } + + fun launchSearchInput( + title: String, + launcher: ActivityResultLauncher, + ) { + val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY).setLabel(searchLabel).build() + val intent = + RemoteInputIntentHelper.createActionRemoteInputIntent().also { inputIntent -> + RemoteInputIntentHelper.putRemoteInputsExtra(inputIntent, listOf(remoteInput)) + RemoteInputIntentHelper.putTitleExtra(inputIntent, title) + RemoteInputIntentHelper.putConfirmLabelExtra(inputIntent, searchLabel) + } + launcher.launch(intent) + } fun startRealtimeTalk() { speaker.stop() @@ -466,15 +506,23 @@ internal fun OpenClawWearApp( viewModel.selectAgent(agentId) }, onSelectSession = { sessionKey -> - state.sessions.firstOrNull { it.key == sessionKey }?.let { session -> + ( + state.sessionSearchResults.firstOrNull { it.key == sessionKey } + ?: state.sessions.firstOrNull { it.key == sessionKey } + )?.let { session -> leaveConversationContext() viewModel.openSession(session) } }, + onSearchSessions = { launchSearchInput(sessionSearchTitle, sessionSearchLauncher) }, + onLoadMoreSessionSearch = viewModel::loadMoreSessionSearch, + onClearSessionSearch = viewModel::clearSessionSearch, onSelectModel = { modelRef -> leaveConversationContext() viewModel.selectModel(modelRef) }, + onSearchModels = { launchSearchInput(modelSearchTitle, modelSearchLauncher) }, + onClearModelSearch = viewModel::clearModelSearch, onAgentPulseVisibilityChanged = viewModel::setAgentPulseVisible, onAgentPulseRefresh = viewModel::refreshAgentPulse, onRefresh = viewModel::refresh, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt index 0b3b745363f5..7d46e5dca37a 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearCompanionUiModels.kt @@ -34,6 +34,8 @@ internal data class WearSessionSummary( val title: String?, val updatedAtEpochMillis: Long?, val selected: Boolean, + val activeOnPhone: Boolean = false, + val openOnWatch: Boolean = false, ) internal data class WearModelSummary( @@ -49,9 +51,17 @@ internal data class WearConversationSnapshot( val agentControlsSupported: Boolean = false, val gatewayControlsSupported: Boolean = false, val activeSessionId: String? = null, + val phoneActiveSessionId: String? = null, val sessions: List = emptyList(), + val sessionSearchQuery: String? = null, + val sessionSearchResults: List = emptyList(), + val sessionSearchHasMore: Boolean = false, + val sessionSearchSupported: Boolean = false, val models: List = emptyList(), + val modelSearchQuery: String? = null, + val modelSearchResults: List = emptyList(), val modelControlsSupported: Boolean = false, + val modelSearchSupported: Boolean = false, val messages: List = emptyList(), val streamingAssistantText: String? = null, val pendingRunCount: Int = 0, @@ -103,6 +113,7 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { agentControlsSupported = WearProxyCapability.AgentControls in proxyCapabilities, gatewayControlsSupported = WearProxyCapability.GatewayControls in proxyCapabilities, activeSessionId = selectedSession?.key, + phoneActiveSessionId = phoneActiveSessionKey, sessions = sessions.map { session -> WearSessionSummary( @@ -110,8 +121,24 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { title = session.title, updatedAtEpochMillis = session.updatedAt, selected = session.key == selectedSession?.key, + activeOnPhone = session.key == phoneActiveSessionKey, + openOnWatch = session.key == selectedSession?.key, ) }, + sessionSearchQuery = sessionSearchQuery, + sessionSearchResults = + sessionSearchResults.map { session -> + WearSessionSummary( + id = session.key, + title = session.title, + updatedAtEpochMillis = session.updatedAt, + selected = session.key == selectedSession?.key, + activeOnPhone = session.key == phoneActiveSessionKey, + openOnWatch = session.key == selectedSession?.key, + ) + }, + sessionSearchHasMore = sessionSearchHasMore, + sessionSearchSupported = WearProxyCapability.SessionSearchPagination in proxyCapabilities, models = models.map { model -> WearModelSummary( @@ -121,6 +148,16 @@ internal fun WearUiState.toConversationSnapshot(): WearConversationSnapshot? { ) }, modelControlsSupported = WearProxyCapability.ModelControls in proxyCapabilities, + modelSearchSupported = WearProxyCapability.ModelCatalogSearch in proxyCapabilities, + modelSearchQuery = modelSearchQuery, + modelSearchResults = + modelSearchResults.map { model -> + WearModelSummary( + ref = model.ref, + name = model.name, + selected = model.ref == selectedModelRef, + ) + }, messages = messages, streamingAssistantText = streamText, pendingRunCount = if (activeRunId != null) 1 else 0, diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt index d128a908c4e5..eca56f8cabf4 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearGatewayRepository.kt @@ -117,6 +117,8 @@ internal data class WearSessionList( val eventStreamId: String? = null, val activeAgentId: String? = null, val selectedSessionValid: Boolean = false, + val hasMore: Boolean = false, + val nextOffset: Int? = null, ) internal data class WearModel( @@ -286,6 +288,7 @@ internal class WearGatewayRepository( expectedNodeId: String, capabilities: Set, selectedModelRef: String? = null, + query: String? = null, ): WearModelList { capabilities.require(WearProxyCapability.ModelControls) val response = @@ -293,6 +296,9 @@ internal class WearGatewayRepository( WearRpcMethod.ModelsList, buildJsonObject { selectedModelRef?.let { put("selectedModelRef", it) } + if (WearProxyCapability.ModelCatalogSearch in capabilities) { + query?.takeIf(String::isNotBlank)?.let { put("query", it) } + } }, expectedNodeId, requirePreferredNode = true, @@ -370,13 +376,20 @@ internal class WearGatewayRepository( expectedNodeId: String? = null, selectedSessionKey: String? = null, capabilities: Set = emptySet(), + limit: Int = 30, + offset: Int? = null, + search: String? = null, ): WearSessionList { val response = requester .request( WearRpcMethod.SessionsList, buildJsonObject { - put("limit", 30) + put("limit", limit) + if (WearProxyCapability.SessionSearchPagination in capabilities) { + offset?.let { put("offset", it) } + search?.takeIf(String::isNotBlank)?.let { put("search", it) } + } if (WearProxyCapability.SessionSelectionLookup in capabilities) { selectedSessionKey?.takeIf(String::isNotBlank)?.let { put("selectedSessionKey", it) } } @@ -394,6 +407,8 @@ internal class WearGatewayRepository( phoneNodeId = response.sourceNodeId, activeAgentId = result.string("activeAgentId"), selectedSessionValid = result.boolean("selectedSessionValid") ?: false, + hasMore = result.boolean("hasMore") ?: false, + nextOffset = result.long("nextOffset")?.toInt(), ) } diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt index 851c5f3786b8..a37ccf9e1e8b 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt @@ -166,6 +166,11 @@ internal fun OpenClawWearScreens( onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit = {}, + onLoadMoreSessionSearch: () -> Unit = {}, + onClearSessionSearch: () -> Unit = {}, + onSearchModels: () -> Unit = {}, + onClearModelSearch: () -> Unit = {}, onAgentPulseVisibilityChanged: (Boolean) -> Unit = {}, onAgentPulseRefresh: () -> Unit = {}, onRefresh: () -> Unit, @@ -299,6 +304,11 @@ internal fun OpenClawWearScreens( onSelectAgent = onSelectAgent, onSelectSession = onSelectSession, onSelectModel = onSelectModel, + onSearchSessions = onSearchSessions, + onLoadMoreSessionSearch = onLoadMoreSessionSearch, + onClearSessionSearch = onClearSessionSearch, + onSearchModels = onSearchModels, + onClearModelSearch = onClearModelSearch, onSpeakLatest = onSpeakLatest, onStopSpeaking = onStopSpeaking, ) @@ -372,6 +382,11 @@ private fun ChatPage( onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit, + onLoadMoreSessionSearch: () -> Unit, + onClearSessionSearch: () -> Unit, + onSearchModels: () -> Unit, + onClearModelSearch: () -> Unit, onSpeakLatest: () -> Unit, onStopSpeaking: () -> Unit, ) { @@ -385,8 +400,6 @@ private fun ChatPage( visibleMessageCount = visibleMessages.size, hasStreaming = streamingText != null, canAbort = canAbort, - hasAssistant = hasAssistant, - hasFailure = snapshot.failure != null, ) val contentRevision = wearChatContentRevision( @@ -396,6 +409,25 @@ private fun ChatPage( latestAnchorIndex = latestAnchorIndex, ) var followState by remember(snapshot.activeSessionId) { mutableStateOf(WearThreadFollowState()) } + var contextPicker by remember { mutableStateOf(null) } + + fun clearContextPickerSearch() { + when (contextPicker) { + WearContextPicker.Session -> onClearSessionSearch() + WearContextPicker.Model -> onClearModelSearch() + else -> Unit + } + } + + fun finishContextPicker() { + clearContextPickerSearch() + contextPicker = null + } + + fun closeContextPicker() { + clearContextPickerSearch() + contextPicker = contextPicker?.let(::wearContextPickerAfterClose) + } LaunchedEffect(listState, snapshot.activeSessionId) { snapshotFlow { @@ -429,15 +461,6 @@ private fun ChatPage( pageLabel = stringResource(R.string.chat), listState = listState, ) { - item { - ConversationIdentity( - snapshot = snapshot, - actionBusy = actionBusy, - onSelectAgent = onSelectAgent, - onSelectSession = onSelectSession, - onSelectModel = onSelectModel, - ) - } item { ConversationStatus( interaction = interaction, @@ -445,28 +468,6 @@ private fun ChatPage( gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED, ) } - item { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - ActionButton( - label = stringResource(R.string.talk), - enabled = inputEnabled && !actionBusy && !speaking, - onClick = onTalk, - modifier = Modifier.weight(1f), - ) - ActionButton( - label = stringResource(R.string.type), - enabled = inputEnabled && !actionBusy && !speaking, - onClick = onType, - modifier = Modifier.weight(1f), - ) - } - } if (canAbort) { item { SecondaryButton( @@ -492,6 +493,11 @@ private fun ChatPage( } } } + if (latestAnchorIndex >= 0) { + item(key = "chat-end") { + Spacer(modifier = Modifier.height(1.dp)) + } + } if (hasAssistant) { item { SecondaryButton( @@ -506,18 +512,42 @@ private fun ChatPage( ) } } + item { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ActionButton( + label = stringResource(R.string.talk), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onTalk, + modifier = Modifier.weight(1f), + ) + ActionButton( + label = stringResource(R.string.type), + enabled = inputEnabled && !actionBusy && !speaking, + onClick = onType, + modifier = Modifier.weight(1f), + ) + } + } + item { + ConversationContextPicker( + snapshot = snapshot, + actionBusy = actionBusy, + onOpenContextPicker = { contextPicker = WearContextPicker.Session }, + ) + } snapshot.failure?.let { failure -> item { InlineError(text = failureDetail(failure)) } } - if (latestAnchorIndex >= 0) { - item(key = "chat-end") { - Spacer(modifier = Modifier.height(1.dp)) - } - } } - if (followState.hasNewContent) { + if (followState.hasNewContent && contextPicker == null) { NewMessagesAction( modifier = Modifier @@ -532,6 +562,31 @@ private fun ChatPage( } } } + contextPicker?.let { picker -> + ContextPickerOverlay( + picker = picker, + snapshot = snapshot, + actionBusy = actionBusy, + onDismiss = ::closeContextPicker, + onOpenAgentPicker = { contextPicker = WearContextPicker.Agent }, + onOpenModelPicker = { contextPicker = WearContextPicker.Model }, + onSelectAgent = { agentId -> + onSelectAgent(agentId) + finishContextPicker() + }, + onSelectSession = { sessionId -> + onSelectSession(sessionId) + finishContextPicker() + }, + onSelectModel = { modelRef -> + onSelectModel(modelRef) + finishContextPicker() + }, + onSearchSessions = onSearchSessions, + onLoadMoreSessionSearch = onLoadMoreSessionSearch, + onSearchModels = onSearchModels, + ) + } } } @@ -1201,13 +1256,11 @@ internal fun wearChatLatestAnchorIndex( visibleMessageCount: Int, hasStreaming: Boolean, canAbort: Boolean, - hasAssistant: Boolean, - hasFailure: Boolean, ): Int { if (visibleMessageCount == 0 && !hasStreaming) return -1 return CHAT_FIXED_ITEM_COUNT + visibleMessageCount + - listOf(canAbort, hasStreaming, hasAssistant, hasFailure).count { it } + listOf(canAbort, hasStreaming).count { it } } internal fun wearThreadLatestAnchorIndex( @@ -1409,6 +1462,7 @@ private fun ControlsPage( onGatewayEnabledChange: (Boolean) -> Unit, ) { val gatewayConnected = snapshot.gatewayState == WearGatewayState.CONNECTED + WearPage(pageLabel = stringResource(R.string.controls)) { item { ConnectionPanel(snapshot = snapshot) @@ -1843,66 +1897,284 @@ private fun OpenClawHeader(pageLabel: String) { } @Composable -private fun ConversationIdentity( +private fun ConversationContextPicker( snapshot: WearConversationSnapshot, actionBusy: Boolean, + onOpenContextPicker: () -> Unit, +) { + val session = snapshot.sessions.firstOrNull(WearSessionSummary::selected) ?: snapshot.sessions.firstOrNull() + val agent = snapshot.agents.firstOrNull(WearAgentSummary::selected) ?: snapshot.agents.firstOrNull() + val model = snapshot.models.firstOrNull(WearModelSummary::selected) + val agentName = + listOfNotNull( + agent?.emoji?.takeIf(String::isNotBlank), + agent?.name ?: stringResource(R.string.agent), + ).joinToString(" ") + val modelName = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model) + ContextPickerOption( + title = + stringResource( + R.string.context_label_value, + stringResource(R.string.session), + session?.title ?: stringResource(R.string.current_session), + ), + detail = + stringResource( + R.string.context_label_value, + stringResource(R.string.agent), + agentName, + ), + status = + stringResource( + R.string.context_label_value, + stringResource(R.string.model), + modelName, + ), + selected = true, + enabled = !actionBusy, + onClick = onOpenContextPicker, + modifier = Modifier.padding(horizontal = 12.dp), + ) +} + +internal enum class WearContextPicker { + Agent, + Session, + Model, +} + +internal fun wearContextPickerAfterClose(picker: WearContextPicker): WearContextPicker? = + when (picker) { + WearContextPicker.Agent, WearContextPicker.Model -> WearContextPicker.Session + WearContextPicker.Session -> null + } + +@Composable +private fun ContextPickerOverlay( + picker: WearContextPicker, + snapshot: WearConversationSnapshot, + actionBusy: Boolean, + onDismiss: () -> Unit, + onOpenAgentPicker: () -> Unit, + onOpenModelPicker: () -> Unit, onSelectAgent: (String) -> Unit, onSelectSession: (String) -> Unit, onSelectModel: (String) -> Unit, + onSearchSessions: () -> Unit, + onLoadMoreSessionSearch: () -> Unit, + onSearchModels: () -> Unit, ) { - val agentIndex = snapshot.agents.indexOfFirst(WearAgentSummary::selected) - val sessionIndex = snapshot.sessions.indexOfFirst(WearSessionSummary::selected) - val modelIndex = snapshot.models.indexOfFirst(WearModelSummary::selected) - val agent = snapshot.agents.getOrNull(agentIndex) ?: snapshot.agents.firstOrNull() - val session = snapshot.sessions.getOrNull(sessionIndex) ?: snapshot.sessions.firstOrNull() - val model = snapshot.models.getOrNull(modelIndex) - Panel { - ContextPickerRow( - label = stringResource(R.string.agent), - value = - listOfNotNull( - agent?.emoji?.takeIf(String::isNotBlank), - agent?.name ?: stringResource(R.string.agent), - ).joinToString(" "), - previous = - snapshot.agents - .getOrNull(agentIndex - 1) - ?.takeIf { snapshot.agentControlsSupported && !actionBusy } - ?.let { previous -> ({ onSelectAgent(previous.id) }) }, - next = - snapshot.agents - .getOrNull(if (agentIndex < 0) 0 else agentIndex + 1) - ?.takeIf { snapshot.agentControlsSupported && !actionBusy } - ?.let { next -> ({ onSelectAgent(next.id) }) }, - ) - ContextPickerRow( - label = stringResource(R.string.session), - value = session?.title ?: stringResource(R.string.current_session), - previous = - snapshot.sessions - .getOrNull(sessionIndex - 1) - ?.takeIf { !actionBusy } - ?.let { previous -> ({ onSelectSession(previous.id) }) }, - next = - snapshot.sessions - .getOrNull(if (sessionIndex < 0) 0 else sessionIndex + 1) - ?.takeIf { !actionBusy } - ?.let { next -> ({ onSelectSession(next.id) }) }, - ) - ContextPickerRow( - label = stringResource(R.string.model), - value = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model), - previous = - snapshot.models - .getOrNull(modelIndex - 1) - ?.takeIf { snapshot.modelControlsSupported && !actionBusy } - ?.let { previous -> ({ onSelectModel(previous.ref) }) }, - next = - snapshot.models - .getOrNull(if (modelIndex < 0) 0 else modelIndex + 1) - ?.takeIf { snapshot.modelControlsSupported && !actionBusy } - ?.let { next -> ({ onSelectModel(next.ref) }) }, + BackHandler(onBack = onDismiss) + val pageLabel = + when (picker) { + WearContextPicker.Agent -> stringResource(R.string.agent) + WearContextPicker.Session -> stringResource(R.string.session) + WearContextPicker.Model -> stringResource(R.string.model) + } + WearPage(pageLabel = pageLabel) { + item { + SecondaryButton( + label = stringResource(R.string.close), + enabled = true, + onClick = onDismiss, + ) + } + if (picker == WearContextPicker.Session) { + item { + val agent = snapshot.agents.firstOrNull(WearAgentSummary::selected) ?: snapshot.agents.firstOrNull() + val model = snapshot.models.firstOrNull(WearModelSummary::selected) + Panel { + ContextPickerRow( + label = stringResource(R.string.agent), + value = + listOfNotNull( + agent?.emoji?.takeIf(String::isNotBlank), + agent?.name ?: stringResource(R.string.agent), + ).joinToString(" "), + onClick = onOpenAgentPicker.takeIf { snapshot.agentControlsSupported && !actionBusy }, + ) + ContextPickerDivider() + ContextPickerRow( + label = stringResource(R.string.model), + value = model?.name ?: snapshot.selectedModelRef ?: stringResource(R.string.model), + onClick = onOpenModelPicker.takeIf { snapshot.modelControlsSupported && !actionBusy }, + ) + } + } + if (snapshot.sessionSearchSupported) { + item { + SecondaryButton( + label = stringResource(R.string.search_sessions), + enabled = !actionBusy, + onClick = onSearchSessions, + ) + } + snapshot.sessionSearchQuery?.let { query -> + item { PickerQueryLabel(query = query) } + } + } + } + if (picker == WearContextPicker.Model && snapshot.modelSearchSupported) { + item { + SecondaryButton( + label = stringResource(R.string.search_models), + enabled = !actionBusy, + onClick = onSearchModels, + ) + } + snapshot.modelSearchQuery?.let { query -> + item { PickerQueryLabel(query = query) } + } + } + when (picker) { + WearContextPicker.Agent -> + snapshot.agents.forEach { agent -> + item(key = "agent:${agent.id}") { + ContextPickerOption( + title = listOfNotNull(agent.emoji?.takeIf(String::isNotBlank), agent.name).joinToString(" "), + detail = agent.id, + status = null, + selected = agent.selected, + enabled = !actionBusy, + onClick = { onSelectAgent(agent.id) }, + ) + } + } + WearContextPicker.Session -> { + val sessions = + if (snapshot.sessionSearchQuery == null) snapshot.sessions else snapshot.sessionSearchResults + if (sessions.isEmpty()) { + item { PickerEmptyResult() } + } + sessions.forEach { session -> + item(key = "session:${session.id}") { + val status = + listOfNotNull( + stringResource(R.string.active_on_phone).takeIf { session.activeOnPhone }, + stringResource(R.string.open_on_watch).takeIf { session.openOnWatch }, + ).joinToString(" / ").takeIf(String::isNotEmpty) + ContextPickerOption( + title = session.title ?: stringResource(R.string.current_session), + detail = null, + status = status, + selected = session.openOnWatch, + enabled = !actionBusy, + onClick = { onSelectSession(session.id) }, + ) + } + } + if ( + snapshot.sessionSearchSupported && + snapshot.sessionSearchQuery != null && + snapshot.sessionSearchHasMore + ) { + item { + SecondaryButton( + label = stringResource(R.string.load_more), + enabled = !actionBusy, + onClick = onLoadMoreSessionSearch, + ) + } + } + } + WearContextPicker.Model -> { + val models = + if (snapshot.modelSearchQuery == null) snapshot.models else snapshot.modelSearchResults + if (models.isEmpty()) { + item { PickerEmptyResult() } + } + models.forEach { model -> + item(key = "model:${model.ref}") { + ContextPickerOption( + title = model.name, + detail = model.ref, + status = null, + selected = model.selected, + enabled = !actionBusy, + onClick = { onSelectModel(model.ref) }, + ) + } + } + } + } + } +} + +@Composable +private fun PickerQueryLabel(query: String) { + Text( + text = query, + color = OpenClawWearTheme.colors.textMuted, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun PickerEmptyResult() { + Text( + text = stringResource(R.string.no_matches), + color = OpenClawWearTheme.colors.textMuted, + fontSize = 12.sp, + textAlign = TextAlign.Center, + ) +} + +@Composable +private fun ContextPickerOption( + title: String, + detail: String?, + status: String?, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = OpenClawWearTheme.colors + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .then( + Modifier.border( + width = 1.dp, + color = if (selected) colors.primary else colors.border, + shape = RoundedCornerShape(14.dp), + ), + ).padding(horizontal = 12.dp, vertical = 9.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = title, + color = if (enabled) colors.text else colors.textMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) + detail?.takeIf(String::isNotBlank)?.let { + Text( + text = it, + color = colors.textMuted, + fontSize = 9.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + status?.let { + Text( + text = it, + color = colors.primary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + } } } @@ -1910,80 +2182,50 @@ private fun ConversationIdentity( private fun ContextPickerRow( label: String, value: String, - previous: (() -> Unit)?, - next: (() -> Unit)?, + onClick: (() -> Unit)?, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + val colors = OpenClawWearTheme.colors + val enabled = onClick != null + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable( + enabled = enabled, + role = Role.Button, + onClick = { onClick?.invoke() }, + ).padding(vertical = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, ) { - PickerChevron( - glyph = "‹", - contentDescription = stringResource(R.string.previous_item, label), - onClick = previous, + Text( + text = localizedWearUppercase(label), + color = colors.textMuted, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + maxLines = 1, ) - Column( - modifier = Modifier.weight(1f), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = localizedWearUppercase(label), - color = OpenClawWearTheme.colors.textMuted, - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.8.sp, - maxLines = 1, - ) - Text( - text = value, - color = OpenClawWearTheme.colors.text, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - PickerChevron( - glyph = "›", - contentDescription = stringResource(R.string.next_item, label), - onClick = next, + Text( + text = value, + color = if (enabled) colors.text else colors.textMuted, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } @Composable -private fun PickerChevron( - glyph: String, - contentDescription: String, - onClick: (() -> Unit)?, -) { - val colors = OpenClawWearTheme.colors - val enabled = onClick != null +private fun ContextPickerDivider() { Box( modifier = Modifier - // Foundation clickable expands hit testing to the system minimum touch target. - // Compact visual bounds keep picker values readable on 192dp round screens. - .width(32.dp) - .height(30.dp) - .semantics { this.contentDescription = contentDescription } - .clickable( - enabled = enabled, - role = Role.Button, - onClick = { onClick?.invoke() }, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = glyph, - color = if (enabled) colors.primary else colors.textMuted.copy(alpha = 0.42f), - fontSize = 24.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.SemiBold, - textAlign = TextAlign.Center, - ) - } + .fillMaxWidth() + .height(1.dp) + .background(OpenClawWearTheme.colors.borderStrong.copy(alpha = 0.45f)), + ) } @Composable @@ -2498,6 +2740,6 @@ private fun failureDetail(failure: WearConversationFailure?): String = -> stringResource(R.string.try_again) } -private const val CHAT_FIXED_ITEM_COUNT = 4 +private const val CHAT_FIXED_ITEM_COUNT = 2 private const val VISIBLE_MESSAGE_COUNT = 8 private const val VISIBLE_REALTIME_ENTRY_COUNT = 6 diff --git a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt index 8c997c524d88..534c4cb515f5 100644 --- a/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt +++ b/apps/android/wear/src/main/java/ai/openclaw/wear/WearViewModel.kt @@ -34,6 +34,13 @@ internal data class WearUiState( val proxyCapabilities: Set = emptySet(), val sessions: List = emptyList(), val selectedSession: WearSession? = null, + val phoneActiveSessionKey: String? = null, + val sessionSearchQuery: String? = null, + val sessionSearchResults: List = emptyList(), + val sessionSearchHasMore: Boolean = false, + val sessionSearchNextOffset: Int? = null, + val modelSearchQuery: String? = null, + val modelSearchResults: List = emptyList(), val messages: List = emptyList(), val streamText: String? = null, val activeRunId: String? = null, @@ -63,6 +70,13 @@ internal fun WearUiState.resetForPhoneChange(): WearUiState = proxyCapabilities = emptySet(), sessions = emptyList(), selectedSession = null, + phoneActiveSessionKey = null, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -85,6 +99,13 @@ internal fun WearUiState.switchAgentContext(agentId: String): WearUiState = activeAgentId = agentId, sessions = emptyList(), selectedSession = null, + phoneActiveSessionKey = null, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -98,6 +119,12 @@ internal fun WearUiState.switchAgentContext(agentId: String): WearUiState = internal fun WearUiState.switchSessionContext(session: WearSession): WearUiState = copy( selectedSession = session, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = emptyList(), streamText = null, activeRunId = null, @@ -125,6 +152,10 @@ internal fun WearUiState.switchModelContext(modelRef: String): WearUiState { ) } +internal fun WearUiState.containsModelRef(modelRef: String): Boolean = + models.any { model -> model.ref == modelRef } || + modelSearchResults.any { model -> model.ref == modelRef } + internal fun shouldAcceptWearTalkSnapshot( snapshot: WearRealtimeTalkSnapshot, attemptId: String?, @@ -199,6 +230,7 @@ internal class WearViewModel( private val sendAttemptTracker = WearSendAttemptTracker() private val controlBusyOwner = WearControlBusyOwner() private var loadJob: Job? = null + private var sessionSearchJob: Job? = null private var phoneRouteGeneration = 0L private var agentPulsePollJob: Job? = null private var agentPulseVisible = false @@ -299,6 +331,46 @@ internal class WearViewModel( restartAgentPulsePolling(forceLoading = true) } + fun searchSessions(query: String) { + if (WearProxyCapability.SessionSearchPagination !in mutableState.value.proxyCapabilities) return + val normalized = query.trim() + if (normalized.isEmpty()) return + loadSessionSearch(normalized, offset = 0, append = false) + } + + fun loadMoreSessionSearch() { + val current = mutableState.value + if (WearProxyCapability.SessionSearchPagination !in current.proxyCapabilities) return + val query = current.sessionSearchQuery ?: return + val offset = current.sessionSearchNextOffset ?: return + if (!current.sessionSearchHasMore) return + loadSessionSearch(query, offset = offset, append = true) + } + + fun clearSessionSearch() { + sessionSearchJob?.cancel() + mutableState.update { + it.copy( + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + ) + } + } + + fun searchModels(query: String) { + if (WearProxyCapability.ModelCatalogSearch !in mutableState.value.proxyCapabilities) return + val normalized = query.trim() + if (normalized.isEmpty()) return + mutableState.value.selectedSession?.let { session -> loadModels(session, normalized) } + } + + fun clearModelSearch() { + cancelModelLoad() + mutableState.update { it.copy(modelSearchQuery = null, modelSearchResults = emptyList()) } + } + fun closeSession() { endRealtimeTalkForNavigation() cancelModelLoad() @@ -491,7 +563,7 @@ internal class WearViewModel( current.realtimeCapturing || current.realtimePlaying || current.selectedModelRef == modelRef || - current.models.none { model -> model.ref == modelRef } || + !current.containsModelRef(modelRef) || WearProxyCapability.ModelControls !in current.proxyCapabilities ) { return @@ -586,6 +658,7 @@ internal class WearViewModel( } private fun loadSessions(expectedNodeId: String? = null) { + sessionSearchJob?.cancel() invalidateAgentPulse(clearSnapshot = true) cancelLoad() cancelModelLoad() @@ -708,6 +781,13 @@ internal class WearViewModel( proxyCapabilities = status.capabilities, sessions = projectedSessions, selectedSession = selectedSession, + phoneActiveSessionKey = activeSessionKey, + sessionSearchQuery = null, + sessionSearchResults = emptyList(), + sessionSearchHasMore = false, + sessionSearchNextOffset = null, + modelSearchQuery = null, + modelSearchResults = emptyList(), messages = if (selectionChanged || !status.connected) emptyList() else it.messages, streamText = if (selectionChanged || !status.connected) null else it.streamText, activeRunId = if (selectionChanged || !status.connected) null else it.activeRunId, @@ -834,11 +914,63 @@ internal class WearViewModel( } } - private fun loadModels(session: WearSession) { + private fun loadSessionSearch( + query: String, + offset: Int, + append: Boolean, + ) { + val current = mutableState.value + if (WearProxyCapability.SessionSearchPagination !in current.proxyCapabilities) return + val phoneNodeId = current.phoneNodeId ?: return + if (!current.connected) return + val routeGeneration = phoneRouteGeneration + sessionSearchJob?.cancel() + sessionSearchJob = + viewModelScope.launch { + try { + val result = + repository.sessions( + expectedNodeId = phoneNodeId, + capabilities = current.proxyCapabilities, + limit = 50, + offset = offset, + search = query, + ) + if (routeGeneration != phoneRouteGeneration || mutableState.value.phoneNodeId != result.phoneNodeId) { + return@launch + } + mutableState.update { state -> + val results = + if (append && state.sessionSearchQuery == query) { + (state.sessionSearchResults + result.sessions).distinctBy(WearSession::key) + } else { + result.sessions + } + state.copy( + sessionSearchQuery = query, + sessionSearchResults = results, + sessionSearchHasMore = result.hasMore, + sessionSearchNextOffset = result.nextOffset, + failure = null, + ) + } + } catch (err: CancellationException) { + throw err + } catch (err: Throwable) { + recordFailureForControlRoute(err, phoneNodeId, routeGeneration, loading = false) + } + } + } + + private fun loadModels( + session: WearSession, + query: String? = null, + ) { val current = mutableState.value val capabilities = current.proxyCapabilities if ( WearProxyCapability.ModelControls !in capabilities || + (query != null && WearProxyCapability.ModelCatalogSearch !in capabilities) || !wearSessionRequestIsCurrent(session, current.selectedSession, session.phoneNodeId) ) { return @@ -853,6 +985,7 @@ internal class WearViewModel( expectedNodeId = session.phoneNodeId, capabilities = capabilities, selectedModelRef = session.modelRef, + query = query, ) val selectedSession = mutableState.value.selectedSession if (!wearSessionRequestIsCurrent(session, selectedSession, modelList.phoneNodeId)) return@launch @@ -871,7 +1004,11 @@ internal class WearViewModel( if (!wearSessionRequestIsCurrent(session, state.selectedSession, modelList.phoneNodeId)) { state } else { - state.copy(models = modelList.models) + if (query == null) { + state.copy(models = modelList.models, modelSearchQuery = null, modelSearchResults = emptyList()) + } else { + state.copy(modelSearchQuery = query, modelSearchResults = modelList.models) + } } } } catch (err: CancellationException) { diff --git a/apps/android/wear/src/main/res/values/strings.xml b/apps/android/wear/src/main/res/values/strings.xml index 83091df3cfc4..6c360210fbf9 100644 --- a/apps/android/wear/src/main/res/values/strings.xml +++ b/apps/android/wear/src/main/res/values/strings.xml @@ -4,8 +4,6 @@ Chat Session Model - Previous %1$s - Next %1$s Controls Talk Watch audio failed @@ -43,6 +41,17 @@ Start a conversation Talk or type on your watch. The paired phone sends the message through its authenticated OpenClaw session. Current session + %1$s: %2$s + Close + Search + Search sessions + Search models + Load more + Active on phone + Open on watch + No matches + Find a session + Find a model Appearance Dark Light diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt index d42a2094b111..f3533a8cf2e1 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/MainActivityTest.kt @@ -80,7 +80,7 @@ class MainActivityTest { @Test fun chatFollowTracksStreamingGrowthAtLatest() { val messages = listOf(WearChatMessage(id = "user-1", role = "user", text = "Status?", timestamp = 1L)) - val anchor = wearChatLatestAnchorIndex(1, hasStreaming = true, canAbort = false, hasAssistant = false, hasFailure = false) + val anchor = wearChatLatestAnchorIndex(1, hasStreaming = true, canAbort = false) val first = nextWearThreadFollowForContent( state = WearThreadFollowState(), @@ -131,21 +131,28 @@ class MainActivityTest { } @Test - fun chatFollowTargetsRenderedTrailingAnchor() { + fun chatFollowStopsAtLatestMessageBeforeControls() { assertEquals( -1, - wearChatLatestAnchorIndex(0, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = true), + wearChatLatestAnchorIndex(0, hasStreaming = false, canAbort = false), ) assertEquals( - 5, - wearChatLatestAnchorIndex(1, hasStreaming = false, canAbort = false, hasAssistant = false, hasFailure = false), + 3, + wearChatLatestAnchorIndex(1, hasStreaming = false, canAbort = false), ) assertEquals( - 10, - wearChatLatestAnchorIndex(2, hasStreaming = true, canAbort = true, hasAssistant = true, hasFailure = true), + 6, + wearChatLatestAnchorIndex(2, hasStreaming = true, canAbort = true), ) } + @Test + fun nestedContextPickerCloseReturnsToSessionPicker() { + assertEquals(WearContextPicker.Session, wearContextPickerAfterClose(WearContextPicker.Agent)) + assertEquals(WearContextPicker.Session, wearContextPickerAfterClose(WearContextPicker.Model)) + assertNull(wearContextPickerAfterClose(WearContextPicker.Session)) + } + @Test fun threadFollowTargetsTrailingAnchorAfterLatestContent() { assertEquals(-1, wearThreadLatestAnchorIndex(entryCount = 0, thinking = false)) @@ -254,17 +261,72 @@ class MainActivityTest { hasActiveRun = false, phoneNodeId = "phone-1", ) + val phoneSession = + WearSession( + key = "session-2", + title = "Phone session", + updatedAt = 7, + hasActiveRun = false, + phoneNodeId = "phone-1", + ) val snapshot = WearUiState( connected = true, phoneNodeId = "phone-1", - sessions = listOf(session), + phoneActiveSessionKey = phoneSession.key, + sessions = listOf(session, phoneSession), selectedSession = session, failure = WearConversationFailure.ACTION_REJECTED, ).toConversationSnapshot() assertEquals(WearConversationFailure.ACTION_REJECTED, snapshot?.failure) - assertNull(snapshot?.sessions?.single()?.title) + assertNull(snapshot?.sessions?.first()?.title) + assertFalse(snapshot?.sessions?.first()?.activeOnPhone == true) + assertTrue(snapshot?.sessions?.first()?.openOnWatch == true) + assertTrue(snapshot?.sessions?.last()?.activeOnPhone == true) + assertFalse(snapshot?.sessions?.last()?.openOnWatch == true) + assertEquals(phoneSession.key, snapshot?.phoneActiveSessionId) + } + + @Test + fun modelSearchResultsRemainSelectableOutsideTheCompactModelWindow() { + val state = + WearUiState( + models = listOf(WearModel(ref = "openai/gpt-a", name = "GPT A")), + modelSearchResults = + listOf(WearModel(ref = "anthropic/claude", name = "Claude")), + ) + + assertTrue(state.containsModelRef("openai/gpt-a")) + assertTrue(state.containsModelRef("anthropic/claude")) + assertFalse(state.containsModelRef("google/gemini")) + } + + @Test + fun pickerSearchVisibilityFollowsNegotiatedCapabilities() { + val legacySnapshot = + WearUiState( + phoneNodeId = "phone-1", + proxyCapabilities = + setOf( + WearProxyCapability.ModelControls, + WearProxyCapability.SessionSelectionLookup, + ), + ).toConversationSnapshot() + val currentSnapshot = + WearUiState( + phoneNodeId = "phone-1", + proxyCapabilities = + setOf( + WearProxyCapability.ModelCatalogSearch, + WearProxyCapability.SessionSearchPagination, + ), + ).toConversationSnapshot() + + assertFalse(legacySnapshot?.modelSearchSupported == true) + assertFalse(legacySnapshot?.sessionSearchSupported == true) + assertTrue(currentSnapshot?.modelSearchSupported == true) + assertTrue(currentSnapshot?.sessionSearchSupported == true) } @Test diff --git a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt index f7984b39f4b3..764734d1bbca 100644 --- a/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt +++ b/apps/android/wear/src/test/java/ai/openclaw/wear/WearGatewayRepositoryTest.kt @@ -157,7 +157,7 @@ class WearGatewayRepositoryTest { when (method) { WearRpcMethod.SessionsList -> json.parseToJsonElement( - """{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true}""", + """{"sessions":[{"key":"agent:main","agentId":"main","displayName":"Main","updatedAt":7,"hasActiveRun":true,"modelRef":"openai/gpt-test"}],"activeAgentId":"main","selectedSessionValid":true,"hasMore":true,"nextOffset":35}""", ) WearRpcMethod.ChatHistory -> json.parseToJsonElement( @@ -171,7 +171,13 @@ class WearGatewayRepositoryTest { val sessions = repository.sessions( selectedSessionKey = "agent:main", - capabilities = setOf(WearProxyCapability.SessionSelectionLookup), + offset = 5, + search = "older", + capabilities = + setOf( + WearProxyCapability.SessionSelectionLookup, + WearProxyCapability.SessionSearchPagination, + ), ) val history = repository.history("agent:main", sessions.phoneNodeId) @@ -189,7 +195,9 @@ class WearGatewayRepositoryTest { assertEquals("working", history.activeText) assertEquals("openai/gpt-test", history.selectedModelRef) assertEquals(7L, history.eventSequence) - assertEquals(setOf("limit", "selectedSessionKey"), requester.calls[0].second.keys) + assertTrue(sessions.hasMore) + assertEquals(35, sessions.nextOffset) + assertEquals(setOf("limit", "offset", "search", "selectedSessionKey"), requester.calls[0].second.keys) assertEquals(setOf("sessionKey", "limit", "maxChars"), requester.calls[1].second.keys) } @@ -207,7 +215,7 @@ class WearGatewayRepositoryTest { WearRpcMethod.AgentsSelect -> JsonObject(emptyMap()) WearRpcMethod.GatewayDisconnect -> json.parseToJsonElement( - """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""", + """{"connected":false,"status":"Offline","activeAgentId":"main","selectedModelRef":"openai/gpt-test","capabilities":["agent-controls","gateway-controls","model-controls","model-catalog-search","session-selection-lookup","session-search-pagination","agent-pulse","attempt-scoped-realtime-audio"]}""", ) else -> error("unexpected $method") } @@ -275,7 +283,7 @@ class WearGatewayRepositoryTest { val requester = RecordingRequester { _, _ -> json.parseToJsonElement( - """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","session-selection-lookup","agent-pulse","attempt-scoped-realtime-audio"]}""", + """{"connected":true,"status":"Connected","capabilities":["agent-controls","future-capability","gateway-controls","model-controls","model-catalog-search","session-selection-lookup","session-search-pagination","agent-pulse","attempt-scoped-realtime-audio"]}""", ) } @@ -287,12 +295,14 @@ class WearGatewayRepositoryTest { @Test fun modelSelectionKeepsTheSelectedSessionAndUsesThePreferredPhone() = runTest { - val capabilities = setOf(WearProxyCapability.ModelControls) + val capabilities = + setOf(WearProxyCapability.ModelControls, WearProxyCapability.ModelCatalogSearch) val requester = RecordingRequester { method, params -> when (method) { WearRpcMethod.ModelsList -> { assertEquals("openai/gpt-a", params.getValue("selectedModelRef").jsonPrimitive.content) + assertEquals("anthropic", params.getValue("query").jsonPrimitive.content) json.parseToJsonElement( """{"models":[{"ref":"openai/gpt-a","name":"GPT A"},{"ref":"openai/gpt-b","name":"GPT B"}]}""", ) @@ -309,7 +319,13 @@ class WearGatewayRepositoryTest { } val repository = WearGatewayRepository(requester) - val models = repository.models("phone-a", capabilities, selectedModelRef = "openai/gpt-a") + val models = + repository.models( + "phone-a", + capabilities, + selectedModelRef = "openai/gpt-a", + query = "anthropic", + ) val selected = repository.selectModel( sessionKey = "agent:main:thread-7", @@ -327,6 +343,40 @@ class WearGatewayRepositoryTest { assertTrue(requester.requirePreferredNodes.all { it }) } + @Test + fun oldPhoneCapabilitiesDoNotReceivePickerSearchFields() = + runTest { + val requester = + RecordingRequester { method, params -> + when (method) { + WearRpcMethod.ModelsList -> { + assertEquals(setOf("selectedModelRef"), params.keys) + json.parseToJsonElement("""{"models":[]}""") + } + WearRpcMethod.SessionsList -> { + assertEquals(setOf("limit", "selectedSessionKey"), params.keys) + json.parseToJsonElement("""{"sessions":[]}""") + } + else -> error("unexpected $method") + } + } + val repository = WearGatewayRepository(requester) + + repository.models( + expectedNodeId = "phone-a", + capabilities = setOf(WearProxyCapability.ModelControls), + selectedModelRef = "openai/gpt-a", + query = "anthropic", + ) + repository.sessions( + expectedNodeId = "phone-a", + selectedSessionKey = "agent:main", + capabilities = setOf(WearProxyCapability.SessionSelectionLookup), + offset = 50, + search = "older", + ) + } + @Test fun chatEventPreservesReplaceAndTextOnlyMessage() { val event = diff --git a/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift b/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift index fc476e4f0780..1362a9e901a3 100644 --- a/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/ConnectionModeCoordinator.swift @@ -5,67 +5,53 @@ import OSLog final class ConnectionModeCoordinator { static let shared = ConnectionModeCoordinator() + struct Transition { + private(set) var generation: UInt64 = 0 + private(set) var mode: AppState.ConnectionMode? + + mutating func begin(_ mode: AppState.ConnectionMode) -> UInt64 { + self.generation &+= 1 + self.mode = mode + return self.generation + } + + func isCurrent(_ generation: UInt64, mode: AppState.ConnectionMode) -> Bool { + self.generation == generation && self.mode == mode + } + } + private let logger = Logger(subsystem: "ai.openclaw", category: "connection") - private var lastMode: AppState.ConnectionMode? - private var applyGeneration: UInt64 = 0 + private var transition = Transition() + private var portSweepTask: Task? /// Apply the requested connection mode by starting/stopping local gateway, /// managing the control-channel SSH tunnel, and cleaning up chat windows/panels. func apply(mode: AppState.ConnectionMode, paused: Bool) async { - self.applyGeneration &+= 1 - let applyGeneration = self.applyGeneration - if let lastMode = self.lastMode, lastMode != mode { + self.portSweepTask?.cancel() + let previousMode = self.transition.mode + let applyGeneration = self.transition.begin(mode) + if let previousMode, previousMode != mode { GatewayProcessManager.shared.clearLastFailure() NodesStore.shared.lastError = nil } - self.lastMode = mode + if mode != .remote { + _ = await NodeServiceManager.stop() + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } + NodesStore.shared.lastError = nil + await RemoteTunnelManager.shared.stopAll() + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } + WebChatManager.shared.resetTunnels() + } + switch mode { case .unconfigured: - _ = await NodeServiceManager.stop() - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - WebChatManager.shared.resetTunnels() GatewayProcessManager.shared.stop() - await GatewayConnection.shared.shutdown() await ControlChannel.shared.disconnect() - Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) } + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } case .local: - _ = await NodeServiceManager.stop() - guard self.applyGeneration == applyGeneration else { return } - NodesStore.shared.lastError = nil - await RemoteTunnelManager.shared.stopAll() - guard self.applyGeneration == applyGeneration else { return } - WebChatManager.shared.resetTunnels() - let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) - if shouldStart { - GatewayProcessManager.shared.setActive(true) - await GatewayProcessManager.shared.waitForStartupAttempt() - guard self.applyGeneration == applyGeneration else { return } - var launchAgentInstalled = false - if GatewayAutostartPolicy.shouldEnsureLaunchAgent( - mode: .local, - paused: paused) - { - launchAgentInstalled = await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() - } - guard self.applyGeneration == applyGeneration else { return } - // Always finish the generation-aware health audit after persistence work. A newer - // inactive lifecycle makes this return false without touching its repair marker. - _ = await GatewayProcessManager.shared.waitForGatewayReady( - launchAgentInstalled: launchAgentInstalled) - guard self.applyGeneration == applyGeneration else { return } - } else { - GatewayProcessManager.shared.stop() - } - do { - try await ControlChannel.shared.configure(mode: .local) - } catch { - // Control channel will mark itself degraded; nothing else to do here. - self.logger.error( - "control channel local configure failed: \(error.localizedDescription, privacy: .public)") - } - Task.detached { await PortGuardian.shared.sweep(mode: .local) } + await self.applyLocalMode(paused: paused, generation: applyGeneration) + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } case .remote: // Never run a local gateway in remote mode. @@ -74,19 +60,51 @@ final class ConnectionModeCoordinator { do { NodesStore.shared.lastError = nil - if let error = await NodeServiceManager.start() { + let nodeError = await NodeServiceManager.start() + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } + if let error = nodeError { NodesStore.shared.lastError = "Node service start failed: \(error)" } _ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel() + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } let settings = CommandResolver.connectionSettings() try await ControlChannel.shared.configure(mode: .remote( target: settings.target, identity: settings.identity)) + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } } catch { + guard self.transition.isCurrent(applyGeneration, mode: mode) else { return } self.logger.error("remote tunnel/configure failed: \(error.localizedDescription, privacy: .public)") } + } - Task.detached { await PortGuardian.shared.sweep(mode: .remote) } + self.portSweepTask = Task { await PortGuardian.shared.sweep(mode: mode) } + } + + private func applyLocalMode(paused: Bool, generation: UInt64) async { + if GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused) { + GatewayProcessManager.shared.setActive(true) + await GatewayProcessManager.shared.waitForStartupAttempt() + guard self.transition.isCurrent(generation, mode: .local) else { return } + var launchAgentInstalled = false + if GatewayAutostartPolicy.shouldEnsureLaunchAgent(mode: .local, paused: paused) { + launchAgentInstalled = await GatewayProcessManager.shared.ensureLaunchAgentEnabledIfNeeded() + } + guard self.transition.isCurrent(generation, mode: .local) else { return } + // Finish persistence before readiness so a newer lifecycle cannot clear its repair marker. + _ = await GatewayProcessManager.shared.waitForGatewayReady( + launchAgentInstalled: launchAgentInstalled) + guard self.transition.isCurrent(generation, mode: .local) else { return } + } else { + GatewayProcessManager.shared.stop() + } + + do { + try await ControlChannel.shared.configure(mode: .local) + } catch { + guard self.transition.isCurrent(generation, mode: .local) else { return } + self.logger.error( + "control channel local configure failed: \(error.localizedDescription, privacy: .public)") } } } diff --git a/apps/macos/Sources/OpenClaw/ControlChannel.swift b/apps/macos/Sources/OpenClaw/ControlChannel.swift index 422d69a59096..4af96725f643 100644 --- a/apps/macos/Sources/OpenClaw/ControlChannel.swift +++ b/apps/macos/Sources/OpenClaw/ControlChannel.swift @@ -217,10 +217,8 @@ final class ControlChannel { } func disconnect() async { - await GatewayConnection.shared.shutdown() self.setStateThrottled(.disconnected) - self.lastPingMs = nil - self.authSourceLabel = nil + await GatewayConnection.shared.shutdown() } func health(timeout: TimeInterval? = nil) async throws -> Data { diff --git a/apps/macos/Sources/OpenClaw/CronJobsStore.swift b/apps/macos/Sources/OpenClaw/CronJobsStore.swift index f077b955fa2f..fa83cfdffa1a 100644 --- a/apps/macos/Sources/OpenClaw/CronJobsStore.swift +++ b/apps/macos/Sources/OpenClaw/CronJobsStore.swift @@ -27,19 +27,24 @@ final class CronJobsStore { private var runsTask: Task? private var eventTask: Task? private var pollTask: Task? + private var runsGeneration: UInt64 = 0 + private let gateway: GatewayConnection private let interval: TimeInterval = 30 private let isPreview: Bool - init(isPreview: Bool = ProcessInfo.processInfo.isPreview) { + init(gateway: GatewayConnection = .shared, isPreview: Bool = ProcessInfo.processInfo.isPreview) { + self.gateway = gateway self.isPreview = isPreview } func start() { - guard !self.isPreview else { return } - guard self.eventTask == nil else { return } - GatewayPushSubscription.restartTask(task: &self.eventTask) { [weak self] push in - self?.handle(push: push) + guard !self.isPreview, self.eventTask == nil else { return } + self.eventTask = Task { [weak self, gateway] in + for await push in await gateway.subscribe() { + guard !Task.isCancelled, let self else { return } + self.handle(push: push) + } } SimpleTaskSupport.startDetachedLoop(task: &self.pollTask, interval: self.interval) { [weak self] in await self?.refreshJobs() @@ -47,14 +52,10 @@ final class CronJobsStore { } func stop() { - self.refreshTask?.cancel() - self.refreshTask = nil - self.runsTask?.cancel() - self.runsTask = nil - self.eventTask?.cancel() - self.eventTask = nil - self.pollTask?.cancel() - self.pollTask = nil + SimpleTaskSupport.stop(task: &self.refreshTask) + self.invalidateRuns() + SimpleTaskSupport.stop(task: &self.eventTask) + SimpleTaskSupport.stop(task: &self.pollTask) } func refreshJobs() async { @@ -65,12 +66,17 @@ final class CronJobsStore { defer { self.isLoadingJobs = false } do { - if let status = try? await GatewayConnection.shared.cronStatus() { + if let status = try? await self.gateway.cronStatus() { self.schedulerEnabled = status.enabled self.schedulerStorePath = status.sqlitePath ?? status.storePath self.schedulerNextWakeAtMs = status.nextWakeAtMs } - self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true) + self.jobs = try await self.gateway.cronList(includeDisabled: true) + if let selectedJobId = self.selectedJobId, + !self.jobs.contains(where: { $0.id == selectedJobId }) + { + self.clearSelectedJob() + } if self.jobs.isEmpty { self.statusMessage = "No cron jobs yet." } @@ -80,22 +86,40 @@ final class CronJobsStore { } } - func refreshRuns(jobId: String, limit: Int = 200) async { - guard !self.isLoadingRuns else { return } - self.isLoadingRuns = true - defer { self.isLoadingRuns = false } + func selectJob(_ id: String) { + if self.selectedJobId != id { + self.selectedJobId = id + self.runEntries = [] + } + self.refreshRuns(jobId: id) + } - do { - self.runEntries = try await GatewayConnection.shared.cronRuns(jobId: jobId, limit: limit) - } catch { - self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") - self.lastError = error.localizedDescription + func refreshRuns(jobId: String, limit: Int = 200, delay: TimeInterval = 0) { + guard self.selectedJobId == jobId else { return } + // Claim before scheduling so late completions cannot own a newer selection. + self.runsGeneration &+= 1 + let generation = self.runsGeneration + self.isLoadingRuns = true + self.lastError = nil + SimpleTaskSupport.schedule(task: &self.runsTask, delay: delay) { [weak self] in + guard let self, self.ownsRunsRequest(generation, jobId: jobId) else { return } + do { + let entries = try await self.gateway.cronRuns(jobId: jobId, limit: limit) + guard self.ownsRunsRequest(generation, jobId: jobId) else { return } + self.runEntries = entries + } catch { + guard self.ownsRunsRequest(generation, jobId: jobId) else { return } + self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)") + self.lastError = error.localizedDescription + } + self.isLoadingRuns = false + self.runsTask = nil } } func runJob(id: String, force: Bool = true) async { do { - try await GatewayConnection.shared.cronRun(jobId: id, force: force) + try await self.gateway.cronRun(jobId: id, force: force) } catch { self.lastError = error.localizedDescription } @@ -103,12 +127,11 @@ final class CronJobsStore { func removeJob(id: String) async { do { - try await GatewayConnection.shared.cronRemove(jobId: id) - await self.refreshJobs() + try await self.gateway.cronRemove(jobId: id) if self.selectedJobId == id { - self.selectedJobId = nil - self.runEntries = [] + self.clearSelectedJob() } + await self.refreshJobs() } catch { self.lastError = error.localizedDescription } @@ -116,7 +139,7 @@ final class CronJobsStore { func setJobEnabled(id: String, enabled: Bool) async { do { - try await GatewayConnection.shared.cronUpdate( + try await self.gateway.cronUpdate( jobId: id, patch: ["enabled": AnyCodable(enabled)]) await self.refreshJobs() @@ -130,9 +153,9 @@ final class CronJobsStore { payload: [String: AnyCodable]) async throws { if let id { - try await GatewayConnection.shared.cronUpdate(jobId: id, patch: payload) + try await self.gateway.cronUpdate(jobId: id, patch: payload) } else { - try await GatewayConnection.shared.cronAdd(payload: payload) + try await self.gateway.cronAdd(payload: payload) } await self.refreshJobs() } @@ -157,7 +180,7 @@ final class CronJobsStore { // Keep UI in sync with the gateway scheduler. self.scheduleRefresh(delayMs: 250) if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId { - self.scheduleRunsRefresh(jobId: selected, delayMs: 200) + self.refreshRuns(jobId: selected, delay: 0.2) } } @@ -167,11 +190,19 @@ final class CronJobsStore { } } - private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) { - SimpleTaskSupport.schedule(task: &self.runsTask, delay: TimeInterval(delayMs) / 1000) { [weak self] in - await self?.refreshRuns(jobId: jobId) - } + private func clearSelectedJob() { + self.invalidateRuns() + self.selectedJobId = nil + self.runEntries = [] } - // MARK: - (no additional RPC helpers) + private func ownsRunsRequest(_ generation: UInt64, jobId: String) -> Bool { + self.runsGeneration == generation && self.selectedJobId == jobId && !Task.isCancelled + } + + private func invalidateRuns() { + self.runsGeneration &+= 1 + SimpleTaskSupport.stop(task: &self.runsTask) + self.isLoadingRuns = false + } } diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift b/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift index e152ad55f8e7..b27742ef36d5 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Layout.swift @@ -150,7 +150,7 @@ extension CronSettings { LazyVStack(alignment: .leading, spacing: 4) { ForEach(self.store.jobs) { job in Button { - self.selectJob(job.id) + self.store.selectJob(job.id) } label: { self.jobRow(job) .frame(maxWidth: .infinity, alignment: .leading) @@ -184,11 +184,6 @@ extension CronSettings { } } - private func selectJob(_ id: String) { - self.store.selectedJobId = id - Task { await self.store.refreshRuns(jobId: id) } - } - @ViewBuilder var detail: some View { if let selected = self.selectedJob { diff --git a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift index 5a3c4188cf4e..bd00c3323201 100644 --- a/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift +++ b/apps/macos/Sources/OpenClaw/CronSettings+Rows.swift @@ -149,7 +149,7 @@ extension CronSettings { .font(.headline) Spacer() Button { - Task { await self.store.refreshRuns(jobId: job.id) } + self.store.refreshRuns(jobId: job.id) } label: { Label("Refresh", systemImage: "arrow.clockwise") } diff --git a/apps/macos/Sources/OpenClaw/DebugActions.swift b/apps/macos/Sources/OpenClaw/DebugActions.swift index 18c3c796b7e2..8d0ffab346c8 100644 --- a/apps/macos/Sources/OpenClaw/DebugActions.swift +++ b/apps/macos/Sources/OpenClaw/DebugActions.swift @@ -108,7 +108,6 @@ enum DebugActions { } case .unconfigured: - await GatewayConnection.shared.shutdown() await ControlChannel.shared.disconnect() } } diff --git a/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift b/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift index 7cd160969389..50e5e18ff3f2 100644 --- a/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift +++ b/apps/macos/Sources/OpenClaw/FileHandle+SafeRead.swift @@ -1,6 +1,15 @@ +import Darwin import Foundation extension FileHandle { + /// Marks a pipe/socket write end so a vanished reader fails the write with a + /// thrown EPIPE instead of raising SIGPIPE, which kills the whole process. + /// Required on every write end whose reader is another process that can exit. + @discardableResult + func disableSIGPIPE() -> Bool { + fcntl(self.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 + } + /// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure. /// /// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and diff --git a/apps/macos/Sources/OpenClaw/GatewayConnection.swift b/apps/macos/Sources/OpenClaw/GatewayConnection.swift index d148aa0dfa6d..5b8f7a911c58 100644 --- a/apps/macos/Sources/OpenClaw/GatewayConnection.swift +++ b/apps/macos/Sources/OpenClaw/GatewayConnection.swift @@ -291,6 +291,7 @@ actor GatewayConnection { do { return try await client.request(method: method, params: params, timeoutMs: timeoutMs) } catch { + try Task.checkCancellation() if allowTLSRepair, let tlsError = error as? GatewayTLSValidationError, await GatewayTLSRepairCoordinator.shared.repair( diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index afef64db02c2..26c3bc7d142b 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -652,7 +652,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } Task { PresenceReporter.shared.start() } Task { await HealthStore.shared.refresh(onDemand: true) } - Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) } + Task { await PortGuardian.shared.reapOrphanedTunnels() } AppStateStore.shared.applyComputerControlHostState() if launchPlan.allowsAutomaticPresentation { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalogClient.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalogClient.swift index b1a557d36867..bd18a436471f 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalogClient.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeCodexThreadCatalogClient.swift @@ -62,6 +62,9 @@ final class CodexAppServerThreadClient: @unchecked Sendable { var requestData: Data? var continuation: CheckedContinuation? var timer: DispatchSourceTimer? + /// One requeue budget for the child-exit race: a failed stdin write was + /// never delivered, so a single retry on a fresh child cannot duplicate. + var redelivered = false init( token: UUID, @@ -105,6 +108,9 @@ final class CodexAppServerThreadClient: @unchecked Sendable { { self.invocation = invocation self.initializeRequestID = initializeRequestID + // The App Server child can exit between requests; without this an + // in-flight stdin write raises SIGPIPE and kills the app. + self.stdinPipe.fileHandleForWriting.disableSIGPIPE() } } @@ -351,9 +357,19 @@ final class CodexAppServerThreadClient: @unchecked Sendable { } try self.write(requestData, over: connection) } catch { - self.finishActive( - .failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable), - restartConnection: true) + // A warm connection can outlive its child; the exit race surfaces + // here as EPIPE before termination is observed. The frame was never + // delivered, so requeue once onto a fresh child instead of failing. + guard !active.redelivered else { + self.finishActive( + .failure(MacNodeCodexThreadCatalog.CatalogError.appServerUnavailable), + restartConnection: true) + return + } + active.redelivered = true + self.active = nil + self.pending.insert(active, at: 0) + self.stopConnection(abortive: true) } } diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift index b77bd312f6d1..7eb5997352d2 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift @@ -350,7 +350,7 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable { let stdinPipe = Pipe() let stdoutPipe = Pipe() let stderrPipe = Pipe() - guard fcntl(stdinPipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) != -1 else { + guard stdinPipe.fileHandleForWriting.disableSIGPIPE() else { self.finishStartLocked(.failure(WorkerError.unavailable("could not protect worker input pipe"))) return } diff --git a/apps/macos/Sources/OpenClaw/PortGuardian.swift b/apps/macos/Sources/OpenClaw/PortGuardian.swift index f0c3002df196..d2ca2c3f1d55 100644 --- a/apps/macos/Sources/OpenClaw/PortGuardian.swift +++ b/apps/macos/Sources/OpenClaw/PortGuardian.swift @@ -60,10 +60,12 @@ actor PortGuardian { } func sweep(mode: AppState.ConnectionMode) async { + guard !Task.isCancelled else { return } self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))") // Reap before the port scan and in every mode: orphans come from earlier // remote sessions and must die even after the user switched modes. await self.reapOrphanedTunnels() + guard !Task.isCancelled else { return } guard mode != .unconfigured else { self.logger.info("port sweep skipped (mode=unconfigured)") return @@ -72,9 +74,11 @@ actor PortGuardian { // Capture the listener before launchd status. If its process exits and the // PID is reused, the newer status snapshot cannot bless the replacement. let listeners = await self.listeners(on: port) + guard !Task.isCancelled else { return } let managedGatewayPID = mode == .local ? await GatewayLaunchAgentManager.runningGatewayPID() : nil + guard !Task.isCancelled else { return } for listener in listeners { if Self.isExpected( listener, @@ -103,6 +107,7 @@ actor PortGuardian { "(pid \(listener.pid, privacy: .public)); preserving conflict") continue } + guard !Task.isCancelled else { return } if await Self.terminateProcess(listener.pid) { let message = """ port \(port) was held by \(listener.command) @@ -330,9 +335,10 @@ actor PortGuardian { /// forget a still-running tunnel (the record is its only retry path). private static func terminateProcess(_ pid: Int32) async -> Bool { #if canImport(Darwin) - guard pid > 0 else { return false } + guard !Task.isCancelled, pid > 0 else { return false } _ = Darwin.kill(pid, SIGTERM) if await self.waitForProcessExit(pid: pid) { return true } + guard !Task.isCancelled else { return false } _ = Darwin.kill(pid, SIGKILL) return await self.waitForProcessExit(pid: pid) #else @@ -343,7 +349,7 @@ actor PortGuardian { private static func waitForProcessExit(pid: Int32, timeout: TimeInterval = 1.0) async -> Bool { let deadline = Date().addingTimeInterval(timeout) while self.tunnelProcessInfo(pid: pid) != nil { - guard Date() < deadline else { return false } + guard !Task.isCancelled, Date() < deadline else { return false } try? await Task.sleep(nanoseconds: 50_000_000) } return true diff --git a/apps/macos/Sources/OpenClaw/TalkMLXSpeechSynthesizer.swift b/apps/macos/Sources/OpenClaw/TalkMLXSpeechSynthesizer.swift index 4b0887b78198..9068691dd369 100644 --- a/apps/macos/Sources/OpenClaw/TalkMLXSpeechSynthesizer.swift +++ b/apps/macos/Sources/OpenClaw/TalkMLXSpeechSynthesizer.swift @@ -642,6 +642,9 @@ private actor ProcessMLXTTSTransport: MLXTTSTransport { { let inputPipe = Pipe() let outputPipe = Pipe() + // The helper child can exit at any time; without this a racing + // send() to its stdin raises SIGPIPE and kills the app. + inputPipe.fileHandleForWriting.disableSIGPIPE() let output = outputPipe.fileHandleForReading let (stream, continuation) = AsyncStream.makeStream() diff --git a/apps/macos/Tests/OpenClawIPCTests/ConnectionModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ConnectionModeCoordinatorTests.swift new file mode 100644 index 000000000000..1127b713653e --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/ConnectionModeCoordinatorTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import OpenClaw + +@MainActor +struct ConnectionModeCoordinatorTests { + @Test(arguments: [ + (AppState.ConnectionMode.unconfigured, AppState.ConnectionMode.local), + (AppState.ConnectionMode.remote, AppState.ConnectionMode.local), + (AppState.ConnectionMode.local, AppState.ConnectionMode.remote), + ]) + func `newer connection mode owns transition side effects`( + previousMode: AppState.ConnectionMode, + nextMode: AppState.ConnectionMode) + { + var transition = ConnectionModeCoordinator.Transition() + let previousGeneration = transition.begin(previousMode) + let currentGeneration = transition.begin(nextMode) + + #expect(!transition.isCurrent(previousGeneration, mode: previousMode)) + #expect(transition.isCurrent(currentGeneration, mode: nextMode)) + } + + @Test func `reselecting the same mode invalidates its prior transition`() { + var transition = ConnectionModeCoordinator.Transition() + let previousGeneration = transition.begin(.remote) + let currentGeneration = transition.begin(.remote) + + #expect(!transition.isCurrent(previousGeneration, mode: .remote)) + #expect(transition.isCurrent(currentGeneration, mode: .remote)) + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/CronJobsStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/CronJobsStoreTests.swift new file mode 100644 index 000000000000..c5f5f13a35d8 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/CronJobsStoreTests.swift @@ -0,0 +1,565 @@ +import Foundation +import Testing +@testable import OpenClaw +@testable import OpenClawKit + +private struct CronGatewayRequest: Sendable { + let id: String + let method: String + let jobId: String? +} + +private actor CronGatewayRequestLog { + private var requests: [CronGatewayRequest] = [] + private var endpointLookups = 0 + private var availableJobs = ["job-a", "job-b"] + private var nextEventSequence = 0 + + func append(_ request: CronGatewayRequest) { + self.requests.append(request) + } + + func lookupEndpoint() { + self.endpointLookups += 1 + } + + func endpointLookupCount() -> Int { + self.endpointLookups + } + + func request(jobId: String, occurrence: Int = 0) -> CronGatewayRequest? { + let matches = self.requests.filter { $0.method == "cron.runs" && $0.jobId == jobId } + guard matches.indices.contains(occurrence) else { return nil } + return matches[occurrence] + } + + func requestCount(method: String, jobId: String? = nil) -> Int { + self.requests.count { $0.method == method && (jobId == nil || $0.jobId == jobId) } + } + + func removeJob(_ jobId: String) { + self.availableJobs.removeAll { $0 == jobId } + } + + func jobsResponse() -> String { + let jobs = self.availableJobs.map { jobId in + #"{"id":"\#(jobId)","name":"\#(jobId)","enabled":true,"createdAtMs":0,"updatedAtMs":0,"# + + #""schedule":{"kind":"every","everyMs":1000},"sessionTarget":"isolated","wakeMode":"now","# + + #""payload":{"kind":"systemEvent","text":"test"},"state":{}}"# + }.joined(separator: ",") + return #"{"jobs":[\#(jobs)]}"# + } + + func eventSequence() -> Int { + self.nextEventSequence += 1 + return self.nextEventSequence + } +} + +private final class CronGatewayFixture: @unchecked Sendable { + let requests: CronGatewayRequestLog + let session: GatewayTestWebSocketSession + let gateway: GatewayConnection + + init(recoveryEligible: Bool = false, initialRunsFailure: (any Error & Sendable)? = nil) { + let requests = CronGatewayRequestLog() + self.requests = requests + self.session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { socket, message, sendIndex in + guard sendIndex > 0, + let request = Self.decodeRequest(message) + else { return } + await requests.append(request) + guard request.method != "cron.runs" else { + if let initialRunsFailure, + await requests.requestCount(method: "cron.runs") == 1 + { + throw initialRunsFailure + } + return + } + let payload: String + switch request.method { + case "cron.status": + payload = #"{"enabled":true,"storePath":"/tmp/cron-tests","jobs":2}"# + case "cron.list": + payload = await requests.jobsResponse() + case "cron.remove": + if let jobId = request.jobId { + await requests.removeJob(jobId) + } + payload = #"{"ok":true}"# + default: + payload = #"{"ok":true}"# + } + socket.emitReceiveSuccess(.data(Data( + #"{"type":"res","id":"\#(request.id)","ok":true,"payload":\#(payload)}"#.utf8))) + }) + }) + if recoveryEligible { + self.gateway = GatewayConnection( + endpointProvider: { + await requests.lookupEndpoint() + return GatewayConnection.EndpointSnapshot( + config: (url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil), + routeAuthority: nil) + }, + supportsSharedEndpointRecovery: true, + activationBindingKeyProvider: { nil }, + sessionBox: WebSocketSessionBox(session: self.session)) + } else { + self.gateway = GatewayConnection( + configProvider: { + await requests.lookupEndpoint() + return (url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil) + }, + sessionBox: WebSocketSessionBox(session: self.session)) + } + } + + private static func decodeRequest(_ message: URLSessionWebSocketTask.Message) -> CronGatewayRequest? { + let data: Data? = switch message { + case let .data(data): data + case let .string(value): value.data(using: .utf8) + @unknown default: nil + } + guard let data, + let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let id = frame["id"] as? String, + let method = frame["method"] as? String + else { return nil } + let parameters = frame["params"] as? [String: Any] + return CronGatewayRequest(id: id, method: method, jobId: parameters?["id"] as? String) + } + + func waitForRequest( + jobId: String, + occurrence: Int = 0, + timeout: Duration = .seconds(2)) async -> CronGatewayRequest? + { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if let request = await self.requests.request(jobId: jobId, occurrence: occurrence) { + return request + } + try? await Task.sleep(for: .milliseconds(2)) + } + return await self.requests.request(jobId: jobId, occurrence: occurrence) + } + + func respond( + to request: CronGatewayRequest, + jobId: String, + summary: String = "completed") async throws + { + let socket = try await self.readySocket() + let response = #"{"type":"res","id":"\#(request.id)","ok":true,"payload":{"entries":["# + + #"{"ts":1700000000000,"jobId":"\#(jobId)","action":"finished","# + + #""status":"ok","summary":"\#(summary)"}]}}"# + socket.emitReceiveSuccessOnce(.data(Data(response.utf8))) + } + + func fail(_ request: CronGatewayRequest, message: String) async throws { + let socket = try await self.readySocket() + let response = #"{"type":"res","id":"\#(request.id)","ok":false,"# + + #""error":{"code":"INVALID_REQUEST","message":"\#(message)"}}"# + socket.emitReceiveSuccessOnce(.data(Data(response.utf8))) + } + + func sendFinishedEvent(jobId: String) async throws { + let socket = try await self.readySocket() + let sequence = await self.requests.eventSequence() + let event = #"{"type":"event","event":"cron","seq":\#(sequence),"# + + #""payload":{"jobId":"\#(jobId)","action":"finished"}}"# + socket.emitReceiveSuccessOnce(.data(Data(event.utf8))) + } + + private func readySocket() async throws -> GatewayTestWebSocketTask { + let deadline = ContinuousClock.now + .seconds(2) + while ContinuousClock.now < deadline { + if let socket = self.session.latestTask(), socket.hasPendingReceiveHandler() { + return socket + } + try? await Task.sleep(for: .milliseconds(2)) + } + return try #require(self.session.latestTask()) + } +} + +@Suite(.serialized) +@MainActor +struct CronJobsStoreTests { + @Test func `selecting another job sends its history request while the previous request is pending`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let firstRequest = try #require(await fixture.waitForRequest(jobId: "job-a")) + store.runEntries = [self.entry(jobId: "job-a", summary: "old A")] + store.lastError = "old A error" + + store.selectJob("job-b") + + #expect(store.selectedJobId == "job-b") + #expect(store.runEntries.isEmpty) + #expect(store.lastError == nil) + #expect(store.isLoadingRuns) + let secondRequest = try #require(await fixture.waitForRequest(jobId: "job-b")) + try await fixture.respond(to: secondRequest, jobId: "job-b", summary: "current B") + try #require(await self.waitUntil { !store.isLoadingRuns }) + #expect(store.runEntries.map(\.jobId) == ["job-b"]) + #expect(store.runEntries.first?.summary == "current B") + + try await fixture.respond(to: firstRequest, jobId: "job-a", summary: "stale A") + await Task.yield() + + #expect(store.runEntries.map(\.jobId) == ["job-b"]) + #expect(store.runEntries.first?.summary == "current B") + #expect(!store.isLoadingRuns) + #expect(store.lastError == nil) + #expect(fixture.session.snapshotMakeCount() == 1) + #expect(fixture.session.snapshotCancelCount() == 0) + #expect(await fixture.requests.endpointLookupCount() == 2) + } + + @Test func `late failure from a superseded job preserves the selected jobs own failure`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let firstRequest = try #require(await fixture.waitForRequest(jobId: "job-a")) + store.runEntries = [self.entry(jobId: "job-a", summary: "old A")] + + store.selectJob("job-b") + #expect(store.runEntries.isEmpty) + let selectedRequest = try #require(await fixture.waitForRequest(jobId: "job-b")) + try await fixture.fail(selectedRequest, message: "selected job B failed") + try #require(await self.waitUntil { !store.isLoadingRuns }) + let selectedError = try #require(store.lastError) + #expect(selectedError.contains("selected job B failed")) + #expect(store.runEntries.isEmpty) + + try await fixture.fail(firstRequest, message: "stale job A failed") + await Task.yield() + + #expect(store.selectedJobId == "job-b") + #expect(store.runEntries.isEmpty) + #expect(store.lastError == selectedError) + #expect(!store.isLoadingRuns) + } + + @Test + func `manual refresh replaces the selected jobs pending request without accepting stale success`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let originalRequest = try #require(await fixture.waitForRequest(jobId: "job-a")) + + store.refreshRuns(jobId: "job-a") + + let replacement = try #require(await fixture.waitForRequest(jobId: "job-a", occurrence: 1)) + #expect(store.isLoadingRuns) + try await fixture.fail(replacement, message: "manual refresh failed") + try #require(await self.waitUntil { !store.isLoadingRuns }) + let replacementError = try #require(store.lastError) + + try await fixture.respond(to: originalRequest, jobId: "job-a", summary: "stale manual result") + await Task.yield() + + #expect(store.runEntries.isEmpty) + #expect(store.lastError == replacementError) + #expect(!store.isLoadingRuns) + #expect(fixture.session.snapshotMakeCount() == 1) + #expect(fixture.session.snapshotCancelCount() == 0) + } + + @Test func `manual refresh after failure clears the old error and publishes the successful retry`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let failedRequest = try #require(await fixture.waitForRequest(jobId: "job-a")) + try await fixture.fail(failedRequest, message: "temporary history failure") + try #require(await self.waitUntil { store.lastError != nil }) + + store.refreshRuns(jobId: "job-a") + + #expect(store.lastError == nil) + #expect(store.isLoadingRuns) + let retry = try #require(await fixture.waitForRequest(jobId: "job-a", occurrence: 1)) + try await fixture.respond(to: retry, jobId: "job-a", summary: "recovered history") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + #expect(store.runEntries.map(\.jobId) == ["job-a"]) + #expect(store.runEntries.first?.summary == "recovered history") + #expect(store.lastError == nil) + } + + @Test func `finished events refresh only the job still selected after their debounce`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.start() + try #require(await self.waitUntil { store.jobs.count == 2 }) + store.selectJob("job-a") + let firstRequest = try #require(await fixture.waitForRequest(jobId: "job-a")) + try await fixture.respond(to: firstRequest, jobId: "job-a") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + try await fixture.sendFinishedEvent(jobId: "job-a") + try #require(await self.waitUntil { store.isLoadingRuns }) + store.selectJob("job-b") + let selectedRequest = try #require(await fixture.waitForRequest(jobId: "job-b")) + try await fixture.respond(to: selectedRequest, jobId: "job-b") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + #expect(await fixture.waitForRequest( + jobId: "job-a", + occurrence: 1, + timeout: .milliseconds(300)) == nil) + #expect(store.runEntries.map(\.jobId) == ["job-b"]) + + try await fixture.sendFinishedEvent(jobId: "job-b") + let eventRequest = try #require(await fixture.waitForRequest(jobId: "job-b", occurrence: 1)) + try await fixture.respond(to: eventRequest, jobId: "job-b", summary: "event B") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + #expect(store.runEntries.map(\.jobId) == ["job-b"]) + #expect(store.runEntries.first?.summary == "event B") + #expect(await fixture.requests.requestCount(method: "cron.runs", jobId: "job-a") == 1) + } + + @Test(arguments: ["selection", "manual", "event"], ["success", "failure"]) + func `stopping the pane rejects late completions from every history entry point`( + source: String, + outcome: String) async throws + { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + if source == "event" { + store.start() + try #require(await self.waitUntil { store.jobs.count == 2 }) + } + store.selectJob("job-a") + var pending = try #require(await fixture.waitForRequest(jobId: "job-a")) + if source != "selection" { + try await fixture.respond(to: pending, jobId: "job-a", summary: "existing history") + try #require(await self.waitUntil { !store.isLoadingRuns }) + if source == "manual" { + store.refreshRuns(jobId: "job-a") + } else { + try await fixture.sendFinishedEvent(jobId: "job-a") + } + pending = try #require(await fixture.waitForRequest(jobId: "job-a", occurrence: 1)) + } + let previousHistory = store.runEntries.map(\.summary) + let previousError = store.lastError + + store.stop() + + #expect(!store.isLoadingRuns) + if outcome == "success" { + try await fixture.respond(to: pending, jobId: "job-a", summary: "late history") + } else { + try await fixture.fail(pending, message: "late history failure") + } + await Task.yield() + + #expect(store.selectedJobId == "job-a") + #expect(store.runEntries.map(\.summary) == previousHistory) + #expect(store.lastError == previousError) + #expect(!store.isLoadingRuns) + #expect(fixture.session.snapshotCancelCount() == 0) + } + + @Test func `removing the selected job cancels its pending history before refreshing jobs`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let pending = try #require(await fixture.waitForRequest(jobId: "job-a")) + store.runEntries = [self.entry(jobId: "job-a", summary: "removed history")] + + await store.removeJob(id: "job-a") + + #expect(store.selectedJobId == nil) + #expect(store.runEntries.isEmpty) + #expect(!store.isLoadingRuns) + #expect(store.jobs.map(\.id) == ["job-b"]) + try await fixture.respond(to: pending, jobId: "job-a", summary: "late removed job") + await Task.yield() + + #expect(store.selectedJobId == nil) + #expect(store.runEntries.isEmpty) + #expect(store.lastError == nil) + } + + @Test + func `job list refresh invalidates pending history when another client removed its selected job`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + let pending = try #require(await fixture.waitForRequest(jobId: "job-a")) + store.runEntries = [self.entry(jobId: "job-a", summary: "old history")] + await fixture.requests.removeJob("job-a") + + await store.refreshJobs() + + #expect(store.selectedJobId == nil) + #expect(store.runEntries.isEmpty) + #expect(!store.isLoadingRuns) + #expect(store.jobs.map(\.id) == ["job-b"]) + try await fixture.fail(pending, message: "removed job completed late") + await Task.yield() + + #expect(store.runEntries.isEmpty) + #expect(store.lastError == nil) + } + + @Test func `superseded history never activates the local Gateway or its launch agent`() async throws { + try await self.withLocalGatewayRecovery { fixture in + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + store.selectJob("job-a") + _ = try #require(await fixture.waitForRequest(jobId: "job-a")) + + store.selectJob("job-b") + + let selectedRequest = try #require(await fixture.waitForRequest(jobId: "job-b")) + try await fixture.respond(to: selectedRequest, jobId: "job-b") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + #expect(store.runEntries.map(\.jobId) == ["job-b"]) + #expect(store.lastError == nil) + #expect(await fixture.requests.endpointLookupCount() == 2) + #expect(fixture.session.snapshotMakeCount() == 1) + #expect(fixture.session.snapshotCancelCount() == 0) + #expect(GatewayProcessManager.shared.status == .stopped) + #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty) + } + } + + @Test func `uncancelled history transport failures activate the Gateway and retry`() async throws { + try await self.withLocalGatewayRecovery(initialRunsFailure: URLError(.networkConnectionLost)) { fixture in + let store = CronJobsStore(gateway: fixture.gateway) + defer { store.stop() } + + store.selectJob("job-a") + + _ = try #require(await fixture.waitForRequest(jobId: "job-a")) + let recoveredRequest = try #require(await fixture.waitForRequest(jobId: "job-a", occurrence: 1)) + #expect(GatewayProcessManager.shared.status != .stopped) + try await fixture.respond(to: recoveredRequest, jobId: "job-a", summary: "recovered history") + try #require(await self.waitUntil { !store.isLoadingRuns }) + + #expect(store.runEntries.map(\.jobId) == ["job-a"]) + #expect(store.runEntries.first?.summary == "recovered history") + #expect(store.lastError == nil) + #expect(await fixture.requests.requestCount(method: "cron.runs", jobId: "job-a") == 2) + } + } + + @Test func `starting and stopping retains normal scheduler and job refresh behavior`() async throws { + let fixture = CronGatewayFixture() + let store = CronJobsStore(gateway: fixture.gateway) + + store.start() + try #require(await self.waitUntil { store.jobs.count == 2 }) + + #expect(store.schedulerEnabled == true) + #expect(store.schedulerStorePath == "/tmp/cron-tests") + #expect(store.jobs.map(\.id) == ["job-a", "job-b"]) + #expect(store.lastError == nil) + #expect(await fixture.requests.requestCount(method: "cron.status") == 1) + #expect(await fixture.requests.requestCount(method: "cron.list") == 1) + + store.stop() + + #expect(!store.isLoadingRuns) + #expect(fixture.session.snapshotCancelCount() == 0) + } + + private func entry(jobId: String, summary: String) -> CronRunLogEntry { + CronRunLogEntry( + ts: 1_700_000_000_000, + jobId: jobId, + action: "finished", + status: "ok", + error: nil, + summary: summary, + runAtMs: nil, + durationMs: nil, + nextRunAtMs: nil) + } + + private func withLocalGatewayRecovery( + initialRunsFailure: (any Error & Sendable)? = nil, + _ operation: (CronGatewayFixture) async throws -> Void) async throws + { + let isolatedState = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-autoqa-185-cron-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: isolatedState, withIntermediateDirectories: true) + let configURL = isolatedState.appendingPathComponent("openclaw.json") + try Data(#"{"gateway":{"mode":"local","port":49185}}"#.utf8).write(to: configURL) + defer { try? FileManager.default.removeItem(at: isolatedState) } + + try await TestIsolation.withEnvValues([ + "OPENCLAW_CONFIG_PATH": configURL.path, + "OPENCLAW_STATE_DIR": isolatedState.path, + ]) { + try await DeviceIdentityStore.withStateDirectory(isolatedState) { + let fixture = CronGatewayFixture( + recoveryEligible: true, + initialRunsFailure: initialRunsFailure) + let manager = GatewayProcessManager.shared + let priorMode = AppStateStore.shared.connectionMode + AppStateStore.shared.connectionMode = .local + manager._testResetGatewayStartTask() + manager.setTestingStatus(.stopped) + manager.setTestingConnection(fixture.gateway) + manager.setTestingSkipControlChannelRefresh(true) + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL( + isolatedState.appendingPathComponent("disable-launch-agent")) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true) + GatewayLaunchAgentManager.setTestingDaemonStatusPayload( + #"{"ok":true,"service":{"loaded":false}}"#) + GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() + defer { + manager._testResetGatewayStartTask() + manager.setTestingStatus(.stopped) + manager.setTestingConnection(nil) + manager.setTestingSkipControlChannelRefresh(false) + manager.setTestingDesiredActive(false) + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false) + GatewayLaunchAgentManager.setTestingDaemonStatusPayload(nil) + GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() + AppStateStore.shared.connectionMode = priorMode + } + + do { + try await operation(fixture) + await fixture.gateway.shutdown() + } catch { + await fixture.gateway.shutdown() + throw error + } + } + } + } + + private func waitUntil( + timeout: Duration = .seconds(2), + _ condition: @MainActor () -> Bool) async -> Bool + { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(2)) + } + return condition() + } +} diff --git a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift index fe23638c0862..02deb9e5b5a8 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift @@ -423,6 +423,10 @@ struct CuaDriverHostCoordinatorTests { driver diagnostic """ + // The relay's readability handler calls stop() on any empty read, which + // closes the pipe's read end; without suppression a racing stop turns + // this write into a harness-killing SIGPIPE. + try TestProcessSupport.suppressSIGPIPE(relay.pipe.fileHandleForWriting) try relay.pipe.fileHandleForWriting.write(contentsOf: Data(driverOutput.utf8)) try relay.pipe.fileHandleForWriting.close() for _ in 0..<1000 where probe.events.count < 2 { diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift index 2108ab5c9181..a9902c511446 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayConnectionControlTests.swift @@ -224,6 +224,112 @@ private func assertConfigLookupCannotRecreateRoute( } @Suite(.serialized) struct GatewayConnectionControlTests { + @Test @MainActor + func `cancelled pending request never activates local gateway recovery`() async throws { + try await self.withIsolatedRecoveryFixture { _, _, _ in } operation: { connection, session in + let request = Task { + try await connection.request(method: "status", params: nil) + } + try #require(await self.waitForRequest(on: session)) + + request.cancel() + + do { + _ = try await request.value + Issue.record("expected the cancelled caller to throw CancellationError") + } catch is CancellationError {} catch { + Issue.record("unexpected cancellation error: \(error)") + } + + #expect(GatewayProcessManager.shared.status == .stopped) + #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty) + #expect(session.snapshotMakeCount() == 1) + #expect(session.latestTask()?.snapshotSendCount() == 2) + } + } + + @Test @MainActor + func `genuine transport failure still activates and retries local gateway recovery`() async throws { + try await self.assertUncancelledFailureRecovers(URLError(.networkConnectionLost)) + } + + @Test @MainActor + func `send-side cancellation without caller cancellation still activates gateway recovery`() async throws { + try await self.assertUncancelledFailureRecovers(CancellationError()) + } + + @Test @MainActor + func `gateway response errors never activate transport recovery`() async throws { + try await self.withIsolatedRecoveryFixture { socket, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message) + else { return } + let response = #"{"type":"res","id":"\#(id)","ok":false,"# + + #""error":{"code":"INVALID_REQUEST","message":"response rejected"}}"# + socket.emitReceiveSuccess(.data(Data(response.utf8))) + } operation: { connection, session in + do { + _ = try await connection.request(method: "status", params: nil) + Issue.record("expected the Gateway response error") + } catch is GatewayResponseError {} catch { + Issue.record("unexpected response error: \(error)") + } + + #expect(GatewayProcessManager.shared.status == .stopped) + #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty) + #expect(session.snapshotMakeCount() == 1) + #expect(session.latestTask()?.snapshotSendCount() == 2) + } + } + + @Test func `uncancelled trusted TLS mismatch still repairs its stored pin`() async throws { + try await withFakeGatewayTLSKeychain { + let url = try #require(URL(string: "wss://gateway.example.ts.net")) + let storeKey = "autoqa-185-tls-recovery" + GatewayTLSStore.saveFingerprint("old", stableID: storeKey) + let route = try #require(GatewayTLSRoute.resolve( + url: url, + connectionMode: .remote, + configuredFingerprint: nil, + storedFingerprint: "old", + storeKey: storeKey)) + let failure = GatewayTLSValidationFailure( + kind: .pinMismatch, + host: "gateway.example.ts.net", + storeKey: storeKey, + expectedFingerprint: "old", + observedFingerprint: "new", + systemTrustOk: true, + port: 443) + let requests = WebSocketMessageRecorder() + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: { socket, message, sendIndex in + guard sendIndex > 0 else { return } + requests.append(message) + if requests.snapshot().count == 1 { + throw GatewayTLSValidationError(failure: failure, context: "isolated TLS test") + } + guard let id = GatewayWebSocketTestSupport.requestID(from: message) else { return } + socket.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id))) + }) + }) + let connection = GatewayConnection( + testEndpointProvider: { + GatewayConnection.EndpointSnapshot( + config: (url: url, token: nil, password: nil), + tls: route, + routeAuthority: nil) + }, + sessionBox: WebSocketSessionBox(session: session)) + + _ = try await connection.request(method: "status", params: nil) + + #expect(GatewayTLSStore.loadFingerprint(stableID: storeKey) == "new") + #expect(requests.snapshot().count == 2) + await connection.shutdown() + } + } + @Test func `operator widget capability refresh is shared and retained`() async throws { let rawOldSurface = "http://127.0.0.1:18789/__openclaw__/cap/old-token" let rawNewSurface = "http://127.0.0.1:18789/__openclaw__/cap/new-token" @@ -711,6 +817,111 @@ private func assertConfigLookupCannotRecreateRoute( } } + @MainActor + private func withIsolatedRecoveryFixture( + _ sendHook: @escaping GatewayTestWebSocketTask.SendHook, + operation: (GatewayConnection, GatewayTestWebSocketSession) async throws -> T) async throws -> T + { + let isolatedState = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-gateway-recovery-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: isolatedState, withIntermediateDirectories: true) + let configURL = isolatedState.appendingPathComponent("openclaw.json") + let port = Int.random(in: 30000...59999) + try Data(#"{"gateway":{"mode":"local","port":\#(port)}}"#.utf8).write(to: configURL) + defer { try? FileManager.default.removeItem(at: isolatedState) } + + return try await TestIsolation.withEnvValues([ + "OPENCLAW_PROFILE": "autoqa-185-tests", + "OPENCLAW_CONFIG_PATH": configURL.path, + "OPENCLAW_STATE_DIR": isolatedState.path, + ]) { + try await DeviceIdentityStore.withStateDirectory(isolatedState) { + let session = GatewayTestWebSocketSession(taskFactory: { + GatewayTestWebSocketTask(sendHook: sendHook) + }) + let connection = GatewayConnection( + endpointProvider: { + GatewayConnection.EndpointSnapshot( + config: (url: URL(string: "ws://127.0.0.1:\(port)")!, token: nil, password: nil), + routeAuthority: nil) + }, + supportsSharedEndpointRecovery: true, + activationBindingKeyProvider: { nil }, + sessionBox: WebSocketSessionBox(session: session)) + let manager = GatewayProcessManager.shared + let priorMode = AppStateStore.shared.connectionMode + AppStateStore.shared.connectionMode = .local + manager._testResetGatewayStartTask() + manager.setTestingStatus(.stopped) + manager.setTestingConnection(connection) + manager.setTestingSkipControlChannelRefresh(true) + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL( + isolatedState.appendingPathComponent("disable-launch-agent")) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(true) + GatewayLaunchAgentManager.setTestingDaemonStatusPayload( + #"{"ok":true,"service":{"loaded":false}}"#) + GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() + defer { + manager._testResetGatewayStartTask() + manager.setTestingStatus(.stopped) + manager.setTestingConnection(nil) + manager.setTestingSkipControlChannelRefresh(false) + manager.setTestingDesiredActive(false) + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false) + GatewayLaunchAgentManager.setTestingDaemonStatusPayload(nil) + GatewayLaunchAgentManager.clearTestingDaemonCommandCalls() + AppStateStore.shared.connectionMode = priorMode + } + + do { + let result = try await operation(connection, session) + await connection.shutdown() + return result + } catch { + await connection.shutdown() + throw error + } + } + } + } + + @MainActor + private func assertUncancelledFailureRecovers(_ failure: any Error & Sendable) async throws { + let requests = WebSocketMessageRecorder() + try await self.withIsolatedRecoveryFixture { socket, message, sendIndex in + guard sendIndex > 0, + let id = GatewayWebSocketTestSupport.requestID(from: message), + let data = Self.messageData(message), + let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return } + if frame["method"] as? String == "status" { + requests.append(message) + if requests.snapshot().count == 1 { + throw failure + } + } + socket.emitReceiveSuccess(.data(GatewayWebSocketTestSupport.okResponseData(id: id))) + } operation: { connection, session in + _ = try await connection.request(method: "status", params: nil) + + #expect(GatewayProcessManager.shared.status != .stopped) + #expect(requests.snapshot().count == 2) + #expect(session.snapshotMakeCount() >= 1) + } + } + + private func waitForRequest(on session: GatewayTestWebSocketSession) async -> Bool { + let deadline = ContinuousClock.now + .seconds(2) + while ContinuousClock.now < deadline { + if session.latestTask()?.snapshotSendCount() ?? 0 >= 2 { + return true + } + try? await Task.sleep(for: .milliseconds(2)) + } + return false + } + private func assertDeviceTokenIsolation( routeA: (url: URL, owner: String), routeB: (url: URL, owner: String) diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift index c5722c1e9a7a..dc8b1ecc64fd 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeCodexThreadCatalogTests.swift @@ -197,9 +197,13 @@ struct MacNodeCodexThreadCatalogTests { } private func openFIFOForWriting(_ url: URL) async throws -> FileHandle { - try await Task.detached { + let handle = try await Task.detached { try FileHandle(forWritingTo: url) }.value + // The FIFO reader is a spawned fake child; if it exits before the exit + // gate write, an unsuppressed SIGPIPE kills the whole test harness. + try TestProcessSupport.suppressSIGPIPE(handle) + return handle } private func requestEmptyList( diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 348d98ecd917..6499f478b028 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -139,12 +139,7 @@ struct OnboardingViewSmokeTests { #expect(!order.contains(2)) } - @Test func `fresh remote setup installs CLI for the Mac node worker`() { - let order = OnboardingView.pageOrder( - for: .remote, - requiresCLIInstall: true) - - #expect(order.contains(2)) + @Test func `CLI install activates only a local gateway`() { #expect(!OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .remote)) #expect(OnboardingView.shouldActivateLocalGateway(afterCLIInstallFor: .local)) } diff --git a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift index f85295ed9cad..22eb0b326c6c 100644 --- a/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/PortGuardianRecordStoreTests.swift @@ -122,6 +122,42 @@ struct PortGuardianRecordStoreTests { #expect(try store.records().isEmpty) } + @Test + func `cancelled port sweep never touches its durable tunnel records`() async throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + let store = try PortGuardianRecordStore(databaseURL: fixture.databaseURL) + let orphan = Self.record(pid: 2_000_000_000, port: 18789, timestamp: 1) + try store.upsert(orphan) + let guardian = PortGuardian(recordStoreFactory: { + try PortGuardianRecordStore(databaseURL: fixture.databaseURL) + }) + + let sweep = Task { + withUnsafeCurrentTask { $0?.cancel() } + await guardian.sweep(mode: .unconfigured) + } + await sweep.value + + #expect(try store.records() == [orphan]) + } + + @Test + func `uncancelled unconfigured sweep still reaps orphaned tunnel records`() async throws { + let fixture = try Self.fixture() + defer { fixture.cleanup() } + let store = try PortGuardianRecordStore(databaseURL: fixture.databaseURL) + let orphan = Self.record(pid: 2_000_000_000, port: 18789, timestamp: 1) + try store.upsert(orphan) + let guardian = PortGuardian(recordStoreFactory: { + try PortGuardianRecordStore(databaseURL: fixture.databaseURL) + }) + + await guardian.sweep(mode: .unconfigured) + + #expect(try store.records().isEmpty) + } + @Test func `failed receipt deletion relinquishes ownership for sweep retry`() async throws { let fixture = try Self.fixture() diff --git a/apps/macos/Tests/OpenClawIPCTests/TestProcessSupport.swift b/apps/macos/Tests/OpenClawIPCTests/TestProcessSupport.swift index cd603b75580e..cdec3e98746a 100644 --- a/apps/macos/Tests/OpenClawIPCTests/TestProcessSupport.swift +++ b/apps/macos/Tests/OpenClawIPCTests/TestProcessSupport.swift @@ -1,6 +1,7 @@ import Darwin import Foundation import Testing +@testable import OpenClaw enum TestProcessSupport { static func pollPID(in file: URL) -> pid_t? { @@ -34,6 +35,15 @@ enum TestProcessSupport { return self.processIsGone(pid) } + /// SIGPIPE from a write whose reader already exited kills the entire test + /// process (swiftpm-testing-helper dies with signal 13, blaming whatever + /// test happens to be running). Mirror the production F_SETNOSIGPIPE guard + /// (MacNodeHostWorker) so a racing reader exit surfaces as a thrown EPIPE + /// on that one write instead. + static func suppressSIGPIPE(_ writeEnd: FileHandle) throws { + try #require(writeEnd.disableSIGPIPE()) + } + static func killLeakedProcesses(in files: [URL]) { let pids = files.compactMap { self.pollPID(in: $0) } for pid in pids where !self.processIsGone(pid) { diff --git a/apps/macos/Tests/OpenClawIPCTests/TestProcessSupportPipeTests.swift b/apps/macos/Tests/OpenClawIPCTests/TestProcessSupportPipeTests.swift new file mode 100644 index 000000000000..d3ab80e756a7 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/TestProcessSupportPipeTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing + +struct TestProcessSupportPipeTests { + @Test func `suppressed write end reports EPIPE instead of killing the harness`() throws { + let pipe = Pipe() + try TestProcessSupport.suppressSIGPIPE(pipe.fileHandleForWriting) + try pipe.fileHandleForReading.close() + + // Without suppression this write would raise SIGPIPE and take down the + // whole swiftpm-testing-helper process instead of throwing. + #expect(throws: Error.self) { + try pipe.fileHandleForWriting.write(contentsOf: Data("x".utf8)) + } + try pipe.fileHandleForWriting.close() + } +} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 27d8b024f549..72281d0c4dc3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -2201,6 +2201,7 @@ public struct EnvironmentSummary: Codable, Sendable { public let lastseenreason: String? public let trust: String? public let capabilities: [String]? + public let invocablecommands: [String]? public let desktop: Bool? public let issues: [[String: AnyCodable]]? public let worker: WorkerEnvironmentMetadata? @@ -2220,6 +2221,7 @@ public struct EnvironmentSummary: Codable, Sendable { lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, + invocablecommands: [String]? = nil, desktop: Bool? = nil, issues: [[String: AnyCodable]]? = nil, worker: WorkerEnvironmentMetadata? = nil) @@ -2238,6 +2240,7 @@ public struct EnvironmentSummary: Codable, Sendable { self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities + self.invocablecommands = invocablecommands self.desktop = desktop self.issues = issues self.worker = worker @@ -2258,6 +2261,7 @@ public struct EnvironmentSummary: Codable, Sendable { case lastseenreason = "lastSeenReason" case trust case capabilities + case invocablecommands = "invocableCommands" case desktop case issues case worker @@ -2297,6 +2301,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { public let lastseenreason: String? public let trust: String? public let capabilities: [String]? + public let invocablecommands: [String]? public let desktop: Bool? public let issues: [[String: AnyCodable]]? public let worker: WorkerEnvironmentMetadata? @@ -2316,6 +2321,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, + invocablecommands: [String]? = nil, desktop: Bool? = nil, issues: [[String: AnyCodable]]? = nil, worker: WorkerEnvironmentMetadata? = nil) @@ -2334,6 +2340,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities + self.invocablecommands = invocablecommands self.desktop = desktop self.issues = issues self.worker = worker @@ -2354,6 +2361,7 @@ public struct EnvironmentsCreateResult: Codable, Sendable { case lastseenreason = "lastSeenReason" case trust case capabilities + case invocablecommands = "invocableCommands" case desktop case issues case worker @@ -2393,6 +2401,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { public let lastseenreason: String? public let trust: String? public let capabilities: [String]? + public let invocablecommands: [String]? public let desktop: Bool? public let issues: [[String: AnyCodable]]? public let worker: WorkerEnvironmentMetadata? @@ -2412,6 +2421,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, + invocablecommands: [String]? = nil, desktop: Bool? = nil, issues: [[String: AnyCodable]]? = nil, worker: WorkerEnvironmentMetadata? = nil) @@ -2430,6 +2440,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities + self.invocablecommands = invocablecommands self.desktop = desktop self.issues = issues self.worker = worker @@ -2450,6 +2461,7 @@ public struct EnvironmentsDestroyResult: Codable, Sendable { case lastseenreason = "lastSeenReason" case trust case capabilities + case invocablecommands = "invocableCommands" case desktop case issues case worker @@ -2505,6 +2517,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { public let lastseenreason: String? public let trust: String? public let capabilities: [String]? + public let invocablecommands: [String]? public let desktop: Bool? public let issues: [[String: AnyCodable]]? public let worker: WorkerEnvironmentMetadata? @@ -2524,6 +2537,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { lastseenreason: String? = nil, trust: String? = nil, capabilities: [String]? = nil, + invocablecommands: [String]? = nil, desktop: Bool? = nil, issues: [[String: AnyCodable]]? = nil, worker: WorkerEnvironmentMetadata? = nil) @@ -2542,6 +2556,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { self.lastseenreason = lastseenreason self.trust = trust self.capabilities = capabilities + self.invocablecommands = invocablecommands self.desktop = desktop self.issues = issues self.worker = worker @@ -2562,6 +2577,7 @@ public struct EnvironmentsStatusResult: Codable, Sendable { case lastseenreason = "lastSeenReason" case trust case capabilities + case invocablecommands = "invocableCommands" case desktop case issues case worker @@ -11027,6 +11043,52 @@ public struct DecisionReceiptV1: Codable, Sendable { } } +public struct DecisionReceiptDisplayV1: Codable, Sendable { + public let schemaversion: Double + public let selectorid: String + public let occurredat: Int + public let action: [String: AnyCodable] + public let decision: [String: AnyCodable] + public let enforcement: [String: AnyCodable] + public let provenance: AnyCodable + public let missingevidence: [String] + public let remediation: [[String: AnyCodable]] + + public init( + schemaversion: Double, + selectorid: String, + occurredat: Int, + action: [String: AnyCodable], + decision: [String: AnyCodable], + enforcement: [String: AnyCodable], + provenance: AnyCodable, + missingevidence: [String], + remediation: [[String: AnyCodable]]) + { + self.schemaversion = schemaversion + self.selectorid = selectorid + self.occurredat = occurredat + self.action = action + self.decision = decision + self.enforcement = enforcement + self.provenance = provenance + self.missingevidence = missingevidence + self.remediation = remediation + } + + private enum CodingKeys: String, CodingKey { + case schemaversion = "schemaVersion" + case selectorid = "selectorId" + case occurredat = "occurredAt" + case action + case decision + case enforcement + case provenance + case missingevidence = "missingEvidence" + case remediation + } +} + public struct AuditRunIdentityPresentV1: Codable, Sendable { public let state: String public let context: ExecutionIdentityContextV1 @@ -11165,7 +11227,7 @@ public struct AuditRunInspectResult: Codable, Sendable { public let schemaversion: Double public let run: [String: AnyCodable] public let identity: AuditRunIdentityV1 - public let decisions: [DecisionReceiptV1] + public let decisiondisplays: [DecisionReceiptDisplayV1] public let coverage: [String: AnyCodable] public let nextdecisioncursor: String? public let nextexecutioncursor: String? @@ -11174,7 +11236,7 @@ public struct AuditRunInspectResult: Codable, Sendable { schemaversion: Double, run: [String: AnyCodable], identity: AuditRunIdentityV1, - decisions: [DecisionReceiptV1], + decisiondisplays: [DecisionReceiptDisplayV1], coverage: [String: AnyCodable], nextdecisioncursor: String? = nil, nextexecutioncursor: String? = nil) @@ -11182,7 +11244,7 @@ public struct AuditRunInspectResult: Codable, Sendable { self.schemaversion = schemaversion self.run = run self.identity = identity - self.decisions = decisions + self.decisiondisplays = decisiondisplays self.coverage = coverage self.nextdecisioncursor = nextdecisioncursor self.nextexecutioncursor = nextexecutioncursor @@ -11192,7 +11254,7 @@ public struct AuditRunInspectResult: Codable, Sendable { case schemaversion = "schemaVersion" case run case identity - case decisions + case decisiondisplays = "decisionDisplays" case coverage case nextdecisioncursor = "nextDecisionCursor" case nextexecutioncursor = "nextExecutionCursor" diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 3d4b8c5c2a4e..fe6e92d7d411 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -1200,7 +1200,6 @@ extensions/slack/src/monitor/message-handler/dispatch-helpers.ts 2 extensions/slack/src/monitor/message-handler/dispatch-streaming.ts 6 extensions/slack/src/monitor/message-handler/dispatch.ts 2 extensions/slack/src/monitor/message-handler/prepare-dm-history.ts 1 -extensions/slack/src/monitor/message-handler/prepare-routing.ts 1 extensions/slack/src/monitor/message-handler/prepare-thread-context.ts 1 extensions/slack/src/monitor/message-handler/prepare.ts 3 extensions/slack/src/monitor/message-handler/preview-finalize.ts 3 @@ -3128,7 +3127,6 @@ src/gateway/worker-environments/node-worker-tunnel.ts 6 src/gateway/worker-environments/node-worker-workspace-fallback.ts 2 src/gateway/worker-environments/node-workspace-transfer-service.ts 1 src/gateway/worker-environments/node-workspace-transfer-snapshot.ts 1 -src/gateway/worker-environments/placement-session-runtime.ts 1 src/gateway/worker-environments/placement-state.ts 3 src/gateway/worker-environments/placement-store.ts 1 src/gateway/worker-environments/provider-lifecycle.ts 1 @@ -3456,7 +3454,7 @@ src/model-picker/apply-session-model-selection.ts 1 src/node-host/config.ts 1 src/node-host/desktop-stream-command.ts 2 src/node-host/invoke-agent-cli-claude-params.ts 4 -src/node-host/invoke-agent-cli-claude.ts 2 +src/node-host/invoke-agent-cli-claude.ts 1 src/node-host/invoke-file-commands.ts 1 src/node-host/invoke-payload.ts 5 src/node-host/invoke.ts 7 @@ -3708,7 +3706,7 @@ src/process/exec-spawn.ts 4 src/process/exec.ts 3 src/process/spawn-secret-input.ts 1 src/process/spawn-utils.ts 1 -src/process/supervisor/adapters/child.ts 3 +src/process/supervisor/adapters/child.ts 2 src/process/supervisor/adapters/pty.ts 1 src/process/terminal-pty.ts 1 src/process/windows-command.ts 1 @@ -4171,7 +4169,7 @@ ui/src/pages/chat/components/chat-composer-slash-menu.ts 1 ui/src/pages/chat/components/chat-composer-view.ts 1 ui/src/pages/chat/components/chat-composer.ts 5 ui/src/pages/chat/components/chat-effort-picker.ts 6 -ui/src/pages/chat/components/chat-header-session-menu.ts 3 +ui/src/pages/chat/components/chat-header-session-menu.ts 2 ui/src/pages/chat/components/chat-message-attachment-availability.ts 1 ui/src/pages/chat/components/chat-message-bubble.ts 2 ui/src/pages/chat/components/chat-message-confirmation.ts 2 @@ -4235,7 +4233,7 @@ ui/src/pages/config/view-schema.ts 2 ui/src/pages/config/view.ts 5 ui/src/pages/connection/connection-page.ts 1 ui/src/pages/connection/view.ts 3 -ui/src/pages/cron/view-runs.ts 5 +ui/src/pages/cron/view-runs.ts 3 ui/src/pages/cron/view.ts 9 ui/src/pages/custodian/custodian-session-store.ts 1 ui/src/pages/custodian/custodian-surface.ts 2 @@ -4249,7 +4247,6 @@ ui/src/pages/logs/log-lines.ts 1 ui/src/pages/logs/view.ts 2 ui/src/pages/memory-import/view.ts 3 ui/src/pages/model-providers/default-models-view.ts 1 -ui/src/pages/model-providers/model-providers-page.ts 1 ui/src/pages/model-providers/view.ts 3 ui/src/pages/model-setup/provider-picker.ts 3 ui/src/pages/model-setup/view.ts 2 diff --git a/config/knip.config.ts b/config/knip.config.ts index eae5a7fb8266..8977eb65ba6f 100644 --- a/config/knip.config.ts +++ b/config/knip.config.ts @@ -144,6 +144,8 @@ const rootEntries = [ "scripts/print-cli-backend-live-metadata.ts!", // Workflow/package-script entrypoints are not imported from production modules. "scripts/openclaw-cross-os-release-checks.ts!", + "scripts/release-plan-producer-core.mts!", + "scripts/release-plan-producer.mts!", // Spawned by the agent concurrency benchmark; no static import edge exists. "scripts/bench-agent-concurrency-worker.ts!", // Spawned by the durable task registry churn benchmark in a fresh GC-enabled process. diff --git a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 index edb97b8c7480..c9bb70248b94 100644 --- a/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 +++ b/docs/.generated/sqlite-session-transcript-schema-baseline.sha256 @@ -1 +1 @@ -73cfbac3e2ef8a75d561165d9798c805e0a8c726c1bcd1c814c7cae777194b2b sqlite-session-transcript-schema-baseline.sql +fecbb8adccfa0be0b452f646d3bee8d5a17faeb2c2d8a2300fb75ef173c709cd sqlite-session-transcript-schema-baseline.sql diff --git a/docs/ci.md b/docs/ci.md index 3cc52aa816d2..17d39df2231f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -55,7 +55,6 @@ dispatch. | `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes | | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | | `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | -| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | | `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | The rare path-triggered `docker-seed-e2e` job selects only the executable @@ -726,10 +725,6 @@ Quality stays separate from security so quality findings can be scheduled, measu The `Docs Agent` workflow is an event-driven Codex maintenance lane for keeping existing docs aligned with recently landed changes. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, and manual dispatch can run it directly. Workflow-run invocations skip when `main` has moved on or when another non-skipped Docs Agent run was created in the last hour. When it runs, it reviews the commit range from the previous non-skipped Docs Agent source SHA to current `main`, so one hourly run can cover all main changes accumulated since the last docs pass. -### Test Performance Agent - -The `Test Performance Agent` workflow is an event-driven Codex maintenance lane for slow tests. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, but it skips if another workflow-run invocation already ran or is running that UTC day. Manual dispatch bypasses that daily activity gate. The lane builds a full-suite grouped Vitest performance report, lets Codex make only small coverage-preserving test performance fixes instead of broad refactors, then reruns the full-suite report and rejects changes that reduce the passing baseline test count. The grouped report records per-config wall time and max RSS on Linux and macOS, so the before/after comparison surfaces test memory deltas beside duration deltas. If the baseline has failing tests, Codex may fix only obvious failures and the after-agent full-suite report must pass before anything is committed. When `main` advances before the bot push lands, the lane rebases the validated patch, reruns `pnpm check:changed`, and retries the push; conflicting stale patches are skipped. It uses GitHub-hosted Ubuntu so the Codex action can keep the same drop-sudo safety posture as the docs agent. - ### Duplicate PRs After Merge The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for post-land duplicate cleanup. It defaults to dry-run and only closes explicitly listed PRs when `apply=true`. Before mutating GitHub, it verifies that the landed PR is merged and that each duplicate has either a shared referenced issue or overlapping changed hunks. diff --git a/docs/cli/audit.md b/docs/cli/audit.md index 398333a9b7ab..76029cd7799e 100644 --- a/docs/cli/audit.md +++ b/docs/cli/audit.md @@ -152,14 +152,15 @@ valid host-bound evidence; it never means allowed. `unsupported` is reserved for a named path with no authoritative Phase 0 integration. A plugin-provided sender or structurally copied resolver result cannot upgrade either state. -A terminal approval receipt shows `allowed` or `denied`, its stable reason -code, enforcement state, authoritative source boundary, policy and grant -references, context fields used, and remediation. Expired and cancelled +A terminal approval display shows `allowed` or `denied`, its stable reason +code, enforcement state, verified producer class, policy and grant counts, +context fields used, and remediation. Expired and cancelled approvals are denied non-actions with distinct reason codes. `no-route` is an enforced denial only when the approval owner recorded that terminal state. A -corrupt approval is `unknown`. The text view labels `operator_approvals` as an -authoritative owner-native SQLite record retained for 30 days; JSON preserves -the same source owner and record reference without lossy reformatting. +corrupt approval is `unknown`. The text view labels a verified +operator-approval producer as an authoritative owner-native SQLite record +retained for 30 days. Neither text nor JSON exposes the raw source owner, record +reference, policy reference, or grant reference. `enforced` requires the approval's immutable owner-local binding to match the selected context, execution, and run exactly. A missing, malformed, or mismatched binding reports `operator_approval_execution_link_missing`, @@ -192,8 +193,9 @@ their exact tuple was recorded and the gate changed the outcome. Portable actions and early suppressions that have no durable delivery record use the generic decision-fact owner instead of duplicating delivery state. -JSON output is the Gateway result without lossy reformatting. An exact result contains one -bounded V1 context (maximum 16 KiB), up to 100 decision receipts, coverage and +JSON output is the Gateway's safe-only result without lossy reformatting. An +exact result contains one bounded V1 context (maximum 16 KiB), up to 100 +`decisionDisplays`, coverage and missing-evidence codes, and an optional `nextDecisionCursor`. An ambiguous run result instead contains at most 50 execution candidates and an optional `nextExecutionCursor`. Sensitive domain, @@ -303,18 +305,25 @@ openclaw gateway call audit.run.inspect \ --params '{"executionId":"5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf","decisionLimit":50}' ``` -Its result is `{ "schemaVersion": 1, "run": ..., "identity": ..., "decisions": -..., "coverage": ..., "nextDecisionCursor"?: ..., "nextExecutionCursor"?: ... }`. +Its result is `{ "schemaVersion": 1, "run": ..., "identity": ..., +"decisionDisplays": ..., "coverage": ..., "nextDecisionCursor"?: ..., +"nextExecutionCursor"?: ... }`. The required `decisionDisplays` array is the +only receipt presentation field. Raw owner receipts and a `decisions` key never +cross the Gateway boundary. The closed request accepts exactly one of `executionId` or `runId`. `decisionLimit` is 1–100 and `decisionCursor` is optional. Run discovery also accepts `executionLimit` from 1–50 and an optional `executionCursor`. A run with multiple retained executions returns the typed `ambiguous` identity state -and no identity context or decisions until the caller selects an execution id. +and no identity context; its required `decisionDisplays` array is empty until +the caller selects an execution id. For one selected context, receipt paging starts with admission, then reads owner-native terminal approvals, merges outbound progress and terminal records, and finally reads generic facts for boundaries without a native durable record. The merge is deterministic across restart and rejects a cursor whose exact -owner row has expired. +owner row has expired. Approval and message selectors use the opaque +`approval-decision:` and `message-decision:` namespaces minted from the same +owner-query snapshot; raw receipt, resolution, and event identifiers never +become selectors. Approval and delivery inspection never write generic duplicates. Generic fact writes and projections also require the full context, execution, and run tuple to match the immutable execution context. diff --git a/docs/cli/config.md b/docs/cli/config.md index 8f4d0a9bf114..f4925ca76f04 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -365,7 +365,7 @@ openclaw config set channels.discord.token \ - `checks.resolvabilityComplete`: whether resolvability checks ran to completion (false when exec refs are skipped) - `refsChecked`: number of refs actually resolved during dry-run - `skippedExecRefs`: number of exec refs skipped because `--allow-exec` was not set - - `errors`: structured missing-path, schema, or resolvability failures when `ok=false` + - `errors`: structured failures when `ok=false`; each carries a `kind` of `missing-path`, `schema`, `resolvability`, `model`, or `conflict` (`conflict` means the config file changed while the command was writing, so nothing was changed — re-run to pick up the new file) diff --git a/docs/concepts/mantis.md b/docs/concepts/mantis.md index d49446df4ab6..32440b4df479 100644 --- a/docs/concepts/mantis.md +++ b/docs/concepts/mantis.md @@ -202,10 +202,13 @@ in. ### Telegram Desktop recorder The Telegram Desktop recorder is a standalone operator utility, invoked -directly through `pnpm qa:telegram-desktop-recorder`. It records native -Telegram Desktop and nothing else: it never drives OpenClaw or sends Telegram -messages. Whoever runs it owns the turn — start the SUT, send through a real -Telegram user, then tell the recorder which message to show — and supplies +directly through `pnpm qa:telegram-desktop-recorder`. It never drives OpenClaw. +Its normal recording commands do not send Telegram messages. The optional +`actions` command drives only the measured Telegram window through bounded +`click`, `key`, `type`, and `sleep` actions; those actions can send as the +signed-in Telegram user. Whoever runs it owns the turn and those side effects — +start the SUT, send through a real Telegram user, then tell the recorder which +message to show — and supplies `--user-driver`, the command the recorder shells out to for the TDLib calls it cannot make itself (`confirm-qr`, `terminate-session`). Any driver exposing those two verbs works, including this repo's diff --git a/docs/gateway/audit.md b/docs/gateway/audit.md index 659be96fe737..376282f21b25 100644 --- a/docs/gateway/audit.md +++ b/docs/gateway/audit.md @@ -235,9 +235,16 @@ facts: authorization. The method requires `operator.read`. Requests are closed and select exactly one -`executionId` or `runId`. Decision pages contain at most 100 receipts; +`executionId` or `runId`. The public result always contains a required +`decisionDisplays` array and never contains the private raw receipt array or a +`decisions` key. The Gateway builds that result from an explicit safe-field +allowlist; clients do not classify receipt prose. Decision pages contain at +most 100 displays; ambiguous run-discovery pages contain at most 50 candidate executions. Both use -bounded cursors. +bounded cursors. Approval and message-delivery selectors are minted from the +same owner-query row metadata as their projected receipts, use the +`approval-decision:` and `message-decision:` namespaces, and never derive from +receipt, resolution, or event identifiers. Every client with `operator.read` in the same Gateway operator domain may receive this retained identity category. This is intentional: the scope already @@ -437,9 +444,11 @@ correlation alone. [Gateway protocol](/gateway/protocol#audit-ledger-rpc). - Identity RPC: `audit.run.inspect` (requires `operator.read`) accepts one `executionId` for exact inspection or one `runId` for bounded discovery. It - returns the immutable V1 context plus paged admission, approval, - owner-native outbound message, and generic decision receipts for an exact - match, or a typed ambiguous candidate page when a run has multiple executions. + returns the immutable V1 context plus paged safe displays for admission, + approval, owner-native outbound message, and generic decision records for an + exact match, or a typed ambiguous candidate page with an empty display array + when a run has multiple executions. Raw owner receipts remain private to the + aggregation and storage owners. ## Related diff --git a/docs/gateway/cloud-workers.md b/docs/gateway/cloud-workers.md index f70b93ce6f7c..c252d2c83e9c 100644 --- a/docs/gateway/cloud-workers.md +++ b/docs/gateway/cloud-workers.md @@ -7,7 +7,7 @@ status: active doc-schema-version: 1 --- -Cloud workers move a session's coding work onto a throwaway cloud machine while the session stays visible in the sidebar and its transcript remains owned by the Gateway. The bundled Crabbox provider boots the box, runs profile setup, and starts `openclaw connect --ephemeral`. The enrolled node then receives the Gateway's pinned worker bundle and hosts the same restricted `openclaw worker` children as any paired session-capable device. +Cloud workers move a session's coding work onto a throwaway cloud machine while the session stays visible in the sidebar and its transcript remains owned by the Gateway. The bundled Crabbox provider boots the box, runs profile setup, and starts `openclaw connect --ephemeral`. In OpenClaw `worker-turn` mode, the enrolled node receives the Gateway's pinned worker bundle and hosts a restricted `openclaw worker` child. Eligible paired devices can instead carry Codex `remote-exec` without launching an OpenClaw worker child. Enrollment is environment-owned and replay-safe. The Gateway persists one setup identity before provider allocation, binds the first authenticated device identity to that exact environment, and reuses the durable device token when provisioning resumes. Initial enrollment and replay both enable worker hosting only for that node process; they do not change durable worker-host configuration. Reclaim or destroy releases the cloud lease and removes the environment-owned node pairing. @@ -22,12 +22,12 @@ Cloud workers are opt-in. Until you configure a profile, clients hide the Cloud | Concern | OpenClaw `worker-turn` mode | Codex `remote-exec` mode | | ---------------------------------- | ---------------------------------------------------- | ------------------------------------------------------- | | Agent runtime and turn loop | Cloud box (`openclaw worker`) | Gateway (Codex app-server) | -| Command, filesystem, and HTTP work | Cloud box | SSH-backed provider sandbox | +| Command, filesystem, and HTTP work | Cloud box | Paired device or SSH-backed provider sandbox | | Model inference and provider auth | Gateway, proxied by `{provider, model}` reference | Gateway, including ChatGPT subscription or API-key auth | | Transcript and live session state | Gateway, fed by the worker's replayable event stream | Gateway through the normal local harness path | | Workspace file state | Changed on the box; reconciled by the Gateway | Changed remotely; reconciled by the Gateway | -The bundled Crabbox provider supports `worker-turn` through the node transport. Codex `remote-exec` remains available only with a provider that explicitly supports its SSH sandbox carrier; OpenClaw rejects a node-only profile before allocating a lease. +The bundled Crabbox cloud provider supports `worker-turn` through the node transport. Codex `remote-exec` supports an explicitly authorized paired device through that device's authenticated duplex node channel, or a cloud provider that explicitly advertises an SSH-backed execution carrier. A Crabbox cloud profile still does not advertise Codex `remote-exec`. After Crabbox setup, the cloud node dials the Gateway's public TLS endpoint over outbound WebSocket. Worker control and workspace transfer use the authenticated node and worker channels, not a Gateway-created reverse tunnel or rsync. Crabbox itself may still require SSH reachability while its CLI runs the provider-owned setup command. Outbound internet access is provider policy; the default AWS profile can reach the internet unless you restrict its network or security group. @@ -180,13 +180,59 @@ While a placement is active, OpenClaw automatically samples available space on t ### Runtime support - **OpenClaw** uses `worker-turn` placement. The restricted `openclaw worker` process runs each turn on the leased node and proxies inference through the Gateway. -- **Codex** uses `remote-exec` placement only when the selected provider advertises an SSH-backed execution carrier. The bundled Crabbox node provider does not, so Codex dispatch to Crabbox fails before allocation. +- **Codex** uses `remote-exec` placement on an eligible paired device, or with a cloud provider that advertises an SSH-backed execution carrier. The bundled Crabbox cloud profile supports only `worker-turn`, so selecting that profile for Codex still fails before allocation. -The Control UI disables cloud destinations whose advertised mode does not match the selected runtime. +The Control UI disables cloud destinations whose advertised mode does not match +the selected runtime, including when moving an existing session. An +incompatible move is rejected before the active source starts draining or +changes its durable placement. Other runtimes remain unavailable unless their harness explicitly declares a cloud placement mode. Cloud targets are not offered for external CLI session catalogs. Remote-exec fails closed if the selected provider or placement sandbox is unavailable; it never falls back to running the operation on the Gateway host. -The equivalent RPC flow is: +### Codex on a paired device + +Paired-device Codex placement requires the `codex` plugin to be installed and +enabled in both the Gateway's configuration and the node's own local +configuration. Include `codex` in `plugins.allow` on either machine when that +machine uses a plugin allowlist. It also requires a connected session-capable +node that advertises `codex.exec-server`, and an explicit +`gateway.nodes.commands.allow` entry for `codex.exec-server.stdio.v1`. Approve +the node's updated pairing surface if needed. Before each exec-server launch, +OpenClaw also requires the normal node invocation approval; denying that +request does not start a process. + +Codex launches its exec-server directly, so paired-device placement does not +consume an OpenClaw worker slot and remains eligible when those slots are full. +OpenClaw `worker-turn` placement still requires an available worker slot. + +Approval permits process execution and filesystem access anywhere the node's +operating system account allows. The exact placement workspace controls the +starting directory and reconciled changes, not OS-level confinement. Trust the +paired device, and use a separate least-privilege OS account when isolation is +required. + +Choose the device in the Control UI **Place** picker or dispatch a +managed-worktree session with an authorized operator connection: + +```bash +openclaw gateway call sessions.dispatch \ + --params '{"key":"agent:main:device-work","deviceId":""}' +``` + +The Codex app-server, model connection, provider credentials, and transcript +remain on the Gateway. The paired node runs the managed Codex exec-server in +the transferred workspace and receives only sanitized process, filesystem, +capability-discovery, and HTTP operations over the existing node channel. It +does not launch an OpenClaw worker child. Credential-bearing HTTP requests are +rejected before they reach the paired device; run authenticated requests on the +Gateway or use an intentionally credential-free endpoint. Normal Codex turns +are supported, but `/btw` side questions are not yet placement-bound and fail +visibly. Completed changes return through the same placement workspace +reconciliation as worker turns. See +[Run Codex on a paired device](/plugins/codex-harness#run-codex-on-a-paired-device) +for the exact allowlist configuration and lifecycle. + +For cloud-profile placement, the equivalent RPC flow is: Create a session with a managed worktree, then dispatch it. Profile dispatch requires `operator.admin` and is available only while at least one worker profile is configured: @@ -211,7 +257,7 @@ openclaw gateway call sessions.dispatch \ The bundled Crabbox provider advertises whatever machine classes the configured Crabbox binary reports for the selected backend, preserving Crabbox's size order. For example, a catalog containing `tiny`, `small`, `standard`, `fast`, `large`, and `beast` produces those six picker rows in that order; if Crabbox reports `standard` as 32 vCPU · 64 GB, that shape appears beside the class. Older binaries that publish no matching class catalog retain the label-only `standard`, `fast`, `large`, and `beast` fallback. You can also pass a provider-native server or instance type such as `c7a.24xlarge`; Crabbox treats any other non-empty class as that exact type. The selected value is fixed for that placement and reused by safe provisioning retries. `machineClass` is valid only with `profileId`, not `deviceId`. -`sessions.dispatch` closes local turn admission, drains active work, validates the eligible Git workspace inventory, provisions the lease, runs setup, enrolls the node, pushes the Gateway bundle, syncs the workspace, and returns once the placement reaches `active` ownership. Inventory validation happens before provider allocation and reports an invalid request with an actionable size or entry limit when the workspace cannot be dispatched. Budget several minutes for the first dispatch; leases and content-addressed bundles are reused where safe. After that, talk to the session as usual. OpenClaw turns route to the worker process; supported SSH-backed providers may still carry Codex remote-exec. +`sessions.dispatch` closes local turn admission, drains active work, validates the eligible Git workspace inventory, provisions the lease, runs setup, enrolls the node, pushes the Gateway bundle when worker hosting requires it, syncs the workspace, and returns once the placement reaches `active` ownership. Inventory validation happens before provider allocation and reports an invalid request with an actionable size or entry limit when the workspace cannot be dispatched. Budget several minutes for the first cloud dispatch; leases and content-addressed bundles are reused where safe. After that, talk to the session as usual. OpenClaw turns route to the worker process; Codex native operations run on the authorized paired device or supported SSH-backed provider. Completed cloud turns reconcile eligible, size-bounded workspace files back into the session's managed worktree before the turn claim is released. Worker-turn uses its terminal worker event to create the durable pending-result fence. Remote-exec waits for workspace quiescence and enters the same reconciliation flow after the local Codex attempt. Before applying the result, the Gateway stages complete authenticated base/current manifests plus each changed resulting blob as a Git ref under `refs/openclaw/worker-results/`; deletions are represented by the manifests and need no blob. This keeps the cloud delta recoverable even if the Gateway stops during the apply without duplicating unchanged baseline content. Workspace results use Git file semantics: regular files, executable bits, symlinks, additions, changes, and deletions are retained, while empty directories and other directory modes are not. The resulting file changes remain in the managed worktree for normal review and commit. @@ -226,10 +272,15 @@ To continue the same session somewhere else, open the **Runs on Cloud** chip and An active paired-device placement stays `active` when its runner disconnects. Control UI shows **Device offline** and **Waiting for device to reconnect; retry after it returns**. Waiting is the default and keeps the remote owner and -workspace intact. **Continue on Gateway…** is explicitly destructive: after a +workspace intact. Any in-flight Codex `remote-exec` attempt fails visibly, its +node exec-server and child processes are terminated, and reconnecting the same +paired device allows a fresh attempt only; the disconnected stdio session is +never resumed. **Continue on Gateway…** is explicitly destructive: after a data-loss confirmation, it abandons the exact offline device owner and resumes from the last Gateway-synced workspace without replay. Unsynced device files -and in-flight work may be lost. If the device is already available, use the +and in-flight work may be lost. This explicit abandonment also fences an active +local Codex turn claim without waiting for an acknowledgment from the offline +node. If the device is already available, use the ordinary reconcile-first move instead. When the work is complete and no turn is running, choose **Stop cloud worker…** from the same chip. The Gateway performs one final workspace reconciliation before it destroys the environment. A placement already in `draining` or `reconciling` is finishing teardown; wait for its badge to become `reclaimed` before deleting the session. @@ -290,6 +341,7 @@ The desktop never gains public ingress. The node reads `/var/lib/crabbox/vnc.pas - **Gateway-owned tool authority.** In worker-turn mode, the Gateway projects current profile, provider, agent, group, sender, sandbox, delegation, inherited, and runtime-cap policy over the worker's fixed coding-tool catalog before every turn. The launch envelope carries only that final closed-vocabulary subset. Explicitly capped scheduled turns reuse their trusted owner-group context without sending that identity to the box or reapplying a fresh sender overlay. Tools outside the worker catalog remain unavailable; an empty result runs with no tools. - **Minted credentials, hashed at rest.** Each dispatch mints a worker credential; the Gateway stores only its hash. Credential rotation and owner-epoch fencing guarantee at most one live owner per session — a stale worker that reconnects is fenced, never merged. - **Environment-bound enrollment.** One short-lived node-only setup credential is bound to the durable environment before allocation. Its first authenticated Ed25519 device identity is recorded atomically with setup completion; replay cannot substitute an unrelated node. +- **Explicit Codex device authorization.** Paired-device remote execution requires an explicitly allowed `codex.exec-server.stdio.v1` command, an approved pairing surface, and normal node invocation approval. The managed exec-server starts with a fresh private home and sanitized environment; allow-once never grants a later launch. Its managed workspace is not an OS sandbox: approved execution can access processes and files allowed to the node account, so use a separate least-privilege account when isolation is required. - **No standing model, forge, or cloud credentials on the box.** OpenClaw worker turns proxy inference by `{provider, model}` reference. Codex remote-exec keeps the app-server plus ChatGPT subscription or API-key auth on the Gateway and sends only sandbox operations to the box. Remote-exec requires prepared auth and rejects ambient auth fallback. Workspace git commits are authored without forge credentials, and Crabbox AWS lease metadata is checked authoritatively for an instance role before setup. Keep setup commands credential-free too. - **Gateway-owned GitHub publication.** Publication credentials stay in the effective managed or native GitHub profile on the Gateway. The broker disables repository hooks, refuses configured Git clean filters, uses a temporary index and `git commit-tree`, pushes only a reconstructed public HTTPS URL with a command-local `gh auth git-credential` helper, and never writes a bearer token to argv, a remote URL, `.git/config`, a worker payload, or a transcript. - **Provider-owned egress.** Gateway-proxied inference removes any OpenClaw need for direct model access, but OpenClaw does not rewrite provider firewalls. Restrict outbound traffic in the worker provider when the task requires it. diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 76ac2d8f2033..68cbba42ed2c 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -94,7 +94,7 @@ A Gateway can remain healthy for browser users while node hosting is unavailable - **Machine authentication:** Tailscale identity headers do not authenticate node-role connections. In `gateway.auth.mode: "trusted-proxy"`, a new node also cannot supply the proxy's user identity headers. To use a shared token, switch to token mode and configure `gateway.auth.token` with a SecretRef; trusted-proxy mode rejects mixed token configuration. A trusted-proxy Gateway can use `gateway.auth.password` only for clean loopback/direct callers. See [trusted-proxy mixed token configuration](/gateway/trusted-proxy-auth#mixed-token-configuration). - **Node onboarding URL:** With `gateway.bind: "loopback"`, configure Tailscale Serve, `gateway.remote.url`, or `plugins.entries.device-pair.config.publicUrl` before minting a join code. Otherwise `openclaw devices join-code` reports: `Gateway is only bound to loopback. Set gateway.bind=lan, enable tailscale serve, or configure plugins.entries.device-pair.config.publicUrl.` - **Node onboarding plugin:** Join codes and `openclaw connect` require the bundled `device-pair` plugin. If it is disabled or excluded by plugin policy, set `plugins.entries.device-pair.enabled: true`, make sure `device-pair` is allowed, and restart the Gateway. -- **Device session runtime:** Paired-device runners host only the embedded OpenClaw runtime. Give at least one selected agent/model route `agentRuntime.id: "openclaw"`; Codex and ACPX routes cannot dispatch to a paired device. Runtime policy belongs on provider/model routes, not the ignored whole-agent runtime keys. Multi-agent rosters must also set `agents.ownership: "explicit"`. See [runtime policy](/gateway/config-agents#runtime-policy). +- **Device session runtime:** Paired-device runners support the embedded OpenClaw runtime and explicitly authorized Codex `remote-exec`; ACPX routes cannot dispatch to a paired device. Codex requires `codex.exec-server.stdio.v1` in `gateway.nodes.commands.allow` plus its normal pairing and invocation approvals. Runtime policy belongs on provider/model routes, not the ignored whole-agent runtime keys. Multi-agent rosters must also set `agents.ownership: "explicit"`. See [Codex paired-device placement](/plugins/codex-harness#run-codex-on-a-paired-device) and [runtime policy](/gateway/config-agents#runtime-policy). - **Edge routing:** When a reverse proxy or access edge fronts the Gateway, the node must satisfy edge auth on the join request, its main Gateway WebSocket, and the worker WebSocket. Keep WebSocket upgrade enabled for `/__openclaw__/worker`. You can instead exempt `/j/*` and `/__openclaw__/worker` from edge identity auth because both routes enforce their own short-lived credentials. See [worker protocol](/gateway/protocol#worker-role-and-closed-protocol). For a Cloudflare Access-fronted Gateway: @@ -496,11 +496,15 @@ most two worker processes by default. A third launch waits up to 10 seconds for a durable slot; while both slots are occupied, the node remains available for status and cancellation but is not selected for a new session turn. -The picker derives every device row from `environments.list`. A device is -selectable only when current inventory reports status `available`, -`sessionHost: true`, valid exact worker slots, and at least one available slot. -Connected non-hosts, saturated hosts, hosts without current capacity, -update-required or otherwise outdated hosts, and unavailable hosts remain +The picker derives every device row from `environments.list`. Every selected +runtime requires an available, connected paired session host. OpenClaw worker +turns additionally require valid exact worker slots with at least one free +slot. Codex paired-device execution launches its exec-server directly, so it +does not consume or require a worker slot; instead, its required command must +appear in the node's effective `invocableCommands`, not merely its declared +capabilities. A declared command is usable only when the approved pairing and +Gateway command allowlist both authorize it. Connected non-hosts, ineligible +or saturated hosts, update-required devices, and unavailable hosts remain visible but disabled with an actionable reason. Enable hosting with `openclaw connect --service --session-host` or the `nodeHost.workerRuns` setting, then restart the node host. Update-required hosts must be upgraded and @@ -520,8 +524,8 @@ process-current, not a terminal placement state. `sessions.list` and until that exact current-v6 node runner reconnects. Gateway restart therefore shows an active device placement as offline until reconnect; current inventory then changes the projection to `available` and emits a session refresh. Exact -worker slots gate new placements only and do not affect availability of a -session the device already owns. +worker slots gate only new placements whose runtime consumes a worker slot; +they do not affect Codex remote execution or an existing session's availability. Control UI shows **Device offline** and waits by default without giving up the placement, workspace, or authority. Retry the next turn after the device diff --git a/docs/plan/runners.md b/docs/plan/runners.md index fa584c401dd8..bf563bcf5c16 100644 --- a/docs/plan/runners.md +++ b/docs/plan/runners.md @@ -410,11 +410,13 @@ The bundled Crabbox provider now boots the box and runs directory. The Gateway persists one replay-safe setup identity, atomically binds the authenticated device identity to the worker environment, pushes the current bundle through the node channel, and removes the node role after -provider teardown. `destroy` = release lease plus pairing cleanup. Codex -remote-exec fails before allocation because it still requires an SSH-backed -provider. The replaced reverse-tunnel/rsync cloud carrier has been deleted. -Distinct stable SSH, OpenShell, Claude, and exec-host contracts remain until -the missing node exec-server carrier supplies and proves equivalent behavior. +provider teardown. `destroy` = release lease plus pairing cleanup. Codex now +supports paired-device `remote-exec` over the approved duplex node carrier; +disconnect ends the attempt, and reconnect starts a fresh attempt without +resume. Crabbox cloud profiles remain `worker-turn` only. The replaced +reverse-tunnel/rsync cloud carrier has been deleted. Distinct stable SSH, +OpenShell, Claude, and exec-host contracts remain intact; broader replacement +and reconnect or resume are later work. ## What the adversarial reviews killed or reshaped diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 343c73dd91e5..7e86b927e3a0 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -272,7 +272,7 @@ listed here. | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles; default `persistence` is `runtime-only` for CLI/app-owned creds | Provider reuses external auth credentials without persisting copied refresh tokens; declare `contracts.externalAuthProviders` in the manifest | | `shouldDeferSyntheticProfileAuth` | Lower stored synthetic profile placeholders behind env/config-backed auth | Provider stores synthetic placeholder profiles that should not win precedence | | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids | -| `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids | +| `prepareDynamicModel` | Return an asynchronously prepared model, or warm reusable metadata before retrying `resolveDynamicModel` | Provider needs network metadata before resolving unknown ids | | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport | | `normalizeToolSchemas` | Normalize tool schemas before the embedded runner sees them | Provider needs transport-family schema cleanup | | `inspectToolSchemas` | Surface provider-owned schema diagnostics after normalization | Provider wants keyword warnings without teaching core provider-specific rules | diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 2f1b1d381ade..bd5ade6fb546 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -244,12 +244,17 @@ loads the owning plugin runtime. Tool factories receive trusted runtime context, including `deliveryContext`, `nativeChannelId` for the active platform conversation when available, and -`requesterSenderId`. +`requesterSenderId`. A factory can use +`toolContext.delivery?.send({ text, mediaUrl })` to send text or media to the +current conversation. The property is unavailable outside an active channel +turn or when the channel uses Gateway-owned delivery. OpenClaw binds the route, +account, thread, and media access policy; the capability expires when the turn +ends. ```typescript register(api) { api.registerTool( - { + (toolContext) => ({ name: "workflow_tool", description: "Run a workflow", parameters: Type.Object({ pipeline: Type.String() }), @@ -258,13 +263,16 @@ register(api) { { additionalProperties: false }, ), async execute(_id, params) { + await toolContext.delivery?.send({ + text: `Workflow started: ${params.pipeline}`, + }); return { content: [{ type: "text", text: params.pipeline }], details: { pipeline: params.pipeline }, }; }, - }, - { optional: true }, + }), + { name: "workflow_tool", optional: true }, ); } ``` diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 2680a2e13451..db304bddf313 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -482,6 +482,28 @@ This preview path is local-only. A remote WebSocket app-server cannot reach the loopback exec-server unless it is running on the same host, so OpenClaw rejects that combination. +Paired-device `remote-exec` placement is a separate, placement-owned execution +path and does not require `appServer.experimental.sandboxExecServer`. The +Gateway keeps Codex app-server and provider auth local, while the authorized +paired device runs the managed Codex exec-server over its existing duplex node +connection. It requires explicit `gateway.nodes.commands.allow` authorization +for `codex.exec-server.stdio.v1`, the approved pairing surface, and normal node +invocation approval. The node receives a fresh private home and sanitized +environments, never Gateway provider, cloud, or GitHub credentials. A lost +node connection terminates the attempt and process instead of resuming it. +Each paired-device attempt uses its own Gateway app-server client because +Codex can register a remote environment but cannot remove one from a running +app-server. The device exec-server does not consume an OpenClaw worker slot. +HTTP requests containing authentication, cookies, API keys, or other +credential-bearing headers are rejected before reaching the device; use a +Gateway-owned authenticated request or a credential-free endpoint instead. +Normal Codex turns are supported, but `/btw` side questions are unavailable +until they can be bound to the active placement. +The managed placement workspace is not an OS sandbox: approved processes and +files have the node account's full access. Use a separate least-privilege node +account when isolation is required. +See [Run Codex on a paired device](/plugins/codex-harness#run-codex-on-a-paired-device). + ## Auth and environment isolation In the default per-agent home, managed stdio launches use Codex's ephemeral @@ -808,14 +830,67 @@ the fallback catalog: } ``` +## Restricted turns + +The Codex harness evaluates the effective tool policy for every turn. It marks +the turn policy-restricted when any explicit policy would otherwise leave a +Codex-native capability outside the OpenClaw policy boundary. + +Restriction sources include global, provider, agent, group, sender, sandbox, +subagent, inherited, scheduled/runtime, and per-run tool policies. A finite +allowlist always restricts the native surface. A deny list restricts it when an +expanded entry is unknown or absent from the audited safe-deny set; this includes +wildcards and tool groups containing any unsafe entry. `disableTools` becomes an +empty per-run allowlist and therefore also restricts the native surface. Default +tool-profile narrowing is not an explicit restriction and does not activate this +mode. + +The current audited safe-deny names are: + +```text +automations, canvas, dashboard, gateway, heartbeat_respond, image_generate, +memory_get, memory_search, message, music_generate, show_widget, skill_workshop, +tts, video_generate, web_fetch, x_search +``` + +A policy containing only those denies stays on the normal Codex native surface; +the harness applies the named OpenClaw denial directly. Any other deny fails +closed into the restricted surface. For example, `tools.deny: ["nodes"]` +restricts the native surface because `nodes` is not in the audited set. + +Policy-restricted turns have no Codex environment selection or native Code Mode. +OpenClaw disables inherited and configured MCP servers, attests that they remain +disabled, disables native hook relays, and applies the effective policy to its +dynamic tools. A temporary restriction on an existing session uses a transient +Codex thread and preserves the unrestricted binding for later resume. + +Ring zero is not a configurable policy profile. It is the host-scoped system +agent path used by OpenClaw setup and repair flows. The host must activate the +system-agent authority and provide the exact single-tool allowlist +`["openclaw"]`. Ring zero applies the restricted tool surface plus host-authored +base instructions and zero project-document budget. It also suppresses +OpenClaw's `AGENTS.md` developer-instruction carrier, so ambient workspace +instructions cannot enter the setup/repair turn. + +Message-only source replies also use the restricted tool surface. Lightweight +bootstrap turns and tool-disabled internal turns additionally set the project- +document budget to zero. These modes are separate inputs even when their final +thread configuration overlaps. + ## Workspace bootstrap files -Codex handles `AGENTS.md` itself through native project-doc discovery. +Codex normally handles `AGENTS.md` itself through native project-doc discovery. OpenClaw does not write synthetic Codex project-doc files or depend on Codex fallback filenames for persona files, because Codex fallbacks only apply when -`AGENTS.md` is missing. +`AGENTS.md` is missing. Ordinary policy-restricted turns have no native +filesystem environment, so OpenClaw instead sends the bounded workspace +`AGENTS.md` snapshot as thread-level developer instructions. Ring-zero, +lightweight, message-only, and tool-disabled internal turns suppress that +carrier. -For OpenClaw workspace parity, local tool notes live in the `## Tools` section of `AGENTS.md` and ride Codex's native project-doc discovery. The Codex harness forwards the other bootstrap files as developer instructions: +For OpenClaw workspace parity, local tool notes live in the `## Tools` section +of `AGENTS.md` and normally ride Codex's native project-doc discovery. The +Codex harness forwards the other bootstrap files as developer instructions: - `SOUL.md`, `IDENTITY.md`, and `USER.md` are forwarded as **turn-scoped** collaboration instructions. Native Codex subagents do not inherit them, diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index f61eb3dfb0ea..83e8ddab232c 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -45,8 +45,11 @@ it uses Codex-flavored OpenAI auth or transport. OpenClaw starts and resumes native Codex threads with Codex's built-in personality disabled (`personality: "none"`) so workspace personality files and OpenClaw agent identity stay authoritative. Native Codex keeps Codex-owned -base/model instructions and project-doc loading otherwise. Lightweight -OpenClaw runs (for example cron) still suppress project-doc loading. +base/model instructions and project-doc loading otherwise. An ordinary +policy-restricted turn has no native filesystem environment, so OpenClaw carries +the bounded workspace `AGENTS.md` snapshot as thread-level developer +instructions instead. Lightweight, ring-zero, message-only, and tool-disabled +internal turns suppress project-doc loading and that fallback carrier. OpenClaw developer instructions cover OpenClaw runtime concerns: source-channel delivery, OpenClaw dynamic tools, ACP delegation, adapter context, and the @@ -220,23 +223,38 @@ process. Failed environment registration never falls back to host execution. See [Sandboxed native execution](/plugins/codex-harness-reference#sandboxed-native-execution) for configuration and local-only transport restrictions. +Paired-device `remote-exec` is separate from the experimental local sandbox +flag: Codex app-server and model auth stay on the Gateway, while an explicitly +authorized managed exec-server on the node owns process, filesystem, capability, +and credential-free HTTP operations. The Gateway rejects authentication, +cookie, API-key, and other sensitive HTTP headers before they reach the node; +authenticated HTTP must run on the Gateway. The existing duplex node channel +carries the Codex JSON-RPC stream without starting an OpenClaw worker child or +consuming a worker slot. Each attempt owns an isolated Gateway app-server +client so its remote environment registration retires with that attempt. +Disconnect ends the active attempt and its remote processes; reconnect allows +only a fresh attempt. Normal Codex turns work, but `/btw` side questions fail +closed because they are not yet placement-bound. The placement workspace does +not confine execution: process and filesystem access remain bounded only by the +node's operating system account. + ## V1 support contract Supported in Codex runtime v1: -| Surface | Support | Why | -| --------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. | -| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. | -| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. | -| Prompt and context plugins | Supported | OpenClaw projects OpenClaw-specific prompt/context into the Codex turn while leaving Codex-owned base, model, and configured project-doc prompts in the native Codex lane. OpenClaw disables Codex's built-in personality for native threads so agent workspace personality files remain authoritative. Native Codex developer instructions accept only command guidance explicitly scoped to `codex_app_server`; legacy global command hints remain for non-Codex prompt surfaces. | -| Context engine lifecycle | Supported | Assemble, ingest, and after-turn maintenance run around Codex turns. Context engines do not replace native Codex compaction. | -| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. | -| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. | -| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. | -| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on the pinned Codex app-server. Blocking is supported; argument rewriting is not. | -| Native permission policy | Supported through Codex app-server approvals and compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. | -| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. | +| Surface | Support | Why | +| --------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. | +| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. | +| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. | +| Prompt and context plugins | Supported | OpenClaw projects OpenClaw-specific prompt/context into the Codex turn while normally leaving Codex-owned base, model, and configured project-doc prompts in the native Codex lane. For ordinary policy-restricted turns without a native filesystem environment, OpenClaw carries the bounded workspace `AGENTS.md` snapshot as thread-level developer instructions. Ring-zero and other context-restricted internal modes suppress both paths. OpenClaw disables Codex's built-in personality for native threads so agent workspace personality files remain authoritative. Native Codex developer instructions accept only command guidance explicitly scoped to `codex_app_server`; legacy global command hints remain for non-Codex prompt surfaces. | +| Context engine lifecycle | Supported | Assemble, ingest, and after-turn maintenance run around Codex turns. Context engines do not replace native Codex compaction. | +| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. | +| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. | +| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. | +| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on the pinned Codex app-server. Blocking is supported; argument rewriting is not. | +| Native permission policy | Supported through Codex app-server approvals and compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. | +| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. | Not supported in Codex runtime v1: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index b4f720ea8f30..d8ab79825be3 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -45,8 +45,10 @@ When no OpenClaw sandbox is active, OpenClaw starts Codex app-server threads with Codex native code mode enabled (code-mode-only stays off by default), so native workspace/code capabilities remain available alongside OpenClaw dynamic tools routed through the app-server `item/tool/call` bridge. An -active OpenClaw sandbox or restricted tool policy disables native code mode -entirely unless you opt into the experimental sandbox exec-server path. +ordinary OpenClaw sandbox or restricted tool policy disables native code mode +unless you opt into the experimental sandbox exec-server path. Paired-device +`remote-exec` instead uses its placement-owned environment without that +experimental flag. Eligible native-shell turns also retain `gateway_exec` and `gateway_process` as a distinct OpenClaw execution path. Use `gateway_exec` only when a command @@ -152,6 +154,90 @@ Restart the gateway after changing plugin config. If a chat already has a session, run `/new` or `/reset` first so the next turn resolves the harness from current config. +## Run Codex on a paired device + +Codex sessions can place native command, filesystem, capability-discovery, and +HTTP execution on an eligible paired device while the Codex app-server, model +inference, provider authentication, and session transcript stay on the Gateway. +This is session-wide `remote-exec` placement, not `node_exec` or +`tools.exec.host: "node"`. + +Install and enable the Codex plugin in both the Gateway's configuration and the +paired node's own local configuration. If either machine uses `plugins.allow`, +include `codex` in that machine's allowlist. On the Gateway, explicitly allow +the high-risk node command: + +```json5 +{ + gateway: { + nodes: { + commands: { + allow: ["codex.exec-server.stdio.v1"], + }, + }, + }, + plugins: { + entries: { + codex: { + enabled: true, + }, + }, + }, +} +``` + +The paired node must enable session hosting and advertise the `codex.exec-server` +capability and `codex.exec-server.stdio.v1` command. If enabling the plugin +changes an existing node's command surface, reconnect the node, inspect +`openclaw nodes pending`, and approve the updated pairing with +`openclaw nodes approve `. The persistent command allowlist does not +replace the normal node invocation approval: deny starts no Codex process, and +allow-once authorizes exactly one exec-server launch. + +Codex launches its node exec-server directly rather than starting an OpenClaw +worker, so a paired host remains eligible when all worker slots are occupied. +The command must still be effectively invocable: declaring it without the +approved pairing surface and Gateway allowlist is insufficient. + +Approval grants access to any process or file available to the node's operating +system account. The verified placement workspace sets the working directory +and reconciliation scope; it does not sandbox or confine that access. Pair only +trusted devices, and run the node under a separate least-privilege OS account +when isolation is required. + +Choose the paired device in the Control UI **Place** picker, or dispatch an +existing managed-worktree session explicitly: + +```bash +openclaw gateway call sessions.dispatch \ + --params '{"key":"agent:main:device-work","deviceId":""}' +``` + +The node starts the same managed, pinned Codex binary with +`codex exec-server --listen stdio` in the placement workspace. The Gateway +relays complete Codex JSON-RPC messages through the existing authenticated, +approval-gated duplex node channel, with a 64 MiB limit per message. It does not +start an OpenClaw worker child, open a reverse tunnel, or copy provider, cloud, +or GitHub credentials to the device. Authenticated remote HTTP is unavailable: +the Gateway rejects requests containing bearer/OAuth authorization, cookies, +API keys, or other sensitive authentication headers before sending them to the +node. Run authenticated HTTP on the Gateway, or use an intentionally +credential-free endpoint. The node process uses a fresh private +`HOME` and `CODEX_HOME` that are removed after the attempt, and both its launch +environment and requested child-process environments are sanitized. Completed +filesystem changes reconcile back into the Gateway-owned managed worktree. + +Disconnecting the node, closing the app-server connection, cancelling the turn, +or retiring the plugin ends that Codex attempt visibly and terminates its remote +exec-server process. Each paired-device attempt owns an isolated Gateway +app-server client, preventing remote environment registrations from +accumulating across attempts. Reconnecting the same paired device permits a +fresh attempt; it never resumes the disconnected stdio connection or its +processes. Normal Codex turns are supported, but `/btw` side questions are not +yet bound to paired-device placement and fail with an actionable explanation. +See [Cloud workers and paired-device placement](/gateway/cloud-workers) and +[Node command policy](/nodes#command-policy). + ## Share threads with Codex Desktop and CLI The default `appServer.homeScope: "agent"` isolates each OpenClaw agent from @@ -271,13 +357,63 @@ Changing auth order does not make a custom, Completions, HTTP, or request-overridden route Codex-compatible. Valid model-scoped Fast-mode and cutoff controls are runtime controls, not request overrides. +### Restricted turns and ring zero + +OpenClaw applies Codex restrictions per turn, not as a permanent session mode. +An existing session can therefore run one restricted turn and return to its +normal Codex thread on the next unrestricted turn. When a restriction is +temporary, OpenClaw preserves the normal thread binding and uses a temporary +restricted thread where necessary. + +An ordinary **policy-restricted turn** occurs when an explicit OpenClaw tool +policy cannot be mapped safely onto Codex's native tool surface. Common +triggers include: + +- a finite `tools.allow` list or an internal per-run allowlist +- `disableTools` or a sender/group policy that denies all tools +- a `tools.deny` entry with a wildcard, tool group, unknown name, or name that + is not in the Codex harness's audited safe-deny set +- an applicable agent, provider, group, sender, sandbox, subagent, inherited, + scheduled, or runtime tool policy with one of those restrictions + +Default tool-profile narrowing alone does not trigger this mode. A deny list +containing only audited OpenClaw-owned tools can also stay on the normal native +surface; the harness enforces those denies without disabling unrelated Codex +capabilities. See [Native tool-policy enforcement](/plugins/sdk-agent-harness#native-tool-policy-enforcement) +for the generic harness contract and [Codex harness reference](/plugins/codex-harness-reference#restricted-turns) +for the current Codex rules. + +For an ordinary policy-restricted turn, OpenClaw disables Codex native Code +Mode, removes environment selections, disables and verifies inherited and +configured MCP servers, disables native hook relays, and filters OpenClaw +dynamic tools through the effective policy. The bounded workspace `AGENTS.md` +snapshot still reaches the model as thread-level developer instructions because +project instructions are context, not tool authority. + +**Ring zero** is stronger and separate. It is the host-owned OpenClaw system +agent used for setup and repair operations. The host activates it with the +single `openclaw` tool; normal agent config cannot opt a chat into ring zero. +Ring-zero turns keep only that host-scoped tool, replace ambient Codex +instructions with host-authored setup instructions, disable native tools and +MCP servers, and suppress workspace project documents, including the +`AGENTS.md` developer-instruction carrier. + +Other narrow internal modes also suppress project documents: lightweight +bootstrap turns, message-only source replies, and tool-disabled internal turns. +They share some isolation settings with policy-restricted turns but are not +synonyms for ring zero. + ### Project instructions Codex loads `AGENTS.md` files through native project-document discovery. For normal app-server threads, OpenClaw raises Codex's aggregate root-to-working- directory budget from the upstream 32 KiB default to a bounded 128 KiB so later -scoped instructions are not silently clipped. Lightweight and restricted turns -set the native project-document budget to zero instead. +scoped instructions are not silently clipped. Ordinary conversation tool-policy +restrictions preserve that budget because project instructions are context, not +tool authority. Their isolated native environment cannot read workspace files, +so OpenClaw supplies the bounded workspace `AGENTS.md` snapshot as thread-level +developer instructions. Lightweight, ring-zero, message-only, and tool-disabled +internal turns set the native project-document budget to zero instead. This byte budget is separate from the character-based workspace bootstrap limits configured through `agents.defaults.bootstrapMaxChars` and diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 71090108eb79..b2cbfa03485d 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -350,11 +350,41 @@ normalizes numeric thread ids the same way core does, so prefer it over ad hoc should expose `messaging.resolveOutboundSessionRoute(...)` so core gets provider-native session and thread identity without parser shims. +### Conversation route ownership + +Implement `messaging.resolveConversationRouteOwner(...)` when generic route +matching cannot reproduce the channel's configured and runtime binding rules. +The resolver receives the current config, account, and recorded conversation +identity, including a delivery `target` when it differs from the routing peer. +It must reuse the same precedence and provider identity grammar as inbound +routing. + +Ownership inspection is synchronous and read-only. Do not refresh binding +liveness, perform network requests, or infer missing provider facts. Return: + +- `{ kind: "agent", agentId }` for an agent-owned route. +- `{ kind: "plugin", pluginId, fallbackAgentId }` for a plugin-owned runtime + binding. `fallbackAgentId` is the route used when that plugin has no active + inbound claim handler. +- `{ kind: "unavailable" }` when authoritative owner state is temporarily + unavailable and the caller should retry. +- `null` when the supplied identity is invalid or cannot be authorized. +- `undefined` to delegate to core's generic owner resolution. + +Keep temporary unavailability distinct from `null`: an adapter restart is not +proof that a previously bound conversation is unowned. +Use `inspectConversationBinding(...)` from +`openclaw/plugin-sdk/conversation-binding-inspection-runtime` when the resolver needs this +available/unavailable distinction. + ### Account-scoped conversation binding support Set `conversationBindings.supportsCurrentConversationBinding` when the channel supports generic current-conversation bindings. `createChatChannelPlugin(...)` -sets this static capability to `true` by default. +sets this static capability to `true` by default. Channels whose monitor owns a custom binding +adapter must also set `bindingStore: "adapter"`; core then fails closed while +that adapter is unavailable instead of reading or writing generic binding rows. +Older `createManager`-only plugins retain the same adapter-owned behavior. If support differs by configured account, also implement `conversationBindings.isCurrentConversationBindingSupported({ accountId })`. diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index 9b24294591cd..eda32122a4c0 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -485,8 +485,10 @@ catalog, API-key auth, and dynamic model resolution. }); ``` - If resolving requires a network call, use `prepareDynamicModel` for async - warm-up - `resolveDynamicModel` runs again after it completes. + If resolving requires a network call, return the requested model directly + from `prepareDynamicModel`. OpenClaw applies the same configured overrides + and normalization as synchronous dynamic resolution. Existing hooks that + return nothing still retry `resolveDynamicModel` after preparation. @@ -683,7 +685,7 @@ catalog, API-key auth, and dynamic model resolution. | `resolveExternalAuthProfiles` | Overlay provider-owned external auth profiles for CLI/app-managed credentials | | `shouldDeferSyntheticProfileAuth` | Lower synthetic stored-profile placeholders behind env/config auth | | `resolveDynamicModel` | Accept arbitrary upstream model IDs | - | `prepareDynamicModel` | Async metadata fetch before resolving | + | `prepareDynamicModel` | Return an asynchronously discovered model, or warm reusable metadata before sync resolution | | `normalizeResolvedModel` | Transport rewrites before the runner | | `normalizeToolSchemas` | Provider-owned tool-schema cleanup before registration | | `inspectToolSchemas` | Provider-owned tool-schema diagnostics | diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index e06a243b06ec..43c991d768ec 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -536,12 +536,18 @@ snapshots; OpenClaw owns all persistence and lifecycle coordination. `openDuplex` accepts the same node, command, parameters, timeout, idempotency key, session key, caller signal, and requested scopes as - `nodes.invoke`, plus an optional `maxMessageBytes`. The limit defaults to - 100 MiB and can be reduced, but never increased beyond 100 MiB. OpenClaw - splits each binary message into ordered 8 KiB payload fragments that fit the - existing 16 KiB transport-frame limit; callers always send and receive - complete `Uint8Array` messages. Concurrent sends preserve message - boundaries. + `nodes.invoke`, plus optional `maxMessageBytes` and + `maxOutstandingDeliveryBytes` limits. The per-message limit defaults to + 100 MiB and can be reduced, but never increased beyond 100 MiB. + `maxOutstandingDeliveryBytes` bounds the combined size of complete messages + whose asynchronous listener callbacks have not settled; it defaults to + `maxMessageBytes`, cannot be smaller than that limit, and cannot exceed + 100 MiB. A protocol that can follow a maximum-sized response with a bounded + asynchronous notification may request a larger outstanding-delivery budget + without raising its per-message ceiling. OpenClaw splits each binary message + into ordered 8 KiB payload fragments that fit the existing 16 KiB + transport-frame limit; callers always send and receive complete + `Uint8Array` messages. Concurrent sends preserve message boundaries. Register the channel's single message listener immediately after `openDuplex` resolves. Before a listener is registered, OpenClaw buffers at diff --git a/docs/plugins/tool-plugins.md b/docs/plugins/tool-plugins.md index dffa950f9999..814385802fa4 100644 --- a/docs/plugins/tool-plugins.md +++ b/docs/plugins/tool-plugins.md @@ -155,6 +155,12 @@ tool({ }); ``` +Factories can use `toolContext.delivery?.send({ text, mediaUrl })` for outbound +messages in the active conversation. The host chooses the destination, +account, thread, and local-media policy; plugins cannot retarget this helper, +and retained copies stop working after the turn closes. The helper is unavailable +for channels whose delivery is owned by a Gateway transport. + Factories still declare a fixed tool name up front. Use `definePluginEntry` directly when the plugin computes tool names dynamically or combines tools with hooks, services, providers, or commands. diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 74bbb84ecf46..80530d158da7 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -410,6 +410,17 @@ The second parent reuses product evidence only when GitHub proves the Release SH For a fresh Code SHA, the workflow resolves the target, dispatches manual `CI`, then dispatches `OpenClaw Release Checks`. Beta-publish maps to `release_profile=beta` and `run_release_soak=false`; its `all` run excludes broad live/E2E and QA-live lanes. Postpublish-confidence uses the exact published package with soak or explicit focused groups. Stable-publish maps to `release_profile=stable`. The final verifier summary includes slowest-job tables for each child run. +Each dispatcher records the exact child run ID and attempt, then exits. Release +Decision reports a decisive blocker without waiting for unrelated diagnostic +tails; with `fail_fast=false`, Diagnostic Drain keeps the selected children +running to terminal. Diagnose `blocked_diagnostics_running` immediately, but do +not retry until the drain is terminal. Recover `orchestration_error` against +the same exact children and never redispatch tests merely to repair collection. +An immutable run-bound execution plan preserves the original attempt, titles, +coverage, gates, and child tuples across collector retries. The final verifier +consumes that plan and the exact attempt-bound Decision and Drain artifacts +instead of polling or reclassifying child results. + The product-performance child is artifact-only in this release path. The umbrella dispatches it with `publish_reports=false`, and validation is rejected unless its artifact-only guard proves that the Clawgrit report publisher stayed diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index f9823357f554..1ed7512e56c0 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -38,6 +38,12 @@ operator's explicit offline-device abandonment decision so restart recovery cannot accidentally resume remote reconciliation. Older readers ignore the column and can reopen the same database safely. +Conversation associations use the same rule for the nullable bare +`route_context_json TEXT` column. The database-open repair ensures the column +for updated binaries. Older readers ignore it and can reopen and update the +same database safely; their association update invalidates context captured by +a newer writer so it cannot be replayed after re-upgrade. + Installing OpenClaw manually through npm bypasses the updater guard. Database open checks still refuse an incompatible build. ## Preflight a target release diff --git a/docs/reference/full-release-validation.md b/docs/reference/full-release-validation.md index 447882446dcb..df9c909584e3 100644 --- a/docs/reference/full-release-validation.md +++ b/docs/reference/full-release-validation.md @@ -39,12 +39,34 @@ exact alpha tags to the `beta` profile and final versions to `stable`. Pass alternate workflow inputs with `-f key=value`; use `-f release_profile=full` only for the broad advisory sweep. `fail_fast` defaults to `false`, so dispatched child workflows finish and expose -independent failures together. Pass `-f fail_fast=true` when the shorter -first-failure cancellation path is preferable. +independent failures together. In that mode, the parent makes no child +cancellation calls. Pass `-f fail_fast=true` only when the shorter +first-failure path is preferable; Release Decision then cancels only the exact +still-active child that owns the blocking failure. + +After dispatch, the parent writes one immutable +`full-release-execution-plan-` artifact. It records selected and +required coverage, gate results, reuse identity, the original parent attempt, +and every exact child run ID, attempt, title, workflow ref, and Tooling SHA. +Decision, Drain, manifest generation, evidence verification, and the final +verifier consume this artifact. Collector retries restore it and adopt the +same children; they never rebuild the plan or redispatch tests. +Release Decision also repeats canonical reuse-chain validation before a reused +run can pass. The sealed target SHA, evidence SHA, policy, changed-path set, +selected run, root run, source manifest, trusted tooling identity, and child +tuple must all still match. + +On a parent retry, final verification selects the newest available Release +Decision and Diagnostic Drain artifacts independently. Both must bind the same +immutable plan and exact child tuple; their source attempts remain recorded in +the artifacts and may differ when only one collector needed a retry. The helper creates a temporary `release-ci/*` ref pinned to the Tooling SHA, passes the Validation SHA as both the candidate ref and `expected_sha`, and -deletes the temporary ref after validation. The Validation SHA equals the Code +deletes the temporary ref after successful validation and strict evidence +verification. If Release Decision reports a blocker while Diagnostic Drain is +still collecting failures, the helper exits nonzero immediately and keeps both +temporary refs for reruns and diagnosis. The Validation SHA equals the Code SHA for product validation or the Release SHA for changelog-only validation; it is not a third release identity. The workflow rejects malformed or mismatched expected SHAs before child dispatch. Every child must report the same Tooling @@ -168,7 +190,26 @@ it before dispatching. A narrower `rerun_group` skips this preflight. | Release checks | **Job:** `Run release/live/Docker/QA validation`
**Child workflow:** `OpenClaw Release Checks`
**Proves:** install smoke, cross-OS package checks, Package Acceptance, and QA Lab parity. QA-live Matrix, Buzz, and Telegram plus gated advisory Discord, WhatsApp, and Slack run for stable/full, beta with `run_release_soak=true`, an explicit `qa-live` controller retry, or the direct child's manual `qa` aggregate. Stable and full profiles also run exhaustive live/E2E suites and Docker release-path chunks.
**Rerun:** classify the failed surface and select one concrete release-check group. | | Package Telegram | **Job:** `Run package Telegram E2E`
**Child workflow:** `NPM Telegram Beta E2E`
**Proves:** a focused published-package Telegram E2E when `release_package_spec` or `npm_telegram_package_spec` is set. Full candidate validation uses the canonical Package Acceptance Telegram E2E instead.
**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. | | Product performance | **Job:** `Run product performance evidence`
**Child workflow:** `OpenClaw Performance`
**Proves:** release-profile performance run (`profile=release`, `repeat=3`, `fail_on_regression=true`, `publish_reports=false`) against the target SHA. Kova output stays in workflow artifacts and the child must prove its report publisher was skipped. Required (blocking) only for `rerun_group=all` or `rerun_group=performance`; not required for narrower rerun groups.
**Rerun:** `rerun_group=performance`. | -| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.
**Rerun:** rerun only this job after rerunning a failed child to green. | +| Release decision | **Job:** `Release Decision`
**Child workflow:** none
**Proves:** polls the exact recorded child run IDs and attempts, enforces release policy, and publishes an attempt-bound decision artifact. A decisive failure becomes `blocked_diagnostics_running` while unrelated child diagnostics continue.
**Rerun:** fix or rerun only the blocking surface. | +| Diagnostic drain | **Job:** `Diagnostic Drain`
**Child workflow:** none
**Proves:** with `fail_fast=false`, follows every selected exact child to terminal without cancellation and writes timing, failed-job, run-attempt, and Tooling-SHA evidence. Collector cancellation instead writes an immediate `cancelled_with_children` handoff containing active child identities.
**Rerun:** recover collection only for `orchestration_error`; product failures do not invalidate the drain. | +| Execution plan | **Job:** `Seal release execution plan`
**Child workflow:** none
**Proves:** persists the original parent attempt, exact child identities and titles, required coverage, gates, and reuse identity in a stable run-bound artifact. Attempt-two collector recovery restores this artifact instead of redispatching.
**Rerun:** restore the existing plan only; a missing plan is an orchestration error. | +| Umbrella verifier | **Job:** `Verify full validation`
**Child workflow:** none
**Proves:** downloads the immutable execution plan plus the exact attempt-bound Release Decision and Diagnostic Drain artifacts, verifies their common digest and parent tuple, and accepts only a strict green decision plus terminal drain.
**Rerun:** recover the existing collectors or rerun only the failed product surface; the verifier never reclassifies or redispatches children. | + +The five child-dispatch jobs own dispatch and exact identity capture only. They +emit the child run ID, run attempt, and URL, then finish. Release Decision owns +the blocking answer; Diagnostic Drain owns complete terminal evidence. The +immutable execution plan owns child identity across collector attempts. The +decision state is one of `qualifying`, `blocked_diagnostics_running`, `passed`, +`blocked_complete`, `orchestration_error`, or `cancelled_with_children`. +Persistent GitHub API failures are orchestration errors. A child whose workflow +path, display title, ref, Tooling SHA, run ID, or attempt changes is a distinct +provenance mismatch. + +`blocked_diagnostics_running` is safe for immediate diagnosis but not for a +retry until Diagnostic Drain is terminal. `orchestration_error` authorizes +collector recovery against the same exact child identities, never test +redispatch. `blocked_complete` means diagnostics are complete; it does not +claim a drain is still running. The umbrella always dispatches product performance in artifact-only mode. `OpenClaw Performance` permits report publication only for scheduled runs or a @@ -190,9 +231,13 @@ as a transition, it accepts the stable name only for an attempt-1 manifest v2 producer. It rejects that legacy name for later attempts and manifest v3. Concurrency is keyed by Validation SHA, Tooling SHA, and rerun group and does -not cancel an older run. Parent cancellation or timeout leaves an adopted -identity-checked child running. Cancel that exact child explicitly when it is -no longer useful. +not cancel an older run. Parent cancellation or timeout leaves adopted +identity-checked children running and records `cancelled_with_children` when +the state collector can complete its cancellation handoff. Cancel an exact +child explicitly when it is no longer useful. Do not run a second foreground +watcher when the SHA-pinned helper already owns the parent; use +`release-ci-summary --watch` only after the helper has returned or when the +parent was dispatched separately. ## Release checks stages diff --git a/docs/reference/test.md b/docs/reference/test.md index b2103d742a10..5f24376016a7 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -198,7 +198,7 @@ If `pnpm test` flakes on a loaded host, rerun once before treating it as a regre - `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets. `pnpm test:perf:imports:changed` scopes the same profiling to files changed since `origin/main`. - `pnpm test:perf:changed:bench -- --ref ` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff; `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first. - `pnpm test:perf:profile:main` writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`); `pnpm test:perf:profile:runner` writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`). -- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. +- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. ## Benchmarks diff --git a/docs/tools/show-widget.md b/docs/tools/show-widget.md index 73191e1960ce..3586a2f02887 100644 --- a/docs/tools/show-widget.md +++ b/docs/tools/show-widget.md @@ -133,13 +133,17 @@ Accepted prompts appear in the transcript as regular user messages and start a n ## Dashboard capabilities -Pinned widgets can use one ticket-bound host API after the operator reviews the declaration shown on the pending card: +Pinned widgets expose one ticket-bound host API. Calls that require declared capabilities work only after the operator reviews the declaration shown on the pending card: +- `openclaw.host.controlUiBaseUrl` exposes the Control UI origin plus its configured base path after the dashboard host initializes. It is `null` before initialization and outside the dashboard, so read it in the link's click handler rather than when the widget script first runs. - `openclaw.prompt.send(text)` requires transient user activation and posts a visible composer message. Declaring and receiving the `prompt` tool grant skips the extra per-click confirmation; validation, focus checks, and rate limits still apply. - `openclaw.state.emit(payload)` adds a session notice. Payloads are capped at 8 KiB, and identical client emissions within five seconds are coalesced. - `openclaw.data.read(bindingId, params?)` resolves only at the Gateway. Grantable bindings are `sessions.list`, `usage.status`, `usage.cost`, `cron.list`, `cron.status`, `agents.list`, and `health`. +- `openclaw.action.run(actionId, params?)` invokes an operator-granted plugin dashboard action verb through its write-scoped Gateway method. - `openclaw.cron.trigger(jobId)` runs an existing job now only when the exact `cron.trigger:` capability was granted. +Links are ordinary user navigation, not a granted host capability. Rendered widgets can open user-clicked links in a new tab; use `target="_blank"` with `rel="noopener noreferrer"` so the dashboard stays open and the destination cannot retain an opener reference. + Network access is separate from host tools. Put exact HTTPS origins in `capabilities.netOrigins`; after approval, only those origins enter the widget's `connect-src`. Wildcards, credentials, paths, query strings, and undeclared origins remain blocked. A literal port is allowed only when it is part of the declared origin. ## Security and storage diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index e1e5bc3a7198..30a61a7991e1 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -331,7 +331,7 @@ select it to open the owning Approvals page. - - Debug: status/health/models snapshots, event log, and manual RPC calls (`status`, `health`, `models.list`). + - Debug: status/health/models snapshots, event log, manual RPC calls, and a System busyness overlay with live CPU, memory, and event-loop delay graphs (`status`, `health`, `models.list`). - The event log includes Control UI refresh/RPC timings, slow chat/config render timings, and browser responsiveness entries for long animation frames or long tasks when the browser exposes those PerformanceObserver entry types. - Logs: live tail of gateway file logs with filter/export (`logs.tail`). - Update: run a package/git update plus restart (`update.run`) with a restart report, then poll `update.status` after reconnect to verify the running gateway version. @@ -409,11 +409,17 @@ The Activity tab lives in **Settings › System**, next to Logs and Debug. It ha - **Sessions** shows recent session activity grouped by day, with search, time, and people filters. Active rows offer **Inspect run** when the Gateway has recorded a run reference. - **Live activity** is the existing ephemeral browser-local observer for tool activity. It is derived from the same Gateway `session.tool` and tool event stream that powers Chat tool cards. It does not add another Gateway event family, endpoint, durable activity store, metrics feed, or external observer stream. -- **Run inspector** is deep-link only and reads the Gateway's durable, immutable `audit.run.inspect` projection. Use **Inspect run** on an active session or the run ID link in Live activity, or open `/activity?view=run&run=` directly. Reloading or revisiting the link queries the Gateway again; it never reconstructs identity from Live activity. +- **Run inspector** is deep-link only and reads the Gateway's durable, immutable `audit.run.inspect` safe-only projection. The RPC contains required `decisionDisplays` and never a raw `decisions` field. Use **Inspect run** on an active session or the run ID link in Live activity, or open `/activity?view=run&run=` directly. Reloading or revisiting the link queries the Gateway again; it never reconstructs identity from Live activity. Live activity entries keep only sanitized summaries and redacted, truncated output previews. Tool argument values are not stored in Activity state; the UI shows that arguments are hidden and records only the argument field count. The in-memory list follows the current browser tab, survives navigation within the Control UI, and resets on page reload, session switch, Gateway switch, or **Clear**. -The Run inspector shows the retained trust domain, ingress, invoker, represented subject, sponsor, agent definition and principal, runtime instance, applicable grants, assurance evidence, lineage, and a bounded decision-receipt page summary. Every fact has a text evidence state. **Absent** means the owning boundary explicitly recorded no value; **unattributed** means a supported path had no usable invoker; **unknown** means expected evidence is missing or unreadable; and **unsupported** means the path has no Phase 0 evidence contract. Color is supplemental only. +The Run inspector shows the retained trust domain, ingress, invoker, represented subject, sponsor, agent definition and principal, runtime instance, applicable grants, assurance evidence, lineage, and a bounded decision-receipt list. Every fact has a text evidence state. **Absent** means the owning boundary explicitly recorded no value; **unattributed** means a supported path had no usable invoker; **unknown** means expected evidence is missing or unreadable; and **unsupported** means the path has no Phase 0 evidence contract. Color is supplemental only. + +Select a receipt to see the Gateway's bounded safe-display projection: structural action and outcome fields, evidence limits, and verified display provenance. Fixed core summaries and next steps appear only when the Gateway knows the producer contract from the owning call path. Generic or otherwise unverified receipts show a structural `unknown` classification and omit their summary, remediation, and self-asserted owner metadata. Activity consumes the safe result directly: it performs no UI-side inference, post-receive stripping, or raw-receipt fallback. **Enforced** means the recorded owner changed the outcome after validating the exact context, execution, and run tuple. **Attribution only** records what happened without claiming authorization. **Unsupported** means that observation has no Phase 0 enforcement contract. The inspector displays these states as text badges as well as color and never infers a reason from another field. + +Receipt requests are limited to 50 records. **Load more receipts** follows the Gateway's opaque cursor and keeps earlier pages visible. A later-page error does not discard receipts already shown. Each receipt link adds `receipt=` and, for a later page, `decision=` to the selected run or execution URL. The Gateway-owned selector chooses the projected display row without exposing the stored receipt identifier in the URL or as page text. Reloading that link requests the same bounded page and selects the same projected row. An expired or invalid page cursor is an explicit inspection error; choose **Restart inspection** to keep the selected run or execution and restart from the first page. + +Approval and message-delivery links use the `approval-decision:` and `message-decision:` selector namespaces. The owner query mints each selector from its row metadata in the same snapshot as the displayed receipt; private receipt, resolution, and event identifiers never become URL parameters. Run inspection requires `operator.read` and a Gateway that advertises `audit.run.inspect`. Execution identity collection is off by default; enable `logging.audit.executionIdentity`, restart the Gateway, and record a new run when you need this evidence. Retained contexts are limited to 30 days and 100,000 rows. A known run can therefore report unavailable or expired identity evidence, and a run reference can be ambiguous when it correlates more than one execution. The UI does not guess between executions: choose a returned candidate to navigate to `/activity?view=run&execution=` and query that exact execution. diff --git a/docs/web/dashboard-architecture.md b/docs/web/dashboard-architecture.md index 1d301b818ee1..1dbb96f9e884 100644 --- a/docs/web/dashboard-architecture.md +++ b/docs/web/dashboard-architecture.md @@ -165,6 +165,7 @@ Shared infrastructure underneath (this is where the simplification lands): - `openclaw.state.emit` — tier 1 session notices (coalesced, size-capped) - `openclaw.data.read` — parameterized read-only bindings (existing allowlisted read RPC set), resolved gateway-side + - `openclaw.action.run` — tier 3 plugin-owned automation - `openclaw.cron.trigger` — tier 3 automation - **`net` = CSP.** Network reach uses the already-shipped per-widget CSP declaration (`connect-src` origins) — the self-updating weather widget @@ -175,12 +176,14 @@ Shared infrastructure underneath (this is where the simplification lands): `pending` on the board: a placeholder card lists them human-readably with one-tap **Allow**/**Reject**. Grants are per widget name; for `html` widgets they are byte-frozen (sha256), and changed bytes keep the grant only if the - declaration shrank. + declaration shrank. User-clicked links are ordinary navigation rather than a + grant capability and open in a new tab for every rendered widget. - **Authoring shim.** The document wrapper injects `window.openclaw.prompt`, - `window.openclaw.state`, `window.openclaw.data`, and `window.openclaw.cron` - as the stable author API. Dashboard calls share one view-ticket-bound - request channel; size reporting and theme tokens remain separate host - notifications. + `window.openclaw.state`, `window.openclaw.data`, `window.openclaw.action`, + `window.openclaw.cron`, and the host-provided + `window.openclaw.host.controlUiBaseUrl` as the stable author API. Dashboard + calls share one view-ticket-bound request channel; size reporting and theme + tokens remain separate host notifications. ### Plugin capability declarations diff --git a/extensions/acpx/doctor-contract-api.test.ts b/extensions/acpx/doctor-contract-api.test.ts index bfa404bddad5..ad9263fe7617 100644 --- a/extensions/acpx/doctor-contract-api.test.ts +++ b/extensions/acpx/doctor-contract-api.test.ts @@ -105,6 +105,7 @@ describe("acpx doctor state migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/active-memory/doctor-contract-api.test.ts b/extensions/active-memory/doctor-contract-api.test.ts index b0716b7ef474..0fc3e640bde1 100644 --- a/extensions/active-memory/doctor-contract-api.test.ts +++ b/extensions/active-memory/doctor-contract-api.test.ts @@ -65,6 +65,7 @@ describe("active-memory doctor state migration", () => { afterEach(async () => { vi.useRealTimers(); + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/alibaba/video-generation-provider.test.ts b/extensions/alibaba/video-generation-provider.test.ts index 3a0dfbbba0b0..56e44a4717d4 100644 --- a/extensions/alibaba/video-generation-provider.test.ts +++ b/extensions/alibaba/video-generation-provider.test.ts @@ -22,6 +22,7 @@ import { mockSuccessfulDashscopeVideoTask, } from "openclaw/plugin-sdk/provider-test-contracts"; // Alibaba tests cover video generation provider plugin behavior. +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { DASHSCOPE_WAN_VIDEO_MODELS, @@ -276,6 +277,10 @@ describe("alibaba video generation provider", () => { expect(alibabaVideoGenerationProvider.isConfigured?.({ cfg: {}, agentDir })).toBe(expected); } finally { clearRuntimeAuthProfileStoreSnapshots(); + // Saving the profile store opens the per-agent database under the temporary agent + // dir, and clearing the snapshots does not release it, so Windows fails the removal + // with EBUSY unless the cached handles are closed first. + closeOpenClawAgentDatabasesForTest(); await fs.rm(agentDir, { force: true, recursive: true }); } }); diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts index 6fa24510593e..053a2c798eab 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.test.ts @@ -1,5 +1,9 @@ import type { Model } from "openclaw/plugin-sdk/llm"; // Amazon Bedrock Mantle tests cover mantle anthropic plugin behavior. +import { + notifyProviderStreamOpened, + withProviderAcceptanceObserver, +} from "openclaw/plugin-sdk/provider-transport-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { describe, expect, it, vi } from "vitest"; import { createMantleAnthropicStreamFn } from "./mantle-anthropic.runtime.js"; @@ -54,19 +58,26 @@ function firstStreamOptions(deps: ReturnType): Record { - it("uses authToken bearer auth for Mantle Anthropic requests", () => { + it("uses authToken bearer auth for Mantle Anthropic requests", async () => { const stream = { kind: "anthropic-stream" }; const model = createTestModel(); const context = { messages: [] }; const deps = createTestDeps(); deps.stream.mockReturnValue(stream as never); - - const result = createMantleAnthropicStreamFn(deps)(model, context, { - apiKey: "bedrock-bearer-token", - headers: { - "X-Caller": "caller-header", + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver( + { + apiKey: "bedrock-bearer-token", + onResponse, + headers: { + "X-Caller": "caller-header", + }, }, - }); + acceptanceObserver, + ); + + const result = createMantleAnthropicStreamFn(deps)(model, context, options); expect(result).toBe(stream); const clientOptions = requireRecord(mockCallArg(deps.createClient), "client options"); @@ -87,6 +98,9 @@ describe("createMantleAnthropicStreamFn", () => { "bedrock-bearer-token", ); expect(streamOptions.thinkingEnabled).toBe(false); + expect(streamOptions.onResponse).toBe(onResponse); + await notifyProviderStreamOpened({ options: streamOptions, cancelStream: vi.fn() }); + expect(acceptanceObserver).toHaveBeenCalledWith({ kind: "provider_stream_opened" }); }); it("omits unsupported Opus 4.7 sampling and reasoning overrides", () => { diff --git a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts index afaed5a55890..d88d5a69dda2 100644 --- a/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts +++ b/extensions/amazon-bedrock-mantle/mantle-anthropic.runtime.ts @@ -16,7 +16,10 @@ import { resolveClaudeOpus5ModelIdentity, resolveClaudeSonnet5ModelIdentity, } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildGuardedModelFetch } from "openclaw/plugin-sdk/provider-transport-runtime"; +import { + buildGuardedModelFetch, + copyProviderAcceptanceObserver, +} from "openclaw/plugin-sdk/provider-transport-runtime"; const MANTLE_ANTHROPIC_BETA = "fine-grained-tool-streaming-2025-05-14"; type AnthropicOptions = ConstructorParameters[0]; @@ -124,7 +127,7 @@ function buildMantleAnthropicBaseOptions( options: SimpleStreamOptions | undefined, apiKey: string, ) { - return { + return copyProviderAcceptanceObserver(options, { ...(requiresDefaultSampling(model) ? {} : { temperature: options?.temperature }), maxTokens: options?.maxTokens || @@ -136,9 +139,10 @@ function buildMantleAnthropicBaseOptions( cacheRetention: options?.cacheRetention, sessionId: options?.sessionId, onPayload: options?.onPayload, + onResponse: options?.onResponse, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, - }; + }); } function adjustMaxTokensForThinking( diff --git a/extensions/amazon-bedrock/stream.runtime.lifecycle.test.ts b/extensions/amazon-bedrock/stream.runtime.lifecycle.test.ts index cef472c1a9a0..b8bab415c3f5 100644 --- a/extensions/amazon-bedrock/stream.runtime.lifecycle.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.lifecycle.test.ts @@ -3,6 +3,7 @@ import { ConversationRole, StopReason as BedrockStopReason, } from "@aws-sdk/client-bedrock-runtime"; +import { withProviderAcceptanceObserver } from "openclaw/plugin-sdk/provider-transport-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import { streamSimpleBedrock } from "./stream.runtime.js"; @@ -23,6 +24,26 @@ async function* events(items: unknown[]) { yield* items; } +function streamBedrockForTest(options: Parameters[2] = {}) { + return streamSimpleBedrock( + model as never, + { messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never, + options, + ); +} + +function expectDestroyedClient( + send: ReturnType, + destroy: ReturnType, +) { + expect(send).toHaveBeenCalledOnce(); + expect(destroy).toHaveBeenCalledOnce(); + expect(destroy.mock.contexts[0]).toBe(send.mock.contexts[0]); + expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan( + send.mock.invocationCallOrder[0] ?? 0, + ); +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -109,3 +130,124 @@ describe("Bedrock provider-owned stream lifecycle", () => { } }); }); + +describe("Bedrock stream client lifecycle", () => { + it("destroys the client after a successful stream", async () => { + let markStreamBlocked!: () => void; + const streamBlocked = new Promise((resolve) => { + markStreamBlocked = resolve; + }); + let releaseStream!: () => void; + const streamReleased = new Promise((resolve) => { + releaseStream = resolve; + }); + async function* successfulStream() { + yield { messageStart: { role: ConversationRole.ASSISTANT } }; + markStreamBlocked(); + await streamReleased; + yield { messageStop: { stopReason: BedrockStopReason.END_TURN } }; + } + const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200, requestId: "bedrock-request-1" }, + stream: successfulStream(), + } as never); + const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver); + + const resultPromise = streamBedrockForTest(options).result(); + await streamBlocked; + expect(destroy).not.toHaveBeenCalled(); + + releaseStream(); + const result = await resultPromise; + + expect(result.stopReason).toBe("stop"); + expect(acceptanceObserver).toHaveBeenCalledWith({ + kind: "http_response", + status: 200, + headers: { "x-amzn-requestid": "bedrock-request-1" }, + }); + expect(onResponse).toHaveBeenCalledWith( + { status: 200, headers: { "x-amzn-requestid": "bedrock-request-1" } }, + expect.objectContaining({ provider: "amazon-bedrock" }), + ); + expectDestroyedClient(send, destroy); + }); + + it("cancels an unread stream when provider acceptance fails", async () => { + const close = vi.fn(async () => ({ done: true as const, value: undefined })); + const responseIterator = { + next: vi.fn(() => new Promise>(() => {})), + return: close, + [Symbol.asyncIterator]() { + return this; + }, + }; + const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: responseIterator, + } as never); + const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); + const hookError = new Error("acceptance observer failed"); + const options = withProviderAcceptanceObserver({}, () => { + throw hookError; + }); + + const result = await streamBedrockForTest(options).result(); + + expect(result).toMatchObject({ + stopReason: "error", + errorMessage: "acceptance observer failed", + }); + expect(close).toHaveBeenCalledOnce(); + expectDestroyedClient(send, destroy); + }); + + it("destroys the client after a provider error", async () => { + const send = vi + .spyOn(BedrockRuntimeClient.prototype, "send") + .mockRejectedValue(new Error("synthetic provider failure")); + const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); + + const result = await streamBedrockForTest().result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("synthetic provider failure"); + expectDestroyedClient(send, destroy); + }); + + it("destroys the client when response stream iteration fails", async () => { + async function* failingStream() { + yield { messageStart: { role: ConversationRole.ASSISTANT } }; + throw new Error("synthetic iterator failure"); + } + const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ + $metadata: { httpStatusCode: 200 }, + stream: failingStream(), + } as never); + const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); + + const result = await streamBedrockForTest().result(); + + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toBe("synthetic iterator failure"); + expectDestroyedClient(send, destroy); + }); + + it("destroys the client after an aborted request", async () => { + const controller = new AbortController(); + controller.abort(); + const send = vi + .spyOn(BedrockRuntimeClient.prototype, "send") + .mockRejectedValue(new Error("synthetic abort")); + const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); + + const result = await streamBedrockForTest({ signal: controller.signal }).result(); + + expect(result.stopReason).toBe("aborted"); + expect(result.errorMessage).toBe("synthetic abort"); + expectDestroyedClient(send, destroy); + }); +}); diff --git a/extensions/amazon-bedrock/stream.runtime.test.ts b/extensions/amazon-bedrock/stream.runtime.test.ts index ebf0bfd6cbcc..a9208050e2f4 100644 --- a/extensions/amazon-bedrock/stream.runtime.test.ts +++ b/extensions/amazon-bedrock/stream.runtime.test.ts @@ -86,104 +86,6 @@ afterEach(() => { vi.restoreAllMocks(); }); -describe("Bedrock stream client lifecycle", () => { - const context = { - messages: [{ role: "user", content: "Hello", timestamp: 0 }], - } as never; - - function expectDestroyedClient( - send: ReturnType, - destroy: ReturnType, - ) { - expect(send).toHaveBeenCalledOnce(); - expect(destroy).toHaveBeenCalledOnce(); - expect(destroy.mock.contexts[0]).toBe(send.mock.contexts[0]); - expect(destroy.mock.invocationCallOrder[0]).toBeGreaterThan( - send.mock.invocationCallOrder[0] ?? 0, - ); - } - - it("destroys the client after a successful stream", async () => { - let markStreamBlocked!: () => void; - const streamBlocked = new Promise((resolve) => { - markStreamBlocked = resolve; - }); - let releaseStream!: () => void; - const streamReleased = new Promise((resolve) => { - releaseStream = resolve; - }); - async function* successfulStream() { - yield { messageStart: { role: ConversationRole.ASSISTANT } }; - markStreamBlocked(); - await streamReleased; - yield { messageStop: { stopReason: BedrockStopReason.END_TURN } }; - } - const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ - $metadata: { httpStatusCode: 200 }, - stream: successfulStream(), - } as never); - const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); - - const resultPromise = streamBedrockForTest(bedrockModel({}), context).result(); - await streamBlocked; - expect(destroy).not.toHaveBeenCalled(); - - releaseStream(); - const result = await resultPromise; - - expect(result.stopReason).toBe("stop"); - expectDestroyedClient(send, destroy); - }); - - it("destroys the client after a provider error", async () => { - const send = vi - .spyOn(BedrockRuntimeClient.prototype, "send") - .mockRejectedValue(new Error("synthetic provider failure")); - const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); - - const result = await streamBedrockForTest(bedrockModel({}), context).result(); - - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("synthetic provider failure"); - expectDestroyedClient(send, destroy); - }); - - it("destroys the client when response stream iteration fails", async () => { - async function* failingStream() { - yield { messageStart: { role: ConversationRole.ASSISTANT } }; - throw new Error("synthetic iterator failure"); - } - const send = vi.spyOn(BedrockRuntimeClient.prototype, "send").mockResolvedValue({ - $metadata: { httpStatusCode: 200 }, - stream: failingStream(), - } as never); - const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); - - const result = await streamBedrockForTest(bedrockModel({}), context).result(); - - expect(result.stopReason).toBe("error"); - expect(result.errorMessage).toBe("synthetic iterator failure"); - expectDestroyedClient(send, destroy); - }); - - it("destroys the client after an aborted request", async () => { - const controller = new AbortController(); - controller.abort(); - const send = vi - .spyOn(BedrockRuntimeClient.prototype, "send") - .mockRejectedValue(new Error("synthetic abort")); - const destroy = vi.spyOn(BedrockRuntimeClient.prototype, "destroy"); - - const result = await streamBedrockForTest(bedrockModel({}), context, { - signal: controller.signal, - }).result(); - - expect(result.stopReason).toBe("aborted"); - expect(result.errorMessage).toBe("synthetic abort"); - expectDestroyedClient(send, destroy); - }); -}); - describe("Bedrock inbound image base64", () => { const model = () => bedrockModel({ input: ["text", "image"] }); const userImage = (data: string) => diff --git a/extensions/amazon-bedrock/stream.runtime.ts b/extensions/amazon-bedrock/stream.runtime.ts index fa8cd325460e..b73e67ec1c3b 100644 --- a/extensions/amazon-bedrock/stream.runtime.ts +++ b/extensions/amazon-bedrock/stream.runtime.ts @@ -73,6 +73,7 @@ import { import { describeToolResultMediaPlaceholder, finalizeTerminalToolCallArguments, + notifyProviderHttpMetadata, } from "openclaw/plugin-sdk/provider-transport-runtime"; import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { supportsBedrockPromptCaching, type BedrockOptions } from "./bedrock-options.js"; @@ -289,19 +290,24 @@ const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOptions> = const command = new ConverseStreamCommand(commandInput); const response = await client.send(command, { abortSignal: options.signal }); + const responseIterator = response.stream![Symbol.asyncIterator](); if (response.$metadata.httpStatusCode !== undefined) { const responseHeaders: Record = {}; if (response.$metadata.requestId) { responseHeaders["x-amzn-requestid"] = response.$metadata.requestId; } - await options?.onResponse?.( - { status: response.$metadata.httpStatusCode, headers: responseHeaders }, + await notifyProviderHttpMetadata({ + options, + response: { status: response.$metadata.httpStatusCode, headers: responseHeaders }, model, - ); + cancelStream: async () => { + await responseIterator.return?.(); + }, + }); } let sawMessageStop = false; - for await (const item of response.stream!) { + for await (const item of { [Symbol.asyncIterator]: () => responseIterator }) { if (item.messageStart) { if (item.messageStart.role !== ConversationRole.ASSISTANT) { throw new Error( diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index 2cd043fba24a..9a1c98705052 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -5,6 +5,10 @@ import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm"; +import { + notifyProviderStreamOpened, + withProviderAcceptanceObserver, +} from "openclaw/plugin-sdk/provider-transport-runtime"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; @@ -541,6 +545,21 @@ describe("createAnthropicVertexStreamFn", () => { expect(transportOptions).not.toHaveProperty("temperature"); }); + it("forwards the private acceptance observer to the shared Anthropic transport", async () => { + const { deps, streamAnthropicMock } = createStreamDeps(); + const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps); + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver); + + void streamFn(makeModel({ id: "claude-sonnet-4-6" }), { messages: [] }, options); + + const transportOptions = streamTransportOptions(streamAnthropicMock); + expect(transportOptions.onResponse).toBe(onResponse); + await notifyProviderStreamOpened({ options: transportOptions, cancelStream: vi.fn() }); + expect(acceptanceObserver).toHaveBeenCalledWith({ kind: "provider_stream_opened" }); + }); + it("keeps already-budgeted cache_control markers intact when forwarding payload hooks", async () => { const { deps, streamAnthropicMock } = createStreamDeps(); const onPayload = vi.fn(async (payload: unknown) => payload); diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index 572e4e523ddd..5a950879a826 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -23,6 +23,7 @@ import { supportsClaudeNativeMaxEffort, supportsClaudeNativeXhighEffort, } from "openclaw/plugin-sdk/provider-model-shared"; +import { copyProviderAcceptanceObserver } from "openclaw/plugin-sdk/provider-transport-runtime"; import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici"; import { resolveAnthropicVertexAdcCredentials, @@ -219,7 +220,7 @@ export function createAnthropicVertexStreamFn( isClaudeMythos5Model(contractModelId) ? undefined : options?.temperature; - const opts: AnthropicVertexTransportOptions = { + const opts: AnthropicVertexTransportOptions = copyProviderAcceptanceObserver(options, { client, ...(temperature !== undefined ? { temperature } : {}), ...(maxTokens !== undefined ? { maxTokens } : {}), @@ -231,9 +232,10 @@ export function createAnthropicVertexStreamFn( // cache boundary and budgets all cache_control markers; re-applying the // payload policy here marked the uncached suffix and breached the 4-marker cap. onPayload: options?.onPayload, + onResponse: options?.onResponse, maxRetryDelayMs: options?.maxRetryDelayMs, metadata: options?.metadata, - }; + }); if (reasoning === "off") { opts.thinkingEnabled = false; diff --git a/extensions/browser/chrome-extension/background.test-harness.ts b/extensions/browser/chrome-extension/background.test-harness.ts index bb4808577b3b..f309f632a37a 100644 --- a/extensions/browser/chrome-extension/background.test-harness.ts +++ b/extensions/browser/chrome-extension/background.test-harness.ts @@ -16,6 +16,10 @@ const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; const RETIRED_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; const backgroundCleanups = new Set<() => Promise>(); +function waitForBackgroundState(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { interval: 1 }); +} + export async function cleanupBackgroundHarnesses(): Promise { await Promise.all([...backgroundCleanups].map(async (cleanup) => await cleanup())); } @@ -388,7 +392,7 @@ export async function loadBackground({ const backgroundModulePath = "./background.js"; await import(backgroundModulePath); if (!deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingReads = storageGet.mock.calls.filter(([keys]) => PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), ); @@ -396,7 +400,7 @@ export async function loadBackground({ }); } if (!deferTabAccessInitialization && !deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl"), ); @@ -482,7 +486,7 @@ export async function loadBackground({ if (socket.readyState !== FakeWebSocket.OPEN) { socket.open(); } - await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); + await waitForBackgroundState(() => expect(socket.send).toHaveBeenCalled()); const helloRaw = socket.send.mock.calls.find( ([raw]) => JSON.parse(raw).type === "auth.hello", )?.[0]; @@ -511,7 +515,7 @@ export async function loadBackground({ ...fields, serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect( socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"), ).toBe(true); @@ -534,7 +538,7 @@ export async function loadBackground({ response.clientProof, ), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true); }); }, diff --git a/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs b/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs deleted file mode 100644 index 2f72f1c31712..000000000000 --- a/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs +++ /dev/null @@ -1,4 +0,0 @@ -// Build-visible bridge for playwright-core's private User-Agent helper. -import coreBundle from "playwright-core/lib/coreBundle"; - -export default coreBundle; diff --git a/extensions/browser/src/browser/playwright-core.runtime.ts b/extensions/browser/src/browser/playwright-core.runtime.ts index 14655966ab68..3cd0cf28127f 100644 --- a/extensions/browser/src/browser/playwright-core.runtime.ts +++ b/extensions/browser/src/browser/playwright-core.runtime.ts @@ -1,15 +1,22 @@ /** * Playwright runtime loader. * - * Static package imports keep the worker deploy build's executable closure visible - * to the bundler while normal package builds may still externalize the dependency. + * Loads playwright-core only when browser behavior needs it. The worker deploy + * build declares its static dependency closure in worker-deploy-build-plugin.mts. */ -import playwrightCoreDefault from "playwright-core"; +import { createRequire } from "node:module"; import type * as PlaywrightCore from "playwright-core"; -import coreBundle from "./playwright-core-bundle.runtime.mjs"; -/** Runtime playwright-core module instance. */ -export const playwrightCore = playwrightCoreDefault as typeof PlaywrightCore; +const require = createRequire(import.meta.url); + +/** Loads the Playwright runtime on first Browser use. */ +export function getPlaywrightCore(): typeof PlaywrightCore { + return require("playwright-core") as typeof PlaywrightCore; +} /** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */ -export const getPlaywrightUserAgent = (coreBundle as { getUserAgent: () => string }).getUserAgent; +export function getPlaywrightUserAgent(): string { + return ( + require("playwright-core/lib/coreBundle") as { getUserAgent: () => string } + ).getUserAgent(); +} diff --git a/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts b/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts index ca8c86a89657..494a75c130c4 100644 --- a/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts +++ b/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test-support.js"; -import { playwrightCore } from "./playwright-core.runtime.js"; +import { getPlaywrightCore } from "./playwright-core.runtime.js"; import { ensurePageState } from "./pw-session-state.js"; import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js"; import { downloadViaPlaywright, waitForDownloadViaPlaywright } from "./pw-tools-core.downloads.js"; @@ -83,7 +83,7 @@ describe.runIf(runChromiumProof)("managed Chromium download cancellation", () => const cdpPort = await getFreePort(); const profileDir = path.join(rootDir, "profile"); - const context = await playwrightCore.chromium.launchPersistentContext(profileDir, { + const context = await getPlaywrightCore().chromium.launchPersistentContext(profileDir, { headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, args: [`--remote-debugging-port=${cdpPort}`], @@ -222,11 +222,14 @@ describe.runIf(runChromiumProof)("managed Chromium download cancellation", () => await context.close(); const restartedCdpPort = await getFreePort(); - const restartedContext = await playwrightCore.chromium.launchPersistentContext(profileDir, { - headless: true, - executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, - args: [`--remote-debugging-port=${restartedCdpPort}`], - }); + const restartedContext = await getPlaywrightCore().chromium.launchPersistentContext( + profileDir, + { + headless: true, + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: [`--remote-debugging-port=${restartedCdpPort}`], + }, + ); cleanup.push(async () => await restartedContext.close()); const restartedPage = restartedContext.pages()[0] ?? (await restartedContext.newPage()); await restartedPage.goto(`http://127.0.0.1:${downloadPort}/`); diff --git a/extensions/browser/src/browser/pw-session-cdp-transport.ts b/extensions/browser/src/browser/pw-session-cdp-transport.ts index f5e9f1828049..9664578ae58e 100644 --- a/extensions/browser/src/browser/pw-session-cdp-transport.ts +++ b/extensions/browser/src/browser/pw-session-cdp-transport.ts @@ -4,9 +4,7 @@ import type { Browser, ConnectOverCDPTransport } from "playwright-core"; import WebSocket from "ws"; import { formatErrorMessage } from "../infra/errors.js"; import { openCdpWebSocket } from "./cdp.helpers.js"; -import { playwrightCore } from "./playwright-core.runtime.js"; - -const { chromium } = playwrightCore; +import { getPlaywrightCore } from "./playwright-core.runtime.js"; type CdpSocketLookup = typeof dnsLookupCb; export async function connectOverCdpPinnedTransport( @@ -130,7 +128,7 @@ export async function connectOverCdpPinnedTransport( ws.on("error", (error) => { scheduleTransportClosed(formatErrorMessage(error)); }); - return await chromium.connectOverCDP(transport, { timeout: opts.timeout }); + return await getPlaywrightCore().chromium.connectOverCDP(transport, { timeout: opts.timeout }); } catch (error) { ws.close(); throw error; diff --git a/extensions/browser/src/browser/pw-session-connection.ts b/extensions/browser/src/browser/pw-session-connection.ts index ab62a5384100..ab4d4f03f7b9 100644 --- a/extensions/browser/src/browser/pw-session-connection.ts +++ b/extensions/browser/src/browser/pw-session-connection.ts @@ -15,7 +15,7 @@ import { } from "./cdp.helpers.js"; import { getChromeWebSocketEndpoint } from "./chrome.js"; import { BrowserTabNotFoundError } from "./errors.js"; -import { playwrightCore } from "./playwright-core.runtime.js"; +import { getPlaywrightCore } from "./playwright-core.runtime.js"; import { connectOverCdpPinnedTransport } from "./pw-session-cdp-transport.js"; import { blockedPageRefsByCdpUrl, @@ -40,7 +40,6 @@ import { targetKey, } from "./pw-session-state.js"; -const { chromium } = playwrightCore; type CdpEndpointPin = NonNullable>>; function resolveCdpConnectRetryDelayMs(attempt: number): number { @@ -452,7 +451,10 @@ export async function connectBrowser( lookup, }); } - return await chromium.connectOverCDP(connectionUrl, { timeout, headers }); + return await getPlaywrightCore().chromium.connectOverCDP(connectionUrl, { + timeout, + headers, + }); }), ); }; diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts index 72b389b86127..8f9953d800c5 100644 --- a/extensions/browser/src/browser/pw-session.mock-setup.ts +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -14,12 +14,12 @@ export const getChromeWebSocketEndpointMock: MockFn = vi.fn(); vi.mock("./playwright-core.runtime.js", () => ({ getPlaywrightUserAgent: () => "Playwright/test", - playwrightCore: { + getPlaywrightCore: () => ({ chromium: { connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args), }, devices: {}, - }, + }), })); vi.mock("./chrome.js", () => ({ diff --git a/extensions/browser/src/browser/pw-tools-core.state.test.ts b/extensions/browser/src/browser/pw-tools-core.state.test.ts index c065cfc6e7b0..3d6fb15e0023 100644 --- a/extensions/browser/src/browser/pw-tools-core.state.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.state.test.ts @@ -35,7 +35,7 @@ const stateMocks = vi.hoisted(() => ({ })); vi.mock("./playwright-core.runtime.js", () => ({ - playwrightCore: { devices: stateMocks.devices }, + getPlaywrightCore: () => ({ devices: stateMocks.devices }), })); vi.mock("./pw-session.js", () => ({ diff --git a/extensions/browser/src/browser/pw-tools-core.state.ts b/extensions/browser/src/browser/pw-tools-core.state.ts index fef272a538dd..7182330ac39c 100644 --- a/extensions/browser/src/browser/pw-tools-core.state.ts +++ b/extensions/browser/src/browser/pw-tools-core.state.ts @@ -3,12 +3,10 @@ */ import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { CDPSession, Page } from "playwright-core"; -import { playwrightCore } from "./playwright-core.runtime.js"; +import { getPlaywrightCore } from "./playwright-core.runtime.js"; import type { PageState } from "./pw-session-contracts.js"; import { ensurePageState, getPageForTargetId } from "./pw-session.js"; -const { devices: playwrightDevices } = playwrightCore; - type DeviceSize = { width: number; height: number }; type PageCdpSend = (method: string, params?: Record) => Promise; @@ -252,7 +250,7 @@ export async function setDeviceViaPlaywright(opts: { if (!name) { throw new Error("device name is required"); } - const descriptor = (playwrightDevices as Record)[name] as + const descriptor = (getPlaywrightCore().devices as Record)[name] as | PlaywrightDeviceDescriptor | undefined; if (!descriptor) { diff --git a/extensions/codex/doctor-contract-api.test.ts b/extensions/codex/doctor-contract-api.test.ts index f022791762ba..e1411564c755 100644 --- a/extensions/codex/doctor-contract-api.test.ts +++ b/extensions/codex/doctor-contract-api.test.ts @@ -11,6 +11,7 @@ import type { PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor-migrations"; import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; import { legacyConfigRules, @@ -59,6 +60,16 @@ function openBindingStore(env: NodeJS.ProcessEnv) { }); } +async function removeCodexDoctorFixture(stateDir: string): Promise { + // Doctor migrations open per-agent databases and leave the shared state database open under + // the temporary state dir; both must be released before removal or Windows keeps the files + // locked and the removal fails with EBUSY. Agent close first: it releases leases through + // shared state, so the reverse order can reopen it. + closeOpenClawAgentDatabasesForTest(); + resetPluginStateStoreForTests(); + await fs.rm(stateDir, { recursive: true, force: true }); +} + async function createBindingMigrationFixture(options: { binding?: Record; legacySharedRoot?: boolean; @@ -272,7 +283,7 @@ describe("codex doctor contract", () => { }), ).toMatchObject({ agentHarnessId: "codex" }); } finally { - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); } }); @@ -357,7 +368,7 @@ describe("codex doctor contract", () => { fs.readFile(fixture.storePath, "utf8").then(JSON.parse), ).resolves.not.toHaveProperty("agent:main:session-1.agentHarnessId"); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -396,7 +407,7 @@ describe("codex doctor contract", () => { await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("migrates a shared-root binding to the configured system agent", async () => { @@ -448,7 +459,7 @@ describe("codex doctor contract", () => { ).toMatchObject({ agentHarnessId: "codex" }); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("keeps an agent-scoped shared-root binding with its explicit owner", async () => { @@ -492,7 +503,7 @@ describe("codex doctor contract", () => { ).resolves.toMatchObject({ sessionId: "explicit-ops-owner" }); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("bounds oversized legacy fingerprints before plugin-state import", async () => { @@ -560,7 +571,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("normalizes a partial raw conversation import before copying the session row", async () => { @@ -638,7 +649,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("normalizes retained raw conversation and session rows before comparison", async () => { @@ -710,7 +721,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("rejects an explicit session file locator outside the session directory", async () => { @@ -743,7 +754,7 @@ describe("codex doctor contract", () => { ).toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("deduplicates session-store aliases before classifying binding ownership", async () => { @@ -795,7 +806,7 @@ describe("codex doctor contract", () => { expect(configuredIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId"); expect(targetIndex["agent:main:aliased-store"]).not.toHaveProperty("agentHarnessId"); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("resolves relative session files from a symlinked store path", async () => { @@ -847,7 +858,7 @@ describe("codex doctor contract", () => { `${sessionKey}.agentHarnessId`, ); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -929,7 +940,7 @@ describe("codex doctor contract", () => { retired: true, }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }, ); @@ -986,7 +997,7 @@ describe("codex doctor contract", () => { retired: true, }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("does not resurrect a retired session generation from its legacy sidecar", async () => { @@ -1041,7 +1052,7 @@ describe("codex doctor contract", () => { fs.readFile(path.join(fixture.sessionsDir, "sessions.json"), "utf8").then(JSON.parse), ).resolves.not.toHaveProperty(`${sessionKey}.agentHarnessId`); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each(["active", "cleared"] as const)( @@ -1081,7 +1092,7 @@ describe("codex doctor contract", () => { warnings: [], }); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }, ); @@ -1106,7 +1117,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).rejects.toThrow(); await expect(fs.access(`${fixture.sidecarPath}.migrated`)).resolves.toBeUndefined(); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains a zero-owner sidecar when canonical plugin state is malformed", async () => { @@ -1135,7 +1146,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(store.lookup(bindingKey)).resolves.toEqual(malformed); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains mixed Codex and foreign ambiguous binding owners", async () => { @@ -1164,7 +1175,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it("retains a sidecar owned by a foreign harness without importing plugin state", async () => { @@ -1188,7 +1199,7 @@ describe("codex doctor contract", () => { await expect(fs.access(fixture.sidecarPath)).resolves.toBeUndefined(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await fs.rm(fixture.stateDir, { recursive: true, force: true }); + await removeCodexDoctorFixture(fixture.stateDir); }); it.each([ @@ -1230,10 +1241,8 @@ describe("codex doctor contract", () => { await expect(fs.access(`${fixture.sidecarPath}.migrated`)).rejects.toThrow(); await expect(openBindingStore(fixture.env).entries()).resolves.toEqual([]); - await Promise.all([ - fs.rm(fixture.stateDir, { recursive: true, force: true }), - fs.rm(externalDir, { recursive: true, force: true }), - ]); + await removeCodexDoctorFixture(fixture.stateDir); + await fs.rm(externalDir, { recursive: true, force: true }); }); it("does not scan above stateDir or follow escaped external store locators", async () => { @@ -1284,10 +1293,8 @@ describe("codex doctor contract", () => { await expect(migration.detectLegacyState(params)).resolves.toBeNull(); - await Promise.all([ - fs.rm(outerDir, { recursive: true, force: true }), - fs.rm(outsideDir, { recursive: true, force: true }), - ]); + await removeCodexDoctorFixture(outerDir); + await fs.rm(outsideDir, { recursive: true, force: true }); }); it("renames old approval-routed destructive plugin policy values", () => { diff --git a/extensions/codex/harness.test.ts b/extensions/codex/harness.test.ts index 2dbb0737886d..152f39c10e20 100644 --- a/extensions/codex/harness.test.ts +++ b/extensions/codex/harness.test.ts @@ -34,9 +34,13 @@ describe("Codex agent harness supports()", () => { it("publishes provider ids for lightweight auto selection", () => { expect(harness.autoSelection?.providerIds).toEqual(["codex", "openai"]); - expect( - (harness as typeof harness & { cloudPlacement?: { mode: "remote-exec" } }).cloudPlacement, - ).toEqual({ mode: "remote-exec" }); + expect(harness.cloudPlacement).toEqual({ + mode: "remote-exec", + devicePlacement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + }); }); it("keeps computer-control denies out of the native-surface exemption", () => { @@ -248,6 +252,62 @@ describe("Codex agent harness supports()", () => { expect(!result.supported ? result.reason : undefined).toContain("not declared"); }); + it("lets explicitly selected Codex discover unlisted models with its own account", () => { + expect( + harness.supports({ + provider: "openai", + modelId: "gpt-future", + requestedRuntime: "codex", + modelProvider: { + requestTransportOverrides: "none", + preparedAuth: { source: "harness" }, + }, + }), + ).toEqual({ supported: true, priority: 100 }); + }); + + it("lets explicit Codex model discovery run before auth has been prepared", () => { + expect( + harness.supports({ + provider: "openai", + modelId: "gpt-future", + requestedRuntime: "codex", + modelProvider: { requestTransportOverrides: "none" }, + }), + ).toEqual({ supported: true, priority: 100 }); + }); + + it.each([ + { + label: "automatic runtime selection", + requestedRuntime: "auto" as const, + modelProvider: { preparedAuth: { source: "harness" as const } }, + }, + { + label: "an authored endpoint", + requestedRuntime: "codex" as const, + modelProvider: { + baseUrl: "https://relay.example.test/v1", + preparedAuth: { source: "harness" as const }, + }, + }, + { + label: "an owner-selected credential", + requestedRuntime: "codex" as const, + modelProvider: { preparedAuth: { source: "profile" as const, mode: "api-key" } }, + }, + ])("does not infer native model access for $label", ({ requestedRuntime, modelProvider }) => { + const result = harness.supports({ + provider: "openai", + modelId: "gpt-future", + requestedRuntime, + modelProvider: { requestTransportOverrides: "none", ...modelProvider }, + }); + + expect(result.supported).toBe(false); + expect(!result.supported ? result.reason : undefined).toContain("not declared"); + }); + it.each([ { label: "forwarded OAuth subscription", diff --git a/extensions/codex/harness.ts b/extensions/codex/harness.ts index b1ccb94fe5bc..a619231e9f94 100644 --- a/extensions/codex/harness.ts +++ b/extensions/codex/harness.ts @@ -49,10 +49,6 @@ const CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES = [ "thread-bootstrap-projection", ] as const satisfies readonly ContextEngineHostCapability[]; -type CodexAppServerAgentHarness = AgentHarnessV2 & { - cloudPlacement?: { mode: "remote-exec" }; -}; - type CodexAppServerAgentHarnessOptions = { id?: string; label?: string; @@ -124,11 +120,17 @@ export function createCodexAppServerAgentHarness( resolvePluginConfigObject(config, "codex") ?? options.resolvePluginConfig?.() ?? options.pluginConfig; - const harness: CodexAppServerAgentHarness = { + const harness: AgentHarnessV2 = { id: harnessRuntimeId, label: options?.label ?? "Codex agent harness", autoSelection: { providerIds: [...providerIds] }, - cloudPlacement: { mode: "remote-exec" }, + cloudPlacement: { + mode: "remote-exec", + devicePlacement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + }, delegatedExecutionPluginIds: ["voice-call"], contextEngineHostCapabilities: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST_CAPABILITIES, conversationToolPolicySupport: "exact", @@ -204,6 +206,19 @@ export function createCodexAppServerAgentHarness( } const preparedAuth = ctx.modelProvider?.preparedAuth; const runtimePolicy = ctx.modelProvider?.runtimePolicy; + // Codex owns discovery and auth for new first-party models. Only trust that + // native account when no authored transport or host credential is involved. + const nativeAccountOwnsUnobservedModel = + provider === "openai" && + ctx.requestedRuntime === "codex" && + Boolean(ctx.modelId?.trim()) && + (preparedAuth === undefined || preparedAuth.source === "harness") && + preparedAuth?.mode === undefined && + preparedAuth?.requirement === undefined && + ctx.modelProvider?.api === undefined && + ctx.modelProvider?.baseUrl === undefined && + ctx.modelProvider?.azureApiVersion === undefined && + ctx.modelProvider?.request === undefined; if (runtimePolicy) { const compatible = runtimePolicy.compatibleIds.some( (id) => id.trim().toLowerCase() === normalizedHarnessRuntimeId, @@ -214,7 +229,7 @@ export function createCodexAppServerAgentHarness( reason: "Codex cannot reproduce the prepared provider route", }; } - } else if (ctx.modelProvider && provider !== "codex") { + } else if (ctx.modelProvider && provider !== "codex" && !nativeAccountOwnsUnobservedModel) { return { supported: false, reason: "provider route compatibility with Codex is not declared", @@ -251,6 +266,7 @@ export function createCodexAppServerAgentHarness( return runCodexAppServerAttempt(params, { bindingStore: options.bindingStore, pluginConfig: resolveAttemptPluginConfig(params.config), + runtime: sessionRuntime, runtimeModelId: readCodexRuntimeModelId(params.model, params.modelId), nativeHookRelay: { enabled: true }, }); @@ -290,6 +306,7 @@ export function createCodexAppServerAgentHarness( return runCodexAppServerSideQuestion(params, { bindingStore: options.bindingStore, pluginConfig: options?.resolvePluginConfig?.() ?? options?.pluginConfig, + runtime: sessionRuntime, runtimeModelId: readCodexRuntimeModelId(params.runtimeModel, params.model), nativeHookRelay: { enabled: true }, }); diff --git a/extensions/codex/index.test.ts b/extensions/codex/index.test.ts index bc9991123aa4..12028b187b64 100644 --- a/extensions/codex/index.test.ts +++ b/extensions/codex/index.test.ts @@ -85,6 +85,7 @@ describe("codex plugin", () => { it("registers request-scoped surfaces with explicit multi-agent ownership", () => { const registerAgentHarness = vi.fn(); const registerNodeHostCommand = vi.fn(); + const registerNodeInvokePolicy = vi.fn(); const registerSessionCatalog = vi.fn(); expect(() => @@ -98,6 +99,7 @@ describe("codex plugin", () => { runtime: createCodexTestRuntime(() => explicitAgentConfig), registerAgentHarness, registerNodeHostCommand, + registerNodeInvokePolicy, registerSessionCatalog, }), ), @@ -110,8 +112,26 @@ describe("codex plugin", () => { "codex.appServer.threads.list.v1", "codex.appServer.thread.turns.list.v1", "codex.terminal.resume.v1", + "codex.exec-server.stdio.v1", ]), ); + const nodeExecServerCommand = registerNodeHostCommand.mock.calls + .map(([command]) => command) + .find((command) => command.command === "codex.exec-server.stdio.v1"); + expect(nodeExecServerCommand).toMatchObject({ + command: "codex.exec-server.stdio.v1", + cap: "codex.exec-server", + dangerous: true, + duplex: true, + }); + const nodeExecServerPolicy = registerNodeInvokePolicy.mock.calls + .map(([policy]) => policy) + .find((policy) => policy.commands.includes("codex.exec-server.stdio.v1")); + expect(nodeExecServerPolicy).toMatchObject({ + commands: ["codex.exec-server.stdio.v1"], + dangerous: true, + }); + expect(nodeExecServerPolicy.defaultPlatforms).toBeUndefined(); }); it("proactively monitors an explicitly configured remote websocket app-server", () => { @@ -286,7 +306,11 @@ describe("codex plugin", () => { const nodeCommands = registerNodeHostCommand.mock.calls.map( ([command]) => (command as { command: string }).command, ); - expect(nodeCommands).toEqual(["codex.cli.sessions.list", "codex.cli.session.resume"]); + expect(nodeCommands).toEqual([ + "codex.cli.sessions.list", + "codex.cli.session.resume", + "codex.exec-server.stdio.v1", + ]); expect(nodeCommands).not.toContain("codex.appServer.threads.list.v1"); expect(nodeCommands).not.toContain("codex.appServer.thread.turns.list.v1"); expect(registerSessionCatalog).not.toHaveBeenCalled(); @@ -938,6 +962,7 @@ describe("codex plugin", () => { }, }, }; + const runtime = createCodexTestRuntime(() => liveConfig); plugin.register( createTestPluginApi({ id: "codex", @@ -945,7 +970,7 @@ describe("codex plugin", () => { source: "test", config: {}, pluginConfig: { codexPlugins: { enabled: false } }, - runtime: createCodexTestRuntime(() => liveConfig), + runtime, registerAgentHarness, registerCommand: vi.fn(), registerMediaUnderstandingProvider: vi.fn(), @@ -967,6 +992,8 @@ describe("codex plugin", () => { { bindingStore: expect.any(Object), pluginConfig: liveConfig.plugins.entries.codex.config, + runtime, + runtimeModelId: undefined, nativeHookRelay: { enabled: true }, }, ); diff --git a/extensions/codex/index.ts b/extensions/codex/index.ts index a3d2bc2d8f87..784e36ad9025 100644 --- a/extensions/codex/index.ts +++ b/extensions/codex/index.ts @@ -44,6 +44,10 @@ import { resumeCodexCliSessionOnNode, resolveCodexCliSessionForBindingOnNode, } from "./src/node-cli-sessions.js"; +import { + createCodexNodeExecServerCommand, + createCodexNodeExecServerInvokePolicy, +} from "./src/node-exec-server.js"; import { createCodexSessionCatalogControl, createCodexSessionCatalogNodeHostCommands, @@ -260,6 +264,8 @@ export default definePluginEntry({ for (const policy of createCodexCliSessionNodeInvokePolicies()) { api.registerNodeInvokePolicy(policy); } + api.registerNodeHostCommand(createCodexNodeExecServerCommand()); + api.registerNodeInvokePolicy(createCodexNodeExecServerInvokePolicy()); api.registerCommand( createCodexCommand({ pluginConfig: api.pluginConfig, diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts index b40665c8fe0f..69500c6074b9 100644 --- a/extensions/codex/src/app-server/attempt-context.test.ts +++ b/extensions/codex/src/app-server/attempt-context.test.ts @@ -141,6 +141,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:main:session-1", sessionAgentId: "main", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, }); expect(context.memoryReferenceFiles).toEqual([]); @@ -181,6 +182,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:marketing-agent:session-1", sessionAgentId: "marketing-agent", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, sandboxed: true, }); @@ -219,6 +221,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:main:session-1", sessionAgentId: "main", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, }); expect(context.threadDeveloperInstructions).toContain("Canonical agent instructions"); @@ -241,6 +244,37 @@ describe("Codex app-server attempt context", () => { } }); + it("keeps ambient workspace instructions out of overlapping ring-zero restrictions", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ring-zero-workspace-")); + const executionDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ring-zero-execution-")); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "Ambient workspace instructions"); + + try { + const context = await buildCodexWorkspaceBootstrapContext({ + params: { + sessionId: "session-1", + sessionKey: "agent:openclaw:session-1", + toolsAllow: ["openclaw"], + pluginHarnessToolPolicyRestricted: true, + config: { agents: { defaults: { workspace: workspaceDir } } }, + } as EmbeddedRunAttemptParams, + resolvedWorkspace: workspaceDir, + executionWorkspace: executionDir, + effectiveWorkspace: executionDir, + sessionKey: "agent:openclaw:session-1", + sessionAgentId: "openclaw", + memoryToolNames: [], + ringZeroActive: true, + }); + + expect(context.threadDeveloperInstructions).toBeUndefined(); + expect(context.threadDeveloperInstructionFiles).toEqual([]); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + await fs.rm(executionDir, { recursive: true, force: true }); + } + }); + it("reads and compares thread-bootstrap context-engine projections", () => { const projection = readContextEngineThreadBootstrapProjection({ mode: "thread_bootstrap", diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 58958eded63e..df98201ead7d 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -26,6 +26,7 @@ import type { } from "openclaw/plugin-sdk/session-transcript-runtime"; import { readNonBlankString as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; +import { isMessageOnlyCodexSourceReply } from "./dynamic-tool-profile.js"; import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js"; import { flattenCodexDynamicToolFunctions, isJsonObject } from "./protocol.js"; import type { CodexAppServerThreadBinding } from "./session-binding.js"; @@ -176,6 +177,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: { sessionKey: string; sessionAgentId: string; memoryToolNames: readonly string[]; + ringZeroActive: boolean; sandboxed?: boolean; }): Promise { try { @@ -244,8 +246,15 @@ export async function buildCodexWorkspaceBootstrapContext(params: { memoryWorkspaceDir: params.effectiveWorkspace, }); const injectOpenClawContext = shouldInjectCodexOpenClawPromptContext(params.params); + const restrictedProjectDocNeedsOpenClawCarrier = + params.params.pluginHarnessToolPolicyRestricted === true && + !params.params.disableTools && + !isMessageOnlyCodexSourceReply(params.params) && + params.params.bootstrapContextMode !== "lightweight"; const threadDeveloperInstructionFiles = - injectOpenClawContext && inheritsAgentWorkspace + injectOpenClawContext && + !params.ringZeroActive && + (inheritsAgentWorkspace || restrictedProjectDocNeedsOpenClawCarrier) ? selectCodexWorkspaceAgentProjectInstructionFiles(contextFiles, params.resolvedWorkspace) : []; const turnScopedDeveloperInstructionFiles = injectOpenClawContext diff --git a/extensions/codex/src/app-server/attempt-startup.test.ts b/extensions/codex/src/app-server/attempt-startup.test.ts index 3558d07abadc..b85ad4644d38 100644 --- a/extensions/codex/src/app-server/attempt-startup.test.ts +++ b/extensions/codex/src/app-server/attempt-startup.test.ts @@ -8,6 +8,7 @@ import { type CodexBundleMcpThreadConfig, type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { startCodexAttemptThread } from "./attempt-startup.js"; import { isCodexAppServerStartupError } from "./attempt-timeouts.js"; @@ -22,6 +23,7 @@ import { import { createCodexTestHostCapabilities } from "./host-capability.test-support.js"; import { defaultCodexPluginMetadataCache } from "./plugin-metadata-cache.js"; import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js"; +import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js"; import { createSandboxContext } from "./sandbox-exec-server.test-helpers.js"; import { resetCodexTestBindingStore, @@ -30,6 +32,7 @@ import { import { clearSharedCodexAppServerClient, clearSharedCodexAppServerClientAndWait, + createIsolatedCodexAppServerClient, getLeasedSharedCodexAppServerClient, releaseLeasedSharedCodexAppServerClient, resolveCodexAppServerSpawnIdentity, @@ -122,6 +125,7 @@ function startThreadWithHarness( >[0]["runtimeArtifactRequest"]; sandbox?: Parameters[0]["sandbox"]; sandboxExecServerEnabled?: boolean; + runtime?: Parameters[0]["runtime"]; }, ) { const harness = overrides?.harness ?? createClientHarness(); @@ -133,6 +137,7 @@ function startThreadWithHarness( const run = startCodexAttemptThread({ bindingStore: testCodexAppServerBindingStore, + runtime: overrides?.runtime, attemptClientFactory: overrides?.attemptClientFactory?.(harness) ?? getLeasedSharedCodexAppServerClient, appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }), @@ -229,6 +234,80 @@ async function waitForThreadStart(harness: ClientHarness): Promise<{ id?: number return waitForRequest(harness, "thread/start"); } +function createPairedAttemptRuntime() { + const channels: Array<{ close: ReturnType; sessionId: string }> = []; + const openDuplex = vi.fn< + NonNullable[0]["runtime"]>["nodes"]["openDuplex"] + >(async (request) => { + let resolveClosed: (value: unknown) => void = () => undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const channel = { + send: vi.fn(async () => undefined), + onMessage: vi.fn(() => () => undefined), + closed, + close: vi.fn(() => resolveClosed({ ok: true })), + }; + channels.push({ + close: channel.close, + sessionId: (request.params as { sessionId: string }).sessionId, + }); + return channel; + }); + return { + runtime: createPluginRuntimeMock({ nodes: { openDuplex } }), + channels, + openDuplex, + }; +} + +async function startIsolatedPairedAttempt(params: { + harness: ClientHarness; + sessionId: string; + runtime: NonNullable[0]["runtime"]>; + paths?: AttemptPaths; +}) { + const paths = params.paths ?? createAttemptPaths(); + const sandbox = { + ...createSandboxContext({}), + backendId: "node", + backend: undefined, + fsBridge: undefined, + runtimeId: `paired-node-${params.sessionId}`, + placementExecutionMode: "remote-exec" as const, + placementNodeId: "paired-device-1", + placementEnvironmentId: `environment-${params.sessionId}`, + placementSessionId: params.sessionId, + placementOwnerEpoch: 1, + }; + const run = startThreadWithHarness(5_000, new AbortController().signal, { + harness: params.harness, + paths, + skipStartSpy: true, + runtime: params.runtime, + sandbox, + attemptClientFactory: () => createIsolatedCodexAppServerClient, + buildAttemptParams: () => ({ + ...createAttemptParams(paths), + sessionId: params.sessionId, + sessionKey: `agent:agent-1:${params.sessionId}`, + }), + }).run; + await answerInitialize(params.harness); + const environmentAdd = await waitForRequest(params.harness, "environment/add"); + params.harness.send({ id: environmentAdd.id, result: {} }); + const threadStart = await waitForThreadStart(params.harness); + params.harness.send({ id: threadStart.id, result: threadStartResult(params.sessionId) }); + const result = await run; + const environmentId = (environmentAdd.params as { environmentId?: string }).environmentId; + expect(environmentId).toMatch(/^openclaw-node-/u); + expect( + readHarnessMessages(params.harness.writes).filter(({ method }) => method === "environment/add"), + ).toHaveLength(1); + return { result, sandbox, environmentId }; +} + function threadStartResult(threadId = "thread-1") { return createThreadStartResult(threadId, "/repo"); } @@ -701,6 +780,94 @@ describe("startCodexAttemptThread", () => { expect(harness.stdinDestroyed).toBe(true); }); + it("retires each fresh paired-node app-server and its registered environment", async () => { + const runtime = createPairedAttemptRuntime(); + const clients = [createClientHarness(), createClientHarness(), createClientHarness()]; + const start = vi.spyOn(CodexAppServerClient, "start"); + for (const harness of clients) { + start.mockReturnValueOnce(harness.client); + } + const environmentIds = new Set(); + + for (const [index, harness] of clients.entries()) { + const attempt = await startIsolatedPairedAttempt({ + harness, + sessionId: `sequential-${index}`, + runtime: runtime.runtime, + }); + environmentIds.add(attempt.environmentId!); + await releaseCodexSandboxExecServerEnvironment( + attempt.sandbox, + attempt.result.sandboxEnvironment, + ); + attempt.result.releaseSharedClientLease(); + + expect(harness.process.stdin.destroyed).toBe(true); + expect(runtime.channels.every(({ close }) => close.mock.calls.length === 1)).toBe(true); + expect(sandboxExecServerRegistry.servers.size).toBe(0); + } + + expect(environmentIds.size).toBe(clients.length); + expect(start).toHaveBeenCalledTimes(clients.length); + expect(runtime.openDuplex).toHaveBeenCalledTimes(clients.length); + }); + + it("closes each paired-node environment and client without interrupting an overlapping sibling", async () => { + const first = createClientHarness(); + const second = createClientHarness(); + const runtime = createPairedAttemptRuntime(); + const firstPaths = createAttemptPaths(); + const secondPaths = createAttemptPaths(); + const start = vi.spyOn(CodexAppServerClient, "start").mockImplementation((options) => { + const codexHome = options?.env?.CODEX_HOME; + if (codexHome?.startsWith(`${firstPaths.agentDir}${path.sep}`)) { + return first.client; + } + if (codexHome?.startsWith(`${secondPaths.agentDir}${path.sep}`)) { + return second.client; + } + throw new Error(`Unexpected isolated Codex home: ${codexHome}`); + }); + const [firstAttempt, secondAttempt] = await Promise.all([ + startIsolatedPairedAttempt({ + harness: first, + sessionId: "overlap-1", + runtime: runtime.runtime, + paths: firstPaths, + }), + startIsolatedPairedAttempt({ + harness: second, + sessionId: "overlap-2", + runtime: runtime.runtime, + paths: secondPaths, + }), + ]); + + expect(firstAttempt.result.client).toBe(first.client); + expect(secondAttempt.result.client).toBe(second.client); + expect(firstAttempt.environmentId).not.toBe(secondAttempt.environmentId); + expect(start).toHaveBeenCalledTimes(2); + const firstChannel = runtime.channels.find(({ sessionId }) => sessionId === "overlap-1"); + const secondChannel = runtime.channels.find(({ sessionId }) => sessionId === "overlap-2"); + await releaseCodexSandboxExecServerEnvironment( + firstAttempt.sandbox, + firstAttempt.result.sandboxEnvironment, + ); + firstAttempt.result.releaseSharedClientLease(); + expect(first.process.stdin.destroyed).toBe(true); + expect(second.process.stdin.destroyed).toBe(false); + expect(firstChannel?.close).toHaveBeenCalledOnce(); + expect(secondChannel?.close).not.toHaveBeenCalled(); + await releaseCodexSandboxExecServerEnvironment( + secondAttempt.sandbox, + secondAttempt.result.sandboxEnvironment, + ); + secondAttempt.result.releaseSharedClientLease(); + expect(second.process.stdin.destroyed).toBe(true); + expect(secondChannel?.close).toHaveBeenCalledOnce(); + expect(sandboxExecServerRegistry.servers.size).toBe(0); + }); + it("forwards prepared auth without a legacy profile selector", async () => { const preparedAuth = { kind: "api-key" as const, diff --git a/extensions/codex/src/app-server/attempt-startup.ts b/extensions/codex/src/app-server/attempt-startup.ts index 59856b3eba4a..f6b77b32c1c9 100644 --- a/extensions/codex/src/app-server/attempt-startup.ts +++ b/extensions/codex/src/app-server/attempt-startup.ts @@ -11,6 +11,7 @@ import { type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, type resolveSandboxContext, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS, CodexAppServerUnsafeSubscriptionError, @@ -73,6 +74,7 @@ import type { CodexAppServerBindingStore } from "./session-binding.js"; import { clearSharedCodexAppServerClientIfCurrent, clearSharedCodexAppServerClientIfCurrentAndUnclaimed, + createIsolatedCodexAppServerClient, isCodexAppServerStartSelectionChangedError, releaseLeasedSharedCodexAppServerClient, retireSharedCodexAppServerClientIfCurrent, @@ -131,6 +133,7 @@ type StartCodexAttemptThreadResult = { export async function startCodexAttemptThread(params: { attemptClientFactory: CodexAppServerClientFactory; bindingStore: CodexAppServerBindingStore; + runtime?: PluginRuntime; appServer: CodexAppServerRuntimeOptions; pluginConfig: CodexPluginConfig; computerUseConfig: ResolvedCodexComputerUseConfig; @@ -171,6 +174,7 @@ export async function startCodexAttemptThread(params: { startupTimeoutMs: number; signal: AbortSignal; onStartupTimeout: () => void | Promise; + onExecutionDisconnect?: (error: Error) => void; spawnedBy: EmbeddedRunAttemptParams["spawnedBy"]; }): Promise { let pluginAppServer = params.appServer; @@ -274,7 +278,11 @@ export async function startCodexAttemptThread(params: { return; } startupClientLeaseReleased = true; - releaseLeasedSharedCodexAppServerClient(activeStartupClient); + if (params.attemptClientFactory === createIsolatedCodexAppServerClient) { + activeStartupClient.close(); + } else { + releaseLeasedSharedCodexAppServerClient(activeStartupClient); + } }; releaseSharedClientLease = startupClientLease; attemptedClient = activeStartupClient; @@ -362,7 +370,10 @@ export async function startCodexAttemptThread(params: { const releaseStartupSandboxEnvironment = async () => { if (startupSandboxEnvironmentAcquired) { startupSandboxEnvironmentAcquired = false; - await releaseCodexSandboxExecServerEnvironment(params.sandbox); + await releaseCodexSandboxExecServerEnvironment( + params.sandbox, + startupSandboxEnvironment, + ); } }; releaseStartupResourcesOnTimeout = releaseStartupSandboxEnvironment; @@ -376,9 +387,11 @@ export async function startCodexAttemptThread(params: { ? await ensureCodexSandboxExecServerEnvironment({ client: activeStartupClient, sandbox: params.sandbox ?? null, + runtime: params.runtime, appServerStartOptions: params.appServer.start, timeoutMs: params.appServer.requestTimeoutMs, - signal: startupAbandonController.signal, + signal: AbortSignal.any([params.signal, startupAbandonController.signal]), + onExecutionDisconnect: params.onExecutionDisconnect, }) : undefined; startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment); diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 2309e3c06ea0..bd516c0b80be 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -32,6 +32,7 @@ import { type CodexAppServerLiveThreadOwnership, } from "./client-runtime.js"; import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; import { readCodexThreadContextSnapshot } from "./event-projector-usage.js"; import { readCodexNotificationThreadId, @@ -70,7 +71,7 @@ type CodexAppServerCompactOptions = { }; type CodexNativeCompactionCompletion = - | { completed: true; tokensAfter?: number } + | { completed: true; turnId?: string; itemId?: string; tokensAfter?: number } | { completed: false; reason: string }; function watchCodexNativeCompactionCompletion(params: { @@ -111,7 +112,12 @@ function watchCodexNativeCompactionCompletion(params: { resolveCompletion(result); }; const complete = () => - finish({ completed: true, ...(tokensAfter !== undefined ? { tokensAfter } : {}) }); + finish({ + completed: true, + ...(compactionTurnId ? { turnId: compactionTurnId } : {}), + ...(compactionItemId ? { itemId: compactionItemId } : {}), + ...(tokensAfter !== undefined ? { tokensAfter } : {}), + }); const fail = (reason: string) => finish({ completed: false, reason }); const retireUnconfirmed = (reason: string) => { if (settled || retirementStarted) { @@ -742,6 +748,18 @@ async function compactCodexNativeThread( throw new Error(completion.reason); } tokensAfter = completion.tokensAfter; + if (completion.turnId && completion.itemId) { + await persistCodexContextCompactionActivity({ + sessionTarget: params.sessionTarget, + config: params.config, + cwd: params.workspaceDir, + runId: params.runId, + threadId: binding.threadId, + turnId: completion.turnId, + itemId: completion.itemId, + timestamp: Date.now(), + }); + } embeddedAgentLog.info("completed codex app-server compaction", { sessionId: params.sessionId, threadId: binding.threadId, diff --git a/extensions/codex/src/app-server/config-parsing.ts b/extensions/codex/src/app-server/config-parsing.ts index 6dbae5713641..fbd5d3e6deee 100644 --- a/extensions/codex/src/app-server/config-parsing.ts +++ b/extensions/codex/src/app-server/config-parsing.ts @@ -225,6 +225,17 @@ export function isCodexRemoteExecPlacementSandbox(sandbox: unknown): boolean { ); } +export function isCodexPairedNodeRemoteExecPlacementSandbox(sandbox: unknown): boolean { + return ( + isCodexRemoteExecPlacementSandbox(sandbox) && + typeof sandbox === "object" && + sandbox !== null && + "placementNodeId" in sandbox && + typeof sandbox.placementNodeId === "string" && + sandbox.placementNodeId.length > 0 + ); +} + export function assertCodexAppServerCommandHasNoInlineArgs(params: { command: string; source: CodexAppServerCommandSource; diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 370a53ab5ffc..95245e05dfc5 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -24,6 +24,7 @@ export type { } from "./config-contracts.js"; export { resolveOpenClawExecPolicyForCodexAppServer } from "./config-exec-policy.js"; export { + isCodexPairedNodeRemoteExecPlacementSandbox, isCodexRemoteExecPlacementSandbox, isCodexSandboxExecServerEnabled, readCodexPluginConfig, diff --git a/extensions/codex/src/app-server/context-compaction-activity.test.ts b/extensions/codex/src/app-server/context-compaction-activity.test.ts new file mode 100644 index 000000000000..19e7a03d5c02 --- /dev/null +++ b/extensions/codex/src/app-server/context-compaction-activity.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; + +const appendMessage = vi.hoisted(() => vi.fn()); +const publishUpdate = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({ + appendSessionTranscriptMessageByIdentity: appendMessage, + publishSessionTranscriptUpdateByIdentity: publishUpdate, +})); + +beforeEach(() => { + appendMessage.mockReset(); + publishUpdate.mockReset(); +}); + +describe("persistCodexContextCompactionActivity", () => { + it("publishes one model-excluded activity and leaves replay deduplication to transcript identity", async () => { + appendMessage + .mockImplementationOnce(async (params: { message: unknown }) => ({ + appended: true, + message: params.message, + messageId: "activity-message", + })) + .mockResolvedValueOnce({ + appended: false, + message: {}, + messageId: "activity-message", + }); + const params = { + runId: "run-1", + cwd: "/workspace", + sessionTarget: { + agentId: "main", + sessionId: "session-1", + sessionKey: "agent:main:dashboard:session-1", + storePath: "/state/openclaw-agent.sqlite", + }, + threadId: "thread-1", + turnId: "turn-1", + itemId: "compact-1", + timestamp: 123, + } as Parameters[0]; + + await persistCodexContextCompactionActivity(params); + await persistCodexContextCompactionActivity(params); + + expect(appendMessage).toHaveBeenCalledTimes(2); + expect(appendMessage.mock.calls[0]?.[0]).toMatchObject({ + eventId: "codex-context-compaction:thread-1:turn-1:compact-1", + message: { + role: "custom", + customType: "openclaw.context-compaction", + content: "Context compacted", + display: true, + excludeFromContext: true, + idempotencyKey: "codex-context-compaction:thread-1:turn-1:compact-1", + }, + }); + expect(publishUpdate).toHaveBeenCalledOnce(); + expect(publishUpdate.mock.calls[0]?.[0]).toMatchObject({ + update: { + messageId: "activity-message", + runId: "run-1", + }, + }); + }); +}); diff --git a/extensions/codex/src/app-server/context-compaction-activity.ts b/extensions/codex/src/app-server/context-compaction-activity.ts new file mode 100644 index 000000000000..7b55635809d6 --- /dev/null +++ b/extensions/codex/src/app-server/context-compaction-activity.ts @@ -0,0 +1,76 @@ +import { + embeddedAgentLog, + formatErrorMessage, + type EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import { + appendSessionTranscriptMessageByIdentity, + publishSessionTranscriptUpdateByIdentity, +} from "openclaw/plugin-sdk/session-transcript-runtime"; + +const CONTEXT_COMPACTION_CUSTOM_TYPE = "openclaw.context-compaction"; + +export async function persistCodexContextCompactionActivity(params: { + sessionTarget?: EmbeddedRunAttemptParams["sessionTarget"]; + config?: EmbeddedRunAttemptParams["config"]; + cwd?: string; + runId?: string; + threadId: string; + turnId: string; + itemId: string; + timestamp: number; +}): Promise { + const target = params.sessionTarget; + if (!target?.sessionId || !target.sessionKey || !target.storePath) { + return; + } + const activityId = `codex-context-compaction:${params.threadId}:${params.turnId}:${params.itemId}`; + const message = { + role: "custom" as const, + customType: CONTEXT_COMPACTION_CUSTOM_TYPE, + content: "Context compacted", + display: true, + excludeFromContext: true, + details: { + kind: "context_compaction", + backend: "codex-app-server", + threadId: params.threadId, + turnId: params.turnId, + itemId: params.itemId, + ...(params.runId ? { runId: params.runId } : {}), + }, + timestamp: params.timestamp, + idempotencyKey: activityId, + }; + try { + const appended = await appendSessionTranscriptMessageByIdentity({ + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + storePath: target.storePath, + config: params.config, + cwd: params.cwd, + eventId: activityId, + message, + }); + if (!appended?.appended) { + return; + } + await publishSessionTranscriptUpdateByIdentity({ + agentId: target.agentId, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + storePath: target.storePath, + update: { + message: appended.message, + messageId: appended.messageId, + ...(params.runId ? { runId: params.runId } : {}), + }, + }); + } catch (error) { + embeddedAgentLog.warn("failed to persist codex context compaction activity", { + error: formatErrorMessage(error), + itemId: params.itemId, + }); + } +} diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index 38e9dce3ef86..af44a2a26340 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -225,6 +225,69 @@ describe("Codex app-server dynamic tool build", () => { expect(tools).toEqual([]); }); + it("keeps host and plugin tools while native paired-device execution owns filesystem and shell", async () => { + const workspaceDir = path.join(tempDir, "paired-node-workspace"); + const params = createParams(path.join(tempDir, "paired-node-session.jsonl"), workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + const factory = vi.fn((options: Parameters[0]) => [ + ...createOpenClawCodingTools(options).filter((tool) => tool.name === "message"), + createRuntimeDynamicTool("paired_host_plugin"), + ]); + setOpenClawCodingToolsFactoryForTests(factory); + + const tools = await buildDynamicToolsForTest(params, workspaceDir, { + sandbox: { + enabled: true, + backendId: "node", + workspaceDir, + agentWorkspaceDir: workspaceDir, + containerWorkdir: "/remote/workspace", + workspaceAccess: "rw", + browserAllowHostControl: false, + placementExecutionMode: "remote-exec", + placementNodeId: "paired-device-1", + } as never, + }); + + expect(tools.map((tool) => tool.name)).toEqual( + expect.arrayContaining(["message", "paired_host_plugin"]), + ); + expect(factory).toHaveBeenCalledWith( + expect.objectContaining({ + toolConstructionPlan: { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: true, + includePluginTools: true, + }, + }), + ); + }); + + it("fails paired-device execution visibly when native execution is unavailable", async () => { + const workspaceDir = path.join(tempDir, "paired-node-workspace"); + const params = createParams(path.join(tempDir, "paired-node-session.jsonl"), workspaceDir); + params.disableTools = false; + params.runtimePlan = createCodexRuntimePlanFixture(); + const factory = vi.fn(() => [createRuntimeDynamicTool("exec")]); + setOpenClawCodingToolsFactoryForTests(factory); + + await expect( + buildDynamicToolsForTest(params, workspaceDir, { + sandbox: { + enabled: true, + backendId: "node", + placementExecutionMode: "remote-exec", + placementNodeId: "paired-device-1", + } as never, + nativeToolSurfaceEnabled: false, + }), + ).rejects.toThrow("requires its native exec-server tool surface"); + expect(factory).not.toHaveBeenCalled(); + }); + it("uses the prepared explicit-policy fact to disable the native surface", () => { const params = createParams("/tmp/session.jsonl", "/tmp/workspace"); params.disableTools = false; @@ -2444,6 +2507,20 @@ describe("Codex app-server dynamic tool build", () => { }), ).toBe(true); + expect( + shouldEnableCodexAppServerNativeToolSurface( + params, + { + ...sandbox, + backendId: "node", + backend: undefined, + placementExecutionMode: "remote-exec", + placementNodeId: "device-1", + } as never, + { sandboxExecServerEnabled: true }, + ), + ).toBe(true); + expect( shouldEnableCodexAppServerNativeToolSurface( params, diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 2a22a97dd67f..4511df38f4da 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -74,6 +74,35 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [ "apply_patch", ] as const; const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]); + +/** Keeps paired-device filesystem and process ownership on its native exec-server. */ +export function resolveCodexNodePlacementToolConstructionPlan( + sandbox: OpenClawSandboxContext | undefined, + nativeToolSurfaceEnabled: boolean | undefined, +): OpenClawCodingToolsOptions["toolConstructionPlan"] { + if ( + !isCodexRemoteExecPlacementSandbox(sandbox) || + sandbox?.backendId !== "node" || + !("placementNodeId" in sandbox) || + typeof sandbox.placementNodeId !== "string" || + !sandbox.placementNodeId + ) { + return undefined; + } + if (!nativeToolSurfaceEnabled) { + throw new Error( + "Codex paired-device remote execution requires its native exec-server tool surface; adjust the session tool policy and start a fresh attempt.", + ); + } + return { + includeBaseCodingTools: false, + includeShellTools: false, + includeChannelTools: true, + includeOpenClawTools: true, + includePluginTools: true, + }; +} + function preserveRingZeroSystemAgentTool( allTools: T[], filteredTools: T[], @@ -254,6 +283,10 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { }); const webFetchHostnameAllowlistRef: { value?: string[] } = {}; const buildOpenClawCodingTools = () => { + const toolConstructionPlan = resolveCodexNodePlacementToolConstructionPlan( + input.sandbox, + input.nativeToolSurfaceEnabled, + ); const options: OpenClawCodingToolsOptions = { agentId: input.sessionAgentId, ...buildEmbeddedAttemptToolRunContext(params), @@ -268,6 +301,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { ? { mode: params.permissionMode, root: params.sessionRoot } : undefined, sandbox: input.sandbox, + ...(toolConstructionPlan ? { toolConstructionPlan } : {}), messageProvider: resolveCodexMessageToolProvider(params), toolPolicyMessageProvider: params.messageProvider ?? params.messageChannel, // Capability-gated tools (requiredClientCaps) need the originating client's @@ -688,7 +722,7 @@ function canCodexAppServerNativeToolSurfaceHonorSandbox( } if ( options.sandboxExecServerEnabled === true && - sandbox.backend && + (sandbox.backend || isCodexRemoteExecPlacementSandbox(sandbox)) && canSandboxToolPolicyExposeCodexNativeToolSurface(sandbox) ) { return true; diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index b4b7da7f862f..d1a81bd14571 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -10,6 +10,7 @@ import { } from "openclaw/plugin-sdk/agent-harness-runtime"; import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { AttemptFailureSource, EmbeddedRunAttemptResult } from "./attempt-terminal.js"; +import { persistCodexContextCompactionActivity } from "./context-compaction-activity.js"; import { CodexAssistantProjection } from "./event-projector-assistant.js"; import { CodexProjectionDiagnostics } from "./event-projector-diagnostics.js"; import { CodexEventProjection } from "./event-projector-events.js"; @@ -520,6 +521,16 @@ export class CodexAppServerEventProjector { channelId: this.params.messageChannel ?? this.params.messageProvider ?? undefined, }, }); + await persistCodexContextCompactionActivity({ + sessionTarget: this.params.sessionTarget, + config: this.params.config, + cwd: this.params.workspaceDir, + runId: this.params.runId, + threadId: this.threadId, + turnId: this.turnId, + itemId, + timestamp: this.nextTranscriptTimestamp(), + }); this.emitCompactionEnd(itemId, true); } this.toolProgressProjection.recordToolMeta(item); diff --git a/extensions/codex/src/app-server/run-attempt-connection.test.ts b/extensions/codex/src/app-server/run-attempt-connection.test.ts index 38bc0d8ff50d..f36502fadcd7 100644 --- a/extensions/codex/src/app-server/run-attempt-connection.test.ts +++ b/extensions/codex/src/app-server/run-attempt-connection.test.ts @@ -19,6 +19,10 @@ import { testCodexAppServerBindingStore, writeCodexAppServerBinding, } from "./session-binding.test-helpers.js"; +import { + createIsolatedCodexAppServerClient, + getLeasedSharedCodexAppServerClient, +} from "./shared-client.js"; setupRunAttemptTestHooks(); @@ -154,6 +158,69 @@ describe("prepareCodexAttemptConnection", () => { expect(connection.disableLoginShell).toBe(true); }); + it.each([ + { + name: "paired-device remote execution", + placement: { placementExecutionMode: "remote-exec", placementNodeId: "paired-device-1" }, + expectedFactory: createIsolatedCodexAppServerClient, + }, + { + name: "SSH remote execution", + placement: { placementExecutionMode: "remote-exec" }, + expectedFactory: getLeasedSharedCodexAppServerClient, + }, + { + name: "local sandbox execution", + placement: {}, + expectedFactory: getLeasedSharedCodexAppServerClient, + }, + ])( + "selects the correct app-server ownership for $name", + async ({ placement, expectedFactory }) => { + const sessionFile = path.join( + tempDir, + `client-ownership-${placement.placementNodeId ?? "other"}.jsonl`, + ); + const workspaceDir = path.join( + tempDir, + `workspace-client-ownership-${placement.placementNodeId ?? "other"}`, + ); + const params = createParams(sessionFile, workspaceDir); + params.sandbox = { ...createSandboxContext({}), ...placement } as NonNullable< + typeof params.sandbox + >; + if (placement.placementExecutionMode === "remote-exec") { + const runtimePlan = createCodexRuntimePlanFixture(); + params.runtimePlan = { + ...runtimePlan, + auth: { + ...runtimePlan.auth, + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: { + provider: "openai", + modelId: "gpt-5.4-codex", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authRequirement: "api-key", + requestTransportOverrides: "none", + }, + }, + }; + params.resolvedApiKey = "prepared-test-key"; + } + registerCodexTestSessionIdentity(sessionFile, params.sessionId, params.sessionKey); + + const connection = await prepareCodexAttemptConnection({ + params, + options: { bindingStore: testCodexAppServerBindingStore }, + }); + + expect(connection.attemptClientFactory).toBe(expectedFactory); + }, + ); + it("keeps a user-home subscription on native account verification", async () => { const sessionFile = path.join(tempDir, "user-home-native-auth.jsonl"); const workspaceDir = path.join(tempDir, "workspace-user-home-native-auth"); diff --git a/extensions/codex/src/app-server/run-attempt-connection.ts b/extensions/codex/src/app-server/run-attempt-connection.ts index b0722aaee71c..fd53e8506d62 100644 --- a/extensions/codex/src/app-server/run-attempt-connection.ts +++ b/extensions/codex/src/app-server/run-attempt-connection.ts @@ -21,6 +21,7 @@ import { import { resolveCodexBindingAppServerConnection } from "./binding-connection.js"; import { canUseCodexModelBackedApprovalsReviewerForModel, + isCodexPairedNodeRemoteExecPlacementSandbox, isCodexRemoteExecPlacementSandbox, readCodexPluginConfig, readCodexRequirementsToml, @@ -47,7 +48,10 @@ import { applyCodexSessionPermissionPolicy, resolveCodexSessionPermissionCwd, } from "./session-permission-policy.js"; -import { getLeasedSharedCodexAppServerClient } from "./shared-client.js"; +import { + createIsolatedCodexAppServerClient, + getLeasedSharedCodexAppServerClient, +} from "./shared-client.js"; import { rotateOversizedCodexAppServerStartupBinding } from "./startup-binding.js"; export async function prepareCodexAttemptConnection({ params, options }: CodexRunAttemptInput) { @@ -69,7 +73,6 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu const preDynamicStartupStages = createCodexDynamicToolBuildStageTracker({ enabled: profilerEnabled, }); - const attemptClientFactory = options.clientFactory ?? getLeasedSharedCodexAppServerClient; const runtimeArtifactRequest = params.captureRuntimeArtifact || params.expectedRuntimeArtifact ? params.expectedRuntimeArtifact @@ -99,6 +102,12 @@ export async function prepareCodexAttemptConnection({ params, options }: CodexRu sessionKey: sandboxSessionKey, workspaceDir: resolvedWorkspace, }); + // Upstream cannot remove registered environments, so node leases own one disposable client. + const attemptClientFactory = + options.clientFactory ?? + (isCodexPairedNodeRemoteExecPlacementSandbox(sandbox) + ? createIsolatedCodexAppServerClient + : getLeasedSharedCodexAppServerClient); preDynamicStartupStages.mark("sandbox"); const execPolicy = resolveOpenClawExecPolicyForCodexAppServer({ // Explicit modes replace legacy fields; full also replaces approval-file floors. diff --git a/extensions/codex/src/app-server/run-attempt-context.ts b/extensions/codex/src/app-server/run-attempt-context.ts index d153858127f7..57858d771653 100644 --- a/extensions/codex/src/app-server/run-attempt-context.ts +++ b/extensions/codex/src/app-server/run-attempt-context.ts @@ -4,6 +4,7 @@ import { CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, embeddedAgentLog, getAgentHarnessHookRunner, + isHostScopedAgentToolActive, resolveContextEngineOwnerPluginId, runHarnessContextEngineMaintenance, } from "openclaw/plugin-sdk/agent-harness-runtime"; @@ -21,6 +22,7 @@ import { resolveCodexContinuityProjectionMaxChars, type CodexProjectedContextRange, } from "./context-engine-projection.js"; +import { isSystemAgentOnlyCodexDynamicToolAllowlist } from "./dynamic-tool-profile.js"; import type { CodexAttemptRuntime } from "./run-attempt-runtime.js"; import { joinPresentSections } from "./run-attempt-state.js"; import type { CodexAttemptTools } from "./run-attempt-tool-setup.js"; @@ -148,11 +150,14 @@ export async function prepareCodexAttemptContext( sessionKey: contextSessionKey, sessionAgentId, memoryToolNames, + ringZeroActive: + isHostScopedAgentToolActive("openclaw") && + isSystemAgentOnlyCodexDynamicToolAllowlist(runtimeParams.toolsAllow), sandboxed: sandbox?.enabled === true, }); // A thread keeps the bounded agent-workspace snapshot captured at creation. // Workspace edits take effect only in the next session. - const agentWorkspaceDeveloperInstructions = workspaceBootstrapContext.inheritsAgentWorkspace + const agentWorkspaceDeveloperInstructions = workspaceBootstrapContext.threadDeveloperInstructions ? (connection.mutable.startupBinding?.agentWorkspaceDeveloperInstructions ?? workspaceBootstrapContext.threadDeveloperInstructions) : undefined; diff --git a/extensions/codex/src/app-server/run-attempt-finalize.ts b/extensions/codex/src/app-server/run-attempt-finalize.ts index 87fb45abc986..9cc85b6a14b4 100644 --- a/extensions/codex/src/app-server/run-attempt-finalize.ts +++ b/extensions/codex/src/app-server/run-attempt-finalize.ts @@ -137,15 +137,21 @@ export async function finalizeCodexAttempt( const effectiveTimedOut = state.timedOut && !recoveredTurnWatchTimeout; const effectiveTurnCompletionIdleTimedOut = state.turnCompletionIdleTimedOut && !recoveredTurnWatchTimeout; + // Transport loss aborts in-flight work mechanically, but its terminal outcome + // must remain a failure unless the operator explicitly canceled the attempt. const isFinalAborted = () => - projectedTerminal.aborted || terminalState.explicitCancellationObserved || - (runAbortController.signal.aborted && !state.clientClosedAbort && !recoveredTurnWatchTimeout); + (!resourceState.executionDisconnectError && + (projectedTerminal.aborted || + (runAbortController.signal.aborted && + !state.clientClosedAbort && + !recoveredTurnWatchTimeout))); const clientClosedPromptErrorForFinal = state.clientClosedPromptError && hasRecoverableCompletedAssistant ? undefined : state.clientClosedPromptError; let finalPromptError = + resourceState.executionDisconnectError ?? clientClosedPromptErrorForFinal ?? (effectiveTurnCompletionIdleTimedOut ? state.turnCompletionIdleTimeoutMessage @@ -213,6 +219,9 @@ export async function finalizeCodexAttempt( rateLimits: readRecentCodexRateLimits(resourceState.client), }); } + // Device loss can arrive during asynchronous failure enrichment. Re-read its + // owner before freezing derived success, cancellation, and terminal state. + finalPromptError = resourceState.executionDisconnectError ?? finalPromptError; const finalPromptErrorSource = effectiveTimedOut || clientClosedPromptErrorForFinal ? "prompt" diff --git a/extensions/codex/src/app-server/run-attempt-resources.ts b/extensions/codex/src/app-server/run-attempt-resources.ts index 4a7d9194beae..f22dd9b86a1d 100644 --- a/extensions/codex/src/app-server/run-attempt-resources.ts +++ b/extensions/codex/src/app-server/run-attempt-resources.ts @@ -21,10 +21,14 @@ import { import { codexNativeSubagentMonitorRuntime } from "./native-subagent-monitor.js"; import type { CodexSandboxPolicy, CodexTurnEnvironmentParams } from "./protocol.js"; import type { CodexAttemptPrompt } from "./run-attempt-prompt.js"; -import { releaseCodexSandboxExecServerEnvironment } from "./sandbox-exec-server.js"; +import { + releaseCodexSandboxExecServerEnvironment, + type CodexSandboxExecEnvironment, +} from "./sandbox-exec-server.js"; import type { CodexAppServerThreadBinding } from "./session-binding.js"; import { clearSharedCodexAppServerClientIfCurrentAndUnclaimed, + createIsolatedCodexAppServerClient, retainSharedCodexAppServerClientIfCurrent, } from "./shared-client.js"; import type { CodexAppServerThreadLifecycleBinding } from "./thread-lifecycle.js"; @@ -55,6 +59,13 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { trajectory: params.hostCapabilities.trajectory, tools: toolBridge.availableSpecs, }); + const executionState: { + sandboxExecEnvironment: CodexSandboxExecEnvironment | undefined; + executionDisconnectError: Error | undefined; + } = { + sandboxExecEnvironment: undefined, + executionDisconnectError: undefined, + }; const state = { client: undefined as unknown as CodexAppServerClient, thread: undefined as unknown as CodexAppServerThreadLifecycleBinding, @@ -76,7 +87,7 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { releaseSharedClientLease: undefined as (() => void) | undefined, startupClientUnsafe: false, sharedCodexClientRetiredForOneShotCleanup: false, - sandboxExecEnvironmentAcquired: false, + ...executionState, codexEnvironmentSelection: undefined as CodexTurnEnvironmentParams[] | undefined, codexExecutionCwd: effectiveCwd, codexSandboxPolicy: undefined as CodexSandboxPolicy | undefined, @@ -148,16 +159,25 @@ export function prepareCodexAttemptResources(prompt: CodexAttemptPrompt) { await state.client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }); } }; + const releaseSandboxExecEnvironment = async () => { + if (state.sandboxExecEnvironment) { + const environment = state.sandboxExecEnvironment; + state.sandboxExecEnvironment = undefined; + await releaseCodexSandboxExecServerEnvironment(sandbox, environment); + } + }; const releaseSharedClientLeaseAndRetireOneShotClient = async () => { + if (connection.attemptClientFactory === createIsolatedCodexAppServerClient) { + // Close the authorized node lease first; losing its socket first is a real disconnect. + await releaseSandboxExecEnvironment(); + const ownedClient = state.releaseSharedClientLease ? state.client : undefined; + releaseSharedClientLeaseOnce(); + await ownedClient?.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }); + return; + } releaseSharedClientLeaseOnce(); await retireSharedCodexClientForOneShotCleanup(); }; - const releaseSandboxExecEnvironment = async () => { - if (state.sandboxExecEnvironmentAcquired) { - state.sandboxExecEnvironmentAcquired = false; - await releaseCodexSandboxExecServerEnvironment(sandbox); - } - }; const runCleanupStep = (step: string, operation: () => Promise | void | undefined) => runAgentCleanupStep({ runId: params.runId, diff --git a/extensions/codex/src/app-server/run-attempt-start.ts b/extensions/codex/src/app-server/run-attempt-start.ts index 8523babd2825..aeff465e51e1 100644 --- a/extensions/codex/src/app-server/run-attempt-start.ts +++ b/extensions/codex/src/app-server/run-attempt-start.ts @@ -82,6 +82,7 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) const startupResult = await startCodexAttemptThread({ attemptClientFactory, bindingStore, + runtime: connection.options.runtime, appServer: pluginAppServer, pluginConfig, computerUseConfig, @@ -119,6 +120,11 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) startupTimeoutMs, signal: runAbortController.signal, onStartupTimeout: () => runAbortController.abort("codex_startup_timeout"), + onExecutionDisconnect: (error) => { + state.executionDisconnectError = error; + embeddedAgentLog.warn(error.message); + runAbortController.abort("client_closed"); + }, spawnedBy: params.spawnedBy, }); state.client = startupResult.client; @@ -127,7 +133,7 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) state.turnRouter = startupResult.turnRouter; state.turnRoute = startupResult.turnRoute; // Adopt cleanup ownership before any fallible validation of the started thread. - state.sandboxExecEnvironmentAcquired = Boolean(startupResult.sandboxEnvironment); + state.sandboxExecEnvironment = startupResult.sandboxEnvironment; state.releaseSharedClientLease = startupResult.releaseSharedClientLease; state.restartContextEngineCodexThread = startupResult.restartContextEngineCodexThread; pluginAppServer = startupResult.pluginAppServer; @@ -232,6 +238,6 @@ export async function startCodexAttemptRuntime(resources: CodexAttemptResources) await runCleanupStep("codex-start-failure-abort-listener", () => params.abortSignal?.removeEventListener("abort", abortFromUpstream), ); - throw error; + throw state.executionDisconnectError ?? error; } } diff --git a/extensions/codex/src/app-server/run-attempt-types.ts b/extensions/codex/src/app-server/run-attempt-types.ts index 4393d3c3089e..9d2a071c6e3a 100644 --- a/extensions/codex/src/app-server/run-attempt-types.ts +++ b/extensions/codex/src/app-server/run-attempt-types.ts @@ -2,11 +2,13 @@ import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams, NativeHookRelayEvent, } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { CodexAppServerBindingStore } from "./session-binding.js"; import type { CodexAppServerClientFactory } from "./shared-client.js"; export type CodexRunAttemptOptions = { bindingStore: CodexAppServerBindingStore; + runtime?: PluginRuntime; pluginConfig?: unknown; /** Private app-server request identity; public attempt identity remains params.modelId. */ runtimeModelId?: string; diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 1dde07c9ff37..fcc135ee997b 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -429,6 +429,7 @@ async function buildCodexTurnContextForTest( sessionKey: params.sessionKey ?? params.sessionId, sessionAgentId, memoryToolNames, + ringZeroActive: false, }); const threadDeveloperInstructions = testing.buildDeveloperInstructions(params, { dynamicTools }); const openClawPromptContext = buildCodexOpenClawPromptContext({ @@ -2415,6 +2416,10 @@ describe("runCodexAppServerAttempt", () => { deny: ["exec", "process", "write", "edit"], }; params.pluginHarnessToolPolicyRestricted = true; + const agentsGuidance = "Restricted turns keep workspace AGENTS guidance."; + await fs.mkdir(params.workspaceDir, { recursive: true }); + await fs.writeFile(path.join(params.workspaceDir, "AGENTS.md"), agentsGuidance); + setAgentWorkspaceForTest(params, params.workspaceDir); const onAgentEvent = vi.fn(); params.onAgentEvent = onAgentEvent; const harness = createStartedThreadHarness(async (method) => { @@ -2437,6 +2442,7 @@ describe("runCodexAppServerAttempt", () => { | { dynamicTools?: CodexDynamicToolSpec[]; environments?: unknown[]; + developerInstructions?: string; config?: Record; } | undefined; @@ -2445,6 +2451,8 @@ describe("runCodexAppServerAttempt", () => { ); expect(startParams?.environments).toEqual([]); + expect(startParams?.config?.project_doc_max_bytes).toBe(131_072); + expect(startParams?.developerInstructions?.split(agentsGuidance)).toHaveLength(2); expect(startParams?.config?.["tools.update_plan.enabled"]).toBe(false); expect(dynamicToolNames.toSorted()).toEqual(["apply_patch", "progress_card", "read"]); const progressCardSpec = flattenSpecsWithNamespace(startParams?.dynamicTools ?? []).find( diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 86e1cb1950ee..1d965c2c128f 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -66,8 +66,9 @@ export async function runCodexAppServerAttempt( turnStart.turn, ); + let finalizedResult: EmbeddedRunAttemptResult; try { - return await finalizeCodexAttempt( + finalizedResult = await finalizeCodexAttempt( resources, turnRuntime, lifecycle, @@ -78,4 +79,13 @@ export async function runCodexAppServerAttempt( } finally { await cleanupCodexAttempt(resources, turnRuntime, lifecycle, turnRequest, activeTurn); } + // Cleanup retires the execution lease; only then can device loss no longer + // race the final result captured during asynchronous terminal processing. + if ( + resources.state.executionDisconnectError && + !connection.terminalState.explicitCancellationObserved + ) { + throw resources.state.executionDisconnectError; + } + return finalizedResult; } diff --git a/extensions/codex/src/app-server/sandbox-exec-server-node-relay.test.ts b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.test.ts new file mode 100644 index 000000000000..f904164b4738 --- /dev/null +++ b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.test.ts @@ -0,0 +1,700 @@ +import { once } from "node:events"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js"; +import { + ensureCodexSandboxExecServerEnvironment, + releaseCodexSandboxExecServerEnvironment, +} from "./sandbox-exec-server.js"; +import { + createClient, + createSandboxContext, + execServerUrlFromClient, + openSocket, + waitForSocketClose, +} from "./sandbox-exec-server.test-helpers.js"; + +const customLoggingPattern = vi.hoisted(() => ({ value: "" })); +vi.mock("openclaw/plugin-sdk/logging-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + redactToolPayloadText: (text: string) => { + const redacted = actual.redactToolPayloadText(text); + return customLoggingPattern.value + ? redacted.replaceAll(customLoggingPattern.value, "[redacted]") + : redacted; + }, + }; +}); + +const MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES = 64 * 1024 * 1024; + +type NodeChannel = Awaited>; + +function createNodeChannel() { + let resolveClosed: (result: unknown) => void = () => {}; + let rejectClosed: (error: Error) => void = () => {}; + let receive: ((message: Uint8Array) => void | Promise) | undefined; + let channelClosed = false; + const closed = new Promise((resolve, reject) => { + resolveClosed = resolve; + rejectClosed = reject; + }); + const channel = { + send: vi.fn(async () => { + if (channelClosed) { + throw new Error("execution channel closed"); + } + }), + onMessage: vi.fn((listener) => { + receive = listener; + return () => { + receive = undefined; + }; + }), + closed, + close: vi.fn(() => { + channelClosed = true; + resolveClosed({ ok: true }); + }), + } satisfies NodeChannel; + return { + channel, + disconnect: () => resolveClosed({ ok: false, error: "device disconnected" }), + fail: (error: Error) => rejectClosed(error), + receive: async (message: Uint8Array) => await receive?.(message), + }; +} + +function createNodeSandbox() { + return { + ...createSandboxContext({}), + backendId: "node", + backend: undefined, + fsBridge: undefined, + placementExecutionMode: "remote-exec" as const, + placementNodeId: "paired-device-1", + placementEnvironmentId: "environment-paired-device-1", + placementSessionId: "session-paired-device-1", + placementOwnerEpoch: 7, + containerWorkdir: "/remote/managed-workspace", + }; +} + +function createNodeRuntime(openDuplex: PluginRuntime["nodes"]["openDuplex"]): PluginRuntime { + return { nodes: { openDuplex } } as PluginRuntime; +} + +function encodeHttpBody(contentType: string, body: string) { + return { + headers: [{ name: "Content-Type", value: contentType }], + bodyBase64: Buffer.from(body).toString("base64"), + }; +} + +async function expectPairedNodeHttpCredentialRejection(params: { + url?: string; + headers?: Array<{ name: string; value: string }>; + bodyBase64?: string; +}): Promise { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + const onExecutionDisconnect = vi.fn<(error: Error) => void>(); + await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + onExecutionDisconnect, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + let resolveForwarded: () => void = () => {}; + const forwarded = new Promise((resolve) => { + resolveForwarded = resolve; + }); + transport.channel.send.mockImplementation(async () => resolveForwarded()); + const outcome = Promise.race([ + once(socket, "message").then(([message]) => ({ + kind: "rejected" as const, + message: JSON.parse(Buffer.from(message as Buffer).toString()) as { + id: number; + error: { code: number; message: string }; + }, + })), + forwarded.then(() => ({ kind: "forwarded" as const })), + ]); + + socket.send( + JSON.stringify({ + id: 13, + method: "http/request", + params: { + method: "POST", + url: "https://example.test", + headers: [], + ...params, + requestId: "request-13", + }, + }), + ); + + const result = await outcome; + expect(result.kind).toBe("rejected"); + if (result.kind !== "rejected") { + return; + } + expect(result.message.id).toBe(13); + expect(result.message.error).toEqual({ + code: -32602, + message: expect.stringMatching(/authenticated remote HTTP.*Gateway.*credential-free/i), + }); + expect(JSON.stringify(result.message)).not.toContain("synthetic-canary"); + expect(transport.channel.send).not.toHaveBeenCalled(); + expect(onExecutionDisconnect).not.toHaveBeenCalled(); +} + +afterEach(async () => { + customLoggingPattern.value = ""; + await sandboxExecServerRegistry.closeAll(); +}); + +describe("Codex paired-device exec-server relay", () => { + it("authorizes one bounded attempt-owned node channel before registering the local environment", async () => { + const transport = createNodeChannel(); + const openDuplex = vi.fn(async () => transport.channel); + const sandbox = createNodeSandbox(); + const client = createClient(); + const attempt = new AbortController(); + + const environment = await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(openDuplex), + signal: attempt.signal, + }); + + expect(environment).toEqual({ + environmentId: expect.stringMatching(/^openclaw-node-/), + cwd: "/remote/managed-workspace", + }); + expect(environment?.environmentId.length).toBeLessThanOrEqual(64); + expect(openDuplex).toHaveBeenCalledWith({ + nodeId: "paired-device-1", + command: "codex.exec-server.stdio.v1", + params: { + cwd: "/remote/managed-workspace", + environmentId: "environment-paired-device-1", + sessionId: "session-paired-device-1", + ownerEpoch: 7, + sessionKey: sandbox.sessionKey, + }, + sessionKey: sandbox.sessionKey, + timeoutMs: 0, + maxMessageBytes: MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES, + maxOutstandingDeliveryBytes: MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES + 2 * 1024 * 1024, + signal: attempt.signal, + }); + expect(openDuplex.mock.invocationCallOrder[0]).toBeLessThan( + client.request.mock.invocationCallOrder[0] ?? Infinity, + ); + expect(execServerUrlFromClient(client)).toMatch(/^ws:\/\/127\.0\.0\.1:\d+\/openclaw-/); + }); + + it.each([ + ["missing environment", { placementEnvironmentId: "" }], + ["invalid session", { placementSessionId: " session " }], + ["negative owner epoch", { placementOwnerEpoch: -1 }], + ["zero owner epoch", { placementOwnerEpoch: 0 }], + ["missing session key", { sessionKey: "" }], + ])( + "rejects a node workspace with %s before opening a channel", + async (_label, invalidIdentity) => { + const sandbox = { ...createNodeSandbox(), ...invalidIdentity }; + const client = createClient(); + const openDuplex = vi.fn(); + + await expect( + ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(openDuplex), + signal: new AbortController().signal, + }), + ).rejects.toThrow("exact placement workspace identity"); + + expect(openDuplex).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); + }, + ); + + it("preserves versionless and reverse JSON-RPC while scrubbing both process environment maps", async () => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + const environment = await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + const initialize = '{"id":1,"method":"initialize","params":{"clientName":"codex"}}'; + socket.send(initialize); + await vi.waitFor(() => expect(transport.channel.send).toHaveBeenCalledTimes(1)); + expect(Buffer.from(transport.channel.send.mock.calls[0]![0]).toString()).toBe(initialize); + + socket.send( + JSON.stringify({ + id: 2, + method: "process/start", + params: { + env: { + OPENAI_API_KEY: "secret-canary", // pragma: allowlist secret + GH_TOKEN: "token-canary", // pragma: allowlist secret + HTTPS_PROXY: ["https://user", "proxy-canary@proxy.example"].join(":"), + SAFE_CANARY: "ordinary-env", + URL: "https://x/e", + }, + envPolicy: { + inherit: "none", + set: { + OPENAI_API_KEY: "policy-secret-canary", // pragma: allowlist secret + GITHUB_TOKEN: "policy-token-canary", // pragma: allowlist secret + DATABASE_URL: ["postgres://user", "database-canary@db.example/app"].join(":"), + SAFE_POLICY: "ordinary-policy", + U: "https://x/p", + }, + }, + unknownFutureField: { preserved: true }, + }, + }), + ); + await vi.waitFor(() => expect(transport.channel.send).toHaveBeenCalledTimes(2)); + const forwarded = JSON.parse( + Buffer.from(transport.channel.send.mock.calls[1]![0]).toString(), + ) as { params: { env: unknown; envPolicy: { set: unknown }; unknownFutureField: unknown } }; + const { params } = forwarded; + expect(params.env).toEqual({ SAFE_CANARY: "ordinary-env", URL: "https://x/e" }); + expect(params.envPolicy.set).toEqual({ SAFE_POLICY: "ordinary-policy", U: "https://x/p" }); + expect(params.unknownFutureField).toEqual({ preserved: true }); + + const reverseRequest = JSON.stringify({ + id: 7, + method: "network/policyRequest", + params: { + processId: "policy-proof", + request: { protocol: "https_connect", host: "example.test", port: 443 }, + }, + }); + const received = once(socket, "message"); + await transport.receive(Buffer.from(reverseRequest)); + const [message] = await received; + expect(Buffer.from(message as Buffer).toString()).toBe(reverseRequest); + const reverseResponse = '{"id":7,"result":{"decision":{"type":"allow"}}}'; + socket.send(reverseResponse); + await vi.waitFor(() => expect(transport.channel.send).toHaveBeenCalledTimes(3)); + expect(Buffer.from(transport.channel.send.mock.calls[2]![0]).toString()).toBe(reverseResponse); + + await releaseCodexSandboxExecServerEnvironment(sandbox, environment); + expect(transport.channel.close).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["bearer authorization", [{ name: "Authorization", value: "Bearer synthetic-canary" }]], + ["OAuth authorization", [{ name: "authorization", value: "OAuth synthetic-canary" }]], + [ + "mixed-case proxy authorization", + [{ name: "pRoXy-AuThOrIzAtIoN", value: "Bearer synthetic-canary" }], + ], + ["request cookie", [{ name: "Cookie", value: "session=synthetic-canary" }]], + ["API key", [{ name: "X-Api-Key", value: "synthetic-canary" }]], + ["Google API key", [{ name: "x-goog-api-key", value: "synthetic-canary" }]], + ["Vault token", [{ name: "X-Vault-Token", value: "synthetic-canary" }]], + ["Cloudflare JWT assertion", [{ name: "Cf-Access-Jwt-Assertion", value: "synthetic-canary" }]], + ["request signature", [{ name: "X-Request-Signature", value: "synthetic-canary" }]], + ["one-time passcode", [{ name: "X-Provider-Otp", value: "synthetic-canary" }]], + ["plural credentials", [{ name: "X-Provider-Credentials", value: "synthetic-canary" }]], + ["mixed-case auth token", [{ name: "X-AuTh-ToKeN", value: "synthetic-canary" }]], + ["provider function key", [{ name: "x-functions-key", value: "synthetic-canary" }]], + [ + "credential after repeated safe headers", + [ + { name: "X-Trace", value: "first" }, + { name: "x-trace", value: "second" }, + { name: "aUtHoRiZaTiOn", value: "Bearer synthetic-canary" }, + ], + ], + ])( + "rejects %s before forwarding any credential to the paired device", + async (_label, headers) => await expectPairedNodeHttpCredentialRejection({ headers }), + ); + + it.each([ + [ + "URL Basic credentials", + { + url: (() => { + const url = new URL("https://example.test/path"); + url.username = "user"; + url.password = "synthetic-canary"; + return url.toString(); + })(), + }, + ], + ["OAuth URL access token", { url: "https://example.test/path?access_token=synthetic-canary" }], + ["session identity", { url: "https://example.test/path?sessionId=synthetic-canary" }], + ["matrix session", { url: "https://x/p;jsessionid=synthetic-canary" }], + ["matrix case", { url: "https://x/p;JSESSIONID=synthetic-canary" }], + ["encoded matrix", { url: "https://x/p%3Bjsessionid%3Dsynthetic-canary" }], + ["nested matrix", { url: "https://x/a;region=west/b;session_id=synthetic-canary" }], + ["nested fragment MFA", { url: "https://x/#/callback?mfa_code=123456" }], + ["direct access-token fragment", { url: "https://x/#access_token=synthetic-canary" }], + ["direct ticket fragment", { url: "https://x/#ticket=synthetic-canary" }], + ["encoded path MFA", { url: "https://x/callback%3Fmfa_code%3D123456" }], + ["nested encoded MFA", { url: "https://x/callback%253Fmfa_code%253D123456" }], + ["ticket query", { url: "https://x/?ticket=synthetic-canary" }], + ["bearer query", { url: "https://x/?bearer=synthetic-canary" }], + ["SAML assertion", { url: "https://example.test/path?SAMLResponse=synthetic-canary" }], + ["OAuth device code", { url: "https://example.test/path?device_code=synthetic-canary" }], + ["OAuth consumer key", { url: "https://x/?oauth_consumer_key=synthetic-canary" }], + ["encoded token", { url: `https://x/?v=${["sk", "live", "x".repeat(30)].join("%255F")}` }], + ["encoded nested credential", { url: "https://x/?u%255Bpassword%255D=synthetic-canary" }], + [ + "OAuth form body", + { + headers: [{ name: "Content-Type", value: "application/x-www-form-urlencoded" }], + bodyBase64: Buffer.from( + "grant_type=authorization_code&client_secret=synthetic-canary", + ).toString("base64"), + }, + ], + [ + "OAuth client assertion form body", + encodeHttpBody("application/x-www-form-urlencoded", "client_assertion=synthetic-canary"), + ], + [ + "passphrase form body", + encodeHttpBody("application/x-www-form-urlencoded", "passphrase=synthetic-canary"), + ], + ["pwd JSON body", encodeHttpBody("application/json", '{"pwd":"synthetic-canary"}')], + ["OAuth PKCE JSON", encodeHttpBody("application/json", '{"code_verifier":"synthetic-canary"}')], + [ + "duplicate escaped JSON", + encodeHttpBody( + "application/json", + '{"client_se\\u0063ret":"synthetic-canary","client_secret":"safe"}', + ), + ], + [ + "oversized JSON", + encodeHttpBody("application/json", `{"safe":"${"x".repeat(1024 * 1024 + 1)}"}`), + ], + [ + "oversized body without a declared content type", + { bodyBase64: Buffer.from("x".repeat(1024 * 1024 + 1)).toString("base64") }, + ], + [ + "invalid JSON body", + encodeHttpBody("application/json", '{"client_assertion":"synthetic-canary"'), + ], + [ + "unsupported XML body", + encodeHttpBody("application/xml", "synthetic-canary"), + ], + [ + "unsupported multipart body", + encodeHttpBody( + "multipart/form-data; boundary=test", + "--test\r\nsynthetic-canary\r\n--test--", + ), + ], + ["unsupported opaque body", { bodyBase64: Buffer.from("synthetic-canary").toString("base64") }], + [ + "XML disguised as plain text", + encodeHttpBody("text/plain", "synthetic-canary"), + ], + ["invalid UTF-8 body", { bodyBase64: Buffer.from([0xff, 0xfe]).toString("base64") }], + [ + "canonical Slack webhook URL", + { + url: new URL( + ["services", `T${"1".repeat(10)}`, `B${"2".repeat(10)}`, "x".repeat(25)].join("%252F"), + "https://hooks.slack.com/", + ).toString(), + }, + ], + [ + "canonical Discord webhook URL", + { + url: new URL( + ["api", "webhooks", "1".repeat(18), "x".repeat(68)].join("/"), + "https://discord.com/", + ).toString(), + }, + ], + ])( + "rejects %s before forwarding any credential to the paired device", + async (_label, params) => await expectPairedNodeHttpCredentialRejection(params), + ); + + it("honors operator-configured custom logging patterns in decoded values", async () => { + customLoggingPattern.value = "tenant-pattern-canary"; + await expectPairedNodeHttpCredentialRejection({ + url: `https://example.test/?safe=${customLoggingPattern.value.replaceAll("-", "%252D")}`, + }); + await expectPairedNodeHttpCredentialRejection( + encodeHttpBody( + "application/json", + '{"safe":"tenant\\u002dpattern\\u002dcanary","safe":"ok"}', + ), + ); + }); + + it.each([ + [ + "JSON nested routing", + "application/json", + JSON.stringify({ greeting: "hello", token_count: 2, status_code: 200 }), + "https://x/stream;region=west#/callback?view=summary", + ], + ["ordinary plain text", "text/plain", "ordinary body", "https://x/c%3Fv%3Ds"], + ["bracketed plain text", "text/plain", "[INFO] deployment completed", "https://x/"], + ["large plain text", "text/plain", "x".repeat(1024 * 1024 + 1), "https://x/"], + ])("forwards credential-free %s byte-for-byte", async (_label, contentType, body, url) => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + const request = JSON.stringify({ + id: 14, + method: "http/request", + params: { + method: "POST", + url, + headers: [ + { name: "X-Trace", value: "first" }, + { name: "x-trace", value: "second" }, + { name: "Content-Type", value: contentType }, + ], + bodyBase64: Buffer.from(body).toString("base64"), + redirectPolicy: "follow", + requestId: "request-14", + streamResponse: true, + }, + }); + + socket.send(request); + + await vi.waitFor(() => expect(transport.channel.send).toHaveBeenCalledOnce()); + expect(Buffer.from(transport.channel.send.mock.calls[0]![0]).toString()).toBe(request); + }); + + it("rejects replay of a claimed channel and binds simultaneous leases to fresh exact channels", async () => { + const channels = [createNodeChannel(), createNodeChannel()]; + let nextChannel = 0; + const openDuplex = vi.fn( + async () => channels[nextChannel++]!.channel, + ); + const sandbox = createNodeSandbox(); + const firstClient = createClient(); + const secondClient = createClient(); + const firstDisconnected = vi.fn<(error: Error) => void>(); + const secondDisconnected = vi.fn<(error: Error) => void>(); + const runtime = createNodeRuntime(openDuplex); + const first = await ensureCodexSandboxExecServerEnvironment({ + client: firstClient as never, + sandbox, + runtime, + signal: new AbortController().signal, + onExecutionDisconnect: firstDisconnected, + }); + const second = await ensureCodexSandboxExecServerEnvironment({ + client: secondClient as never, + sandbox, + runtime, + signal: new AbortController().signal, + onExecutionDisconnect: secondDisconnected, + }); + expect(first?.environmentId).not.toBe(second?.environmentId); + expect(firstClient.request).toHaveBeenCalledWith( + "environment/add", + expect.objectContaining({ environmentId: first?.environmentId }), + expect.any(Object), + ); + expect(secondClient.request).toHaveBeenCalledWith( + "environment/add", + expect.objectContaining({ environmentId: second?.environmentId }), + expect.any(Object), + ); + const firstSocket = await openSocket(execServerUrlFromClient(firstClient)); + const secondSocket = await openSocket(execServerUrlFromClient(secondClient)); + firstSocket.send('{"id":1,"method":"environment/info"}'); + secondSocket.send('{"id":2,"method":"environment/status"}'); + await vi.waitFor(() => { + expect(channels[0]!.channel.send).toHaveBeenCalledTimes(1); + expect(channels[1]!.channel.send).toHaveBeenCalledTimes(1); + }); + + const replay = await openSocket(execServerUrlFromClient(firstClient)); + await expect(waitForSocketClose(replay)).resolves.toEqual({ code: 1008 }); + const firstSocketClosed = waitForSocketClose(firstSocket); + await releaseCodexSandboxExecServerEnvironment(sandbox, first); + expect(channels[0]!.channel.close).toHaveBeenCalledTimes(1); + expect(channels[1]!.channel.close).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(firstSocket.readyState).toBe(firstSocket.CLOSED)); + await expect(firstSocketClosed).resolves.toEqual({ code: 1001 }); + expect(firstDisconnected).not.toHaveBeenCalled(); + expect(secondDisconnected).not.toHaveBeenCalled(); + secondSocket.send('{"id":3,"method":"environment/info"}'); + await vi.waitFor(() => expect(channels[1]!.channel.send).toHaveBeenCalledTimes(2)); + const secondReply = once(secondSocket, "message"); + await channels[1]!.receive(Buffer.from('{"id":3,"result":{"ok":true}}')); + await expect(secondReply).resolves.toEqual([ + Buffer.from('{"id":3,"result":{"ok":true}}'), + false, + ]); + const server = await sandboxExecServerRegistry.servers.get(sandbox.runtimeId); + expect(server?.cleanupTasks.size).toBe(1); + const secondSocketClosed = waitForSocketClose(secondSocket); + await releaseCodexSandboxExecServerEnvironment(sandbox, second); + expect(channels[1]!.channel.close).toHaveBeenCalledTimes(1); + await expect(secondSocketClosed).resolves.toEqual({ code: 1001 }); + expect(server?.cleanupTasks.size).toBe(0); + expect(firstDisconnected).not.toHaveBeenCalled(); + expect(secondDisconnected).not.toHaveBeenCalled(); + }); + + it("makes a node disconnect terminal and closes its transport exactly once", async () => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + const onExecutionDisconnect = vi.fn<(error: Error) => void>(); + const environment = await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + onExecutionDisconnect, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + const socketClosed = waitForSocketClose(socket); + transport.disconnect(); + await expect(socketClosed).resolves.toEqual({ code: 1001 }); + expect(onExecutionDisconnect).toHaveBeenCalledOnce(); + expect(onExecutionDisconnect).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("start a fresh attempt") }), + ); + await expect(transport.channel.send(Buffer.from("{}"))).rejects.toThrow( + "execution channel closed", + ); + await releaseCodexSandboxExecServerEnvironment(sandbox, environment); + expect(transport.channel.close).toHaveBeenCalledTimes(1); + }); + + it("fails an unclaimed node channel immediately before its loopback socket connects", async () => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + const attempt = new AbortController(); + let rejectRegistration: (error: Error) => void = () => {}; + const registration = new Promise>((_resolve, reject) => { + rejectRegistration = reject; + }); + client.request.mockImplementation(async () => await registration); + const onExecutionDisconnect = vi.fn((error: Error) => { + attempt.abort(error); + rejectRegistration(error); + }); + const environment = ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: attempt.signal, + onExecutionDisconnect, + }); + await vi.waitFor(() => expect(client.request).toHaveBeenCalledOnce()); + const fakeSecret = "sk-1234567890abcdef"; + + transport.fail(new Error(`exec-server exited: OPENAI_API_KEY=${fakeSecret}`)); + + await expect(environment).rejects.toThrow("exec-server exited"); + expect(onExecutionDisconnect).toHaveBeenCalledOnce(); + expect(onExecutionDisconnect.mock.calls[0]?.[0].message).not.toContain(fakeSecret); + expect(transport.channel.close).toHaveBeenCalledTimes(1); + expect(sandboxExecServerRegistry.servers.has(sandbox.runtimeId)).toBe(false); + }); + + it("surfaces bounded node-command failures without exposing credentials", async () => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + const onExecutionDisconnect = vi.fn<(error: Error) => void>(); + const environment = await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + onExecutionDisconnect, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + const socketClosed = waitForSocketClose(socket); + const fakeSecret = "sk-1234567890abcdef"; + + transport.fail( + new Error(`exec-server launch failed: OPENAI_API_KEY=${fakeSecret} ${"x".repeat(300)}`), + ); + + await expect(socketClosed).resolves.toEqual({ code: 1011 }); + expect(onExecutionDisconnect).toHaveBeenCalledOnce(); + const failure = onExecutionDisconnect.mock.calls[0]?.[0]; + expect(failure?.message).toContain("exec-server launch failed"); + expect(failure?.message).not.toContain(fakeSecret); + expect(failure?.message.length).toBeLessThan(360); + await releaseCodexSandboxExecServerEnvironment(sandbox, environment); + expect(transport.channel.close).toHaveBeenCalledTimes(1); + }); + + it("rejects device frames above the upstream 64 MiB JSON-RPC ceiling", async () => { + const transport = createNodeChannel(); + const sandbox = createNodeSandbox(); + const client = createClient(); + await ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(async () => transport.channel), + signal: new AbortController().signal, + }); + const socket = await openSocket(execServerUrlFromClient(client)); + const socketClosed = waitForSocketClose(socket); + await transport.receive(new Uint8Array(MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES + 1)); + await expect(socketClosed).resolves.toEqual({ code: 1009 }); + expect(transport.channel.close).toHaveBeenCalledTimes(1); + }); + + it("never registers an environment when paired-device authorization is denied", async () => { + const sandbox = createNodeSandbox(); + const client = createClient(); + const openDuplex = vi.fn(async () => { + throw new Error("paired-device approval denied"); + }); + + await expect( + ensureCodexSandboxExecServerEnvironment({ + client: client as never, + sandbox, + runtime: createNodeRuntime(openDuplex), + signal: new AbortController().signal, + }), + ).rejects.toThrow("paired-device approval denied"); + expect(client.request).not.toHaveBeenCalled(); + expect(sandboxExecServerRegistry.servers.has(sandbox.runtimeId)).toBe(false); + }); +}); diff --git a/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts new file mode 100644 index 000000000000..a702253fb66e --- /dev/null +++ b/extensions/codex/src/app-server/sandbox-exec-server-node-relay.ts @@ -0,0 +1,509 @@ +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import { redactToolPayloadText } from "openclaw/plugin-sdk/logging-core"; +import { sanitizeEnvVars } from "openclaw/plugin-sdk/sandbox"; +import { formatErrorMessage, redactSensitiveText } from "openclaw/plugin-sdk/security-runtime"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; +import type { RawData, WebSocket } from "ws"; +import type { CodexNodeExecServerLease } from "./sandbox-exec-server/types.js"; + +const CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES = 64 * 1024 * 1024; +const CODEX_NODE_EXEC_SERVER_MAX_FAILURE_DETAIL_CHARS = 240; +const CODEX_NODE_HTTP_CREDENTIAL_BODY_MAX_BYTES = 1024 * 1024; +const CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS = 256; +const CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH = 8; +// Plugin SDK exposes no credential classifiers; mirror canonical names without deep core imports. +const CODEX_NODE_HTTP_CREDENTIAL_HEADER_NAMES = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", + "api-key", + "apikey", + "x-auth-token", + "auth-token", + "x-access-token", + "access-token", + "x-secret-key", + "secret-key", + "x-goog-api-key", + "x-vault-token", + "x-api-token", +]); +const CODEX_NODE_HTTP_CREDENTIAL_HEADER_NAME_PATTERN = + /(?:^|[-_])(?:auth(?:orization|entication)?|token|secret|api[-_]?key|apikey|key|password|passwd|pwd|passphrase|passcode|credentials?|session|jwt|assertion|verifier|sig(?:nature)?|hmac|bearer|ticket|challenge|proof|dpop|otp|totp|pin|mfa)(?:[-_]|$)/iu; +const CODEX_NODE_HTTP_CREDENTIAL_FIELD_NAME_PATTERN = + /^(?:(?:[a-z\d]+_)*(?:token|secret|password|passwd|pwd|passphrase|passcode|credentials?|authorization|api_?key|private_key|secret_key|secret_access_key|jwt|assertion|verifier|signature|hmac|bearer|ticket|(?:oauth|consumer|auth|access)_key|otp|totp|pin)|(?:device|authorization|auth|verification|mfa)_code|session(?:_id)?|jsessionid|saml(?:_?response|_?assertion)?|auth|jwt|code|sig|signature|hmac|key|pass)$/u; +const nodeExecServerTextDecoder = new TextDecoder("utf-8", { fatal: true }); + +/** Produces the bounded, redacted terminal failure shared by pending and claimed node leases. */ +export function createCodexNodeExecServerDisconnectError(reason: string, cause?: unknown): Error { + const detail = + cause === undefined + ? "" + : `: ${truncateUtf16Safe( + redactSensitiveText(formatErrorMessage(cause), { mode: "tools" }), + CODEX_NODE_EXEC_SERVER_MAX_FAILURE_DETAIL_CHARS, + )}`; + return new Error( + `Codex paired execution device disconnected; start a fresh attempt. (${reason}${detail})`, + ); +} + +/** Relays one authorized, single-use Codex exec-server channel without interpreting its protocol. */ +export async function startCodexNodeExecServerRelay(params: { + lease: CodexNodeExecServerLease; + socket: WebSocket; +}): Promise { + const { channel } = params.lease; + const { socket } = params; + let closed = false; + const { promise: finished, resolve: finish } = createDeferred(); + let unsubscribe = () => {}; + + const closeBoth = (code = 1001, reason = "execution channel closed") => { + if (closed) { + return; + } + closed = true; + unsubscribe(); + params.lease.closeRelay = undefined; + params.lease.onChannelClosed = undefined; + if (!params.lease.closed) { + params.lease.closed = true; + channel.close(); + } + if (socket.readyState === socket.OPEN || socket.readyState === socket.CONNECTING) { + socket.close(code, reason); + } + finish(); + }; + // The shared loopback server can outlive this lease, so release owns its exact socket. + params.lease.closeRelay = closeBoth; + + const failUnexpectedly = (code: number, reason: string, cause?: unknown) => { + if (!closed && !params.lease.closed) { + params.lease.onDisconnected?.(createCodexNodeExecServerDisconnectError(reason, cause)); + } + closeBoth(code, reason); + }; + + params.lease.onChannelClosed = ({ failed, error }) => + failUnexpectedly( + failed ? 1011 : 1001, + failed ? "execution device failed" : "execution device disconnected", + error, + ); + socket.once("close", () => failUnexpectedly(1001, "execution socket closed")); + socket.once("error", () => failUnexpectedly(1011, "execution socket failed")); + + let toNode = Promise.resolve(); + socket.on("message", (data: RawData) => { + if (closed) { + return; + } + // Stop reading the app-server socket until node-carrier backpressure clears. + socket.pause(); + toNode = toNode + .then(async () => { + const frame = normalizeCodexExecServerFrame(data); + const request = validateCodexExecServerMessage(frame); + const rejection = rejectCredentialedCodexNodeHttpRequest(request); + if (rejection) { + await sendCodexExecServerFrame(socket, rejection); + } else { + await channel.send(sanitizeCodexExecServerRequest(frame, request)); + } + if (!closed) { + socket.resume(); + } + }) + .catch((error: unknown) => { + failUnexpectedly(error instanceof RangeError ? 1009 : 1007, "invalid execution message"); + }); + }); + + unsubscribe = channel.onMessage(async (message) => { + if (closed) { + return; + } + try { + const frame = normalizeCodexExecServerFrame(message); + validateCodexExecServerMessage(frame); + await sendCodexExecServerFrame(socket, frame); + } catch (error) { + failUnexpectedly(error instanceof RangeError ? 1009 : 1007, "invalid device message"); + } + }); + + await finished; +} + +function sendCodexExecServerFrame(socket: WebSocket, frame: Buffer): Promise { + return new Promise((resolve, reject) => { + socket.send(frame, { binary: false }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function normalizeCodexExecServerFrame(data: RawData | Uint8Array): Buffer { + const frame = Array.isArray(data) + ? Buffer.concat(data) + : Buffer.isBuffer(data) + ? data + : data instanceof Uint8Array + ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) + : Buffer.from(data); + if (frame.length > CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES) { + throw new RangeError("Codex exec-server message exceeds its 64 MiB limit."); + } + if (frame.includes(10) || frame.includes(13)) { + throw new Error("Codex exec-server messages must occupy exactly one stdio line."); + } + return frame; +} + +function validateCodexExecServerMessage(frame: Buffer): Record { + const parsed: unknown = JSON.parse(nodeExecServerTextDecoder.decode(frame)); + if (!isRecord(parsed)) { + throw new Error("Codex exec-server message must be a JSON object."); + } + return parsed; +} + +function rejectCredentialedCodexNodeHttpRequest( + request: Record, +): Buffer | undefined { + if (request.method !== "http/request") { + return undefined; + } + if (!isRecord(request.params)) { + throw new Error("Codex http/request params must be an object."); + } + const headers = request.params.headers ?? []; + if (!Array.isArray(headers)) { + throw new Error("Codex http/request headers must be an array."); + } + if (headers.length > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS) { + return createCredentialedCodexNodeHttpRejection(request); + } + let credentialBearing = false; + let contentType: string | undefined; + for (const header of headers) { + if (!isRecord(header) || typeof header.name !== "string" || typeof header.value !== "string") { + throw new Error("Codex http/request headers must contain string names and values."); + } + const name = header.name.trim().toLowerCase(); + if ( + CODEX_NODE_HTTP_CREDENTIAL_HEADER_NAMES.has(name) || + CODEX_NODE_HTTP_CREDENTIAL_HEADER_NAME_PATTERN.test(name) || + hasSensitiveCodexNodeText(header.value) + ) { + credentialBearing = true; + } + if (name === "content-type") { + const declared = header.value.split(";", 1)[0]?.trim().toLowerCase(); + credentialBearing ||= Boolean(contentType && contentType !== declared); + contentType = declared; + } + } + if (!credentialBearing && typeof request.params.url === "string") { + credentialBearing = hasCredentialedCodexNodeHttpUrl(request.params.url); + } + if (!credentialBearing && request.params.bodyBase64 != null) { + credentialBearing = hasCredentialedCodexNodeHttpBody(request.params.bodyBase64, contentType); + } + return credentialBearing ? createCredentialedCodexNodeHttpRejection(request) : undefined; +} + +function hasCredentialedCodexNodeHttpUrl(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("Codex http/request URL must be valid."); + } + if (url.username || url.password || hasSensitiveCodexNodeText(value)) { + return true; + } + let fields = 0; + const hasCredentialedParameter = (name: string, parameterValue: string): boolean => + ++fields > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS || + isCodexNodeCredentialField(name) || + hasSensitiveCodexNodeText(parameterValue); + for (const parameters of [url.searchParams, new URLSearchParams(url.hash.slice(1))]) { + for (const [name, parameterValue] of parameters) { + if (hasCredentialedParameter(name, parameterValue)) { + return true; + } + } + } + for (const initial of [url.pathname, url.hash.slice(1), ...url.searchParams.values()]) { + let component = initial; + for (let depth = 0; depth <= CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH; depth += 1) { + for (const nestedQuery of component.split(/[?#]/u).slice(1)) { + for (const [name, parameterValue] of new URLSearchParams(nestedQuery)) { + if (hasCredentialedParameter(name, parameterValue)) { + return true; + } + } + } + for (const segment of component.split("/")) { + for (const parameter of segment.split(";").slice(1)) { + const separator = parameter.indexOf("="); + const name = separator < 0 ? parameter : parameter.slice(0, separator); + const parameterValue = separator < 0 ? "" : parameter.slice(separator + 1); + if (hasCredentialedParameter(name, parameterValue)) { + return true; + } + } + } + if (!/%[\da-f]{2}/iu.test(component)) { + break; + } + if (depth === CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH) { + return true; + } + try { + component = decodeURIComponent(component); + } catch { + return true; + } + } + } + return false; +} + +function hasCredentialedCodexNodeHttpBody( + value: unknown, + contentType: string | undefined, +): boolean { + if (typeof value !== "string") { + throw new Error("Codex http/request bodyBase64 must be a string."); + } + const declaredText = contentType === "text/plain"; + if ( + !declaredText && + value.length > Math.ceil(CODEX_NODE_HTTP_CREDENTIAL_BODY_MAX_BYTES / 3) * 4 + ) { + // Missing content-type or binary disguise must not bypass bounded credential inspection. + return true; + } + let body: string; + try { + const decoded = Buffer.from(value, "base64"); + if ( + (!declaredText && decoded.length > CODEX_NODE_HTTP_CREDENTIAL_BODY_MAX_BYTES) || + decoded.toString("base64") !== value + ) { + return true; + } + body = nodeExecServerTextDecoder.decode(decoded); + } catch { + return true; + } + if (!body) { + return false; + } + if (hasSensitiveCodexNodeText(body)) { + return true; + } + const declaredJson = + contentType === "application/json" || contentType?.endsWith("+json") === true; + const declaredForm = contentType === "application/x-www-form-urlencoded"; + if (contentType && !declaredJson && !declaredForm && !declaredText) { + return true; + } + const trimmed = body.trimStart(); + if (trimmed.startsWith("<")) { + return true; + } + if (declaredJson || trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + if (hasCredentialedCodexNodeRawJsonStrings(body)) { + return true; + } + const json: unknown = JSON.parse(body); + return hasCredentialedCodexNodeJsonFields(json); + } catch { + if (!declaredText) { + return true; + } + } + } + if (!declaredForm && !body.includes("=")) { + return !declaredText; + } + let fields = 0; + for (const [name, parameterValue] of new URLSearchParams(body)) { + if ( + ++fields > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS || + isCodexNodeCredentialField(name) || + hasSensitiveCodexNodeText(parameterValue) + ) { + return true; + } + } + return false; +} + +function hasCredentialedCodexNodeRawJsonStrings(body: string): boolean { + const tokens = body.matchAll(/("(?:\\.|[^"\\])*")(\s*:)?/gu); + let fields = 0; + for (const match of tokens) { + const value: unknown = JSON.parse(match[1]!); + if ( + ++fields > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS || + typeof value !== "string" || + hasSensitiveCodexNodeText(value) || + (match[2] !== undefined && isCodexNodeCredentialField(value)) + ) { + return true; + } + } + return false; +} + +function hasCredentialedCodexNodeJsonFields(value: unknown): boolean { + const pending = [{ value, depth: 0 }]; + let fields = 0; + while (pending.length > 0) { + const current = pending.pop(); + if (typeof current?.value === "string" && hasSensitiveCodexNodeText(current.value)) { + return true; + } + if (!current || (!Array.isArray(current.value) && !isRecord(current.value))) { + continue; + } + if (current.depth >= CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH) { + return true; + } + const entries = Array.isArray(current.value) + ? current.value.map((entry) => [undefined, entry] as const) + : Object.entries(current.value); + for (const [name, nested] of entries) { + if ( + ++fields > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS || + (typeof name === "string" && isCodexNodeCredentialField(name)) + ) { + return true; + } + pending.push({ value: nested, depth: current.depth + 1 }); + } + } + return false; +} + +function hasSensitiveCodexNodeText(value: string): boolean { + let decoded = value; + for (let depth = 0; depth <= CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH; depth += 1) { + if (redactToolPayloadText(decoded) !== decoded) { + return true; + } + if (!/%[\da-f]{2}/iu.test(decoded)) { + return false; + } + try { + decoded = decodeURIComponent(decoded); + } catch { + return true; + } + } + return true; +} + +function isCodexNodeCredentialField(value: string): boolean { + let decoded = value; + for (let depth = 0; depth < CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_DEPTH; depth += 1) { + let next: string; + try { + next = decodeURIComponent(decoded); + } catch { + return true; + } + if (next === decoded) { + break; + } + decoded = next; + } + const normalized = decoded + .replace(/[\p{C}\p{Z}\u115F\u1160\u3164\uFFA0+]/gu, "") + .replace(/([a-z\d])([A-Z])/gu, "$1_$2") + .replaceAll("-", "_") + .toLowerCase(); + return ( + normalized.length > CODEX_NODE_HTTP_CREDENTIAL_SCAN_MAX_FIELDS || + normalized + .split(/[.[\]]+/u) + .some((component) => CODEX_NODE_HTTP_CREDENTIAL_FIELD_NAME_PATTERN.test(component)) + ); +} + +function createCredentialedCodexNodeHttpRejection(request: Record): Buffer { + if (typeof request.id !== "string" && typeof request.id !== "number") { + throw new Error("Codex http/request must have a JSON-RPC request id."); + } + return Buffer.from( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + error: { + code: -32602, + message: + "Authenticated remote HTTP is unavailable on paired devices; run on Gateway or use an intentionally credential-free endpoint.", + }, + }), + ); +} + +function sanitizeCodexExecServerRequest(frame: Buffer, request: Record): Buffer { + if (request.method !== "process/start") { + return frame; + } + if (!isRecord(request.params)) { + throw new Error("Codex process/start params must be an object."); + } + sanitizeCodexExecServerEnvironment(request.params, "env"); + if (request.params.envPolicy !== undefined) { + if (!isRecord(request.params.envPolicy)) { + throw new Error("Codex process/start envPolicy must be an object."); + } + sanitizeCodexExecServerEnvironment(request.params.envPolicy, "set"); + } + return normalizeCodexExecServerFrame(Buffer.from(JSON.stringify(request))); +} + +function sanitizeCodexExecServerEnvironment( + record: Record, + key: "env" | "set", +): void { + const environment = record[key]; + if (environment === undefined) { + return; + } + if (!isRecord(environment)) { + throw new Error(`Codex process/start ${key} must be an object.`); + } + const values: Record = {}; + for (const [name, value] of Object.entries(environment)) { + if (typeof value !== "string") { + throw new Error(`Codex process/start ${key} values must be strings.`); + } + try { + const url = new URL(value); + if (url.username || url.password) { + continue; + } + } catch { + // Ordinary environment values need not be URLs. + } + values[name] = value; + } + record[key] = sanitizeEnvVars(values).allowed; +} diff --git a/extensions/codex/src/app-server/sandbox-exec-server-registry.ts b/extensions/codex/src/app-server/sandbox-exec-server-registry.ts index 025fb016157c..87927b2d468a 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server-registry.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server-registry.ts @@ -1,12 +1,21 @@ -import type { OpenClawExecServer } from "./sandbox-exec-server/types.js"; +import type { OpenClawLeasedExecServer } from "./sandbox-exec-server/types.js"; export const sandboxExecServerRegistry = { - servers: new Map>(), - async close(server: OpenClawExecServer): Promise { + servers: new Map>(), + async close(server: OpenClawLeasedExecServer): Promise { if (server.closed) { return; } server.closed = true; + if ("node" in server) { + for (const lease of server.node.leases.values()) { + if (!lease.closed) { + lease.closed = true; + lease.channel.close(); + } + } + server.node.leases.clear(); + } for (const client of server.server.clients) { client.close(1001, "shutdown"); } diff --git a/extensions/codex/src/app-server/sandbox-exec-server.ts b/extensions/codex/src/app-server/sandbox-exec-server.ts index f0f0c009747f..de2c0ba8f876 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server.ts @@ -7,14 +7,25 @@ import { once } from "node:events"; import type { IncomingMessage } from "node:http"; import { isIP, type AddressInfo } from "node:net"; import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SandboxContext } from "openclaw/plugin-sdk/sandbox"; import { WebSocketServer, type RawData, type WebSocket } from "ws"; import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; +import { + createCodexNodeExecServerDisconnectError, + startCodexNodeExecServerRelay, +} from "./sandbox-exec-server-node-relay.js"; import { sandboxExecServerRegistry } from "./sandbox-exec-server-registry.js"; import { parseRequest } from "./sandbox-exec-server/json-rpc.js"; +import type { SandboxChildOwner } from "./sandbox-exec-server/sandbox-child.js"; import { CodexSandboxExecSession } from "./sandbox-exec-server/session.js"; -import type { OpenClawExecServer } from "./sandbox-exec-server/types.js"; +import type { + CodexNodeExecServerLease, + OpenClawExecServer, + OpenClawLeasedExecServer, + OpenClawNodeExecServer, +} from "./sandbox-exec-server/types.js"; /** Codex environment metadata registered for one sandbox exec-server lease. */ export type CodexSandboxExecEnvironment = { @@ -23,46 +34,77 @@ export type CodexSandboxExecEnvironment = { }; const CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES = 100 * 1024 * 1024; +const CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES = 64 * 1024 * 1024; +const codexNodeExecServerLeases = new WeakMap< + CodexSandboxExecEnvironment, + CodexNodeExecServerLease +>(); /** Starts or reuses a sandbox exec-server and registers it with Codex app-server. */ export async function ensureCodexSandboxExecServerEnvironment(params: { client: CodexAppServerClient; sandbox: SandboxContext | null; + runtime?: PluginRuntime; appServerStartOptions?: CodexAppServerStartOptions; timeoutMs?: number; signal?: AbortSignal; + onExecutionDisconnect?: (error: Error) => void; }): Promise { - if (!params.sandbox?.enabled || !params.sandbox.backend) { + if (!params.sandbox?.enabled) { return undefined; } + const placementNodeId = readCodexPlacementNodeId(params.sandbox); + if (!params.sandbox.backend && !placementNodeId) { + return undefined; + } + if (placementNodeId && !params.runtime) { + throw new Error("Codex paired-device execution requires its active plugin runtime."); + } if (!canExposeLocalExecServerToAppServer(params.appServerStartOptions)) { throw new Error( "OpenClaw Codex exec-server uses a local loopback URL and cannot be registered with a remote Codex app-server.", ); } - const execServer = await acquireOpenClawExecServer(params.sandbox); + const { server: execServer, nodeLease } = await acquireOpenClawExecServer({ + sandbox: params.sandbox, + runtime: params.runtime, + signal: params.signal, + onExecutionDisconnect: params.onExecutionDisconnect, + }); + // Codex retains a thread's environment instance when its id and cwd stay equal. + // A single-use paired-node channel therefore needs a fresh selected identity. + const environmentId = nodeLease ? `openclaw-node-${nodeLease.id}` : execServer.environmentId; try { + const execServerUrl = nodeLease ? `${execServer.url}?lease=${nodeLease.id}` : execServer.url; await params.client.request( "environment/add", { - environmentId: execServer.environmentId, - execServerUrl: execServer.url, + environmentId, + execServerUrl, }, { timeoutMs: params.timeoutMs, signal: params.signal }, ); } catch (error) { + if (nodeLease && "node" in execServer) { + closeCodexNodeExecServerLease(execServer, nodeLease); + } await releaseOpenClawExecServer(execServer); throw error; } - return { - environmentId: execServer.environmentId, + const environment = { + environmentId, cwd: params.sandbox.containerWorkdir, }; + if (nodeLease) { + codexNodeExecServerLeases.set(environment, nodeLease); + } + return environment; } /** Releases the sandbox exec-server lease associated with a sandbox runtime. */ export async function releaseCodexSandboxExecServerEnvironment( sandbox: SandboxContext | null | undefined, + environment?: CodexSandboxExecEnvironment, ): Promise { if (!sandbox?.enabled) { return; @@ -71,6 +113,11 @@ export async function releaseCodexSandboxExecServerEnvironment( .get(sandbox.runtimeId) ?.catch(() => undefined); if (server) { + const nodeLease = environment && codexNodeExecServerLeases.get(environment); + if (nodeLease && "node" in server) { + codexNodeExecServerLeases.delete(environment); + closeCodexNodeExecServerLease(server, nodeLease); + } await releaseOpenClawExecServer(server); } } @@ -96,7 +143,13 @@ function canExposeLocalExecServerToAppServer( } } -async function acquireOpenClawExecServer(sandbox: SandboxContext): Promise { +async function acquireOpenClawExecServer(params: { + sandbox: SandboxContext; + runtime?: PluginRuntime; + signal?: AbortSignal; + onExecutionDisconnect?: (error: Error) => void; +}): Promise<{ server: OpenClawLeasedExecServer; nodeLease?: CodexNodeExecServerLease }> { + const { sandbox, runtime, signal, onExecutionDisconnect } = params; const key = sandbox.runtimeId; while (true) { const existing = sandboxExecServerRegistry.servers.get(key); @@ -104,12 +157,67 @@ async function acquireOpenClawExecServer(sandbox: SandboxContext): Promise handleClosedCodexNodeExecServerLease(server, nodeLease, { failed: false }), + (error: unknown) => + handleClosedCodexNodeExecServerLease(server, nodeLease, { failed: true, error }), + ) + .catch((error: unknown) => { + embeddedAgentLog.warn("codex paired-device exec-server lease cleanup failed", { + error: error instanceof Error ? error.message : String(error), + }); + }); + return { server, nodeLease }; + } catch (error) { + await releaseOpenClawExecServer(server); + throw error; + } } } } -function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise { +function startAndRememberOpenClawExecServer( + sandbox: SandboxContext, +): Promise { const created = startOpenClawExecServer(sandbox); const key = sandbox.runtimeId; sandboxExecServerRegistry.servers.set(key, created); @@ -121,21 +229,37 @@ function startAndRememberOpenClawExecServer(sandbox: SandboxContext): Promise { +async function startOpenClawExecServer(sandbox: SandboxContext): Promise { const backend = sandbox.backend; const fsBridge = sandbox.fsBridge; - if (!backend) { - throw new Error("OpenClaw sandbox backend is unavailable."); - } - if (!fsBridge) { - throw new Error("Sandbox filesystem bridge is unavailable."); + const placementNodeId = readCodexPlacementNodeId(sandbox); + let connection: + | { kind: "node"; id: string } + | { + kind: "sandbox"; + backend: NonNullable; + fsBridge: NonNullable; + }; + if (placementNodeId) { + connection = { kind: "node", id: placementNodeId }; + } else { + if (!backend) { + throw new Error("OpenClaw sandbox backend is unavailable."); + } + if (!fsBridge) { + throw new Error("Sandbox filesystem bridge is unavailable."); + } + connection = { kind: "sandbox", backend, fsBridge }; } const server = new WebSocketServer({ host: "127.0.0.1", port: 0, // Match ws' historical default: Codex fs/writeFile sends one base64 JSON-RPC // frame, while the socket error handler below makes oversize frames nonfatal. - maxPayload: CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES, + maxPayload: + connection.kind === "node" + ? CODEX_NODE_EXEC_SERVER_MAX_MESSAGE_BYTES + : CODEX_SANDBOX_EXEC_SERVER_MAX_INBOUND_MESSAGE_BYTES, }); await once(server, "listening"); const address = server.address(); @@ -145,19 +269,21 @@ async function startOpenClawExecServer(sandbox: SandboxContext): Promise(), + cleanupTasks: new Set>(), }; + const execServer: OpenClawLeasedExecServer = + connection.kind === "node" + ? { ...common, node: { id: connection.id, leases: new Map() } } + : { ...common, backend: connection.backend, fsBridge: connection.fsBridge }; server.on("connection", (socket, request) => { // ws emits error for maxPayload rejections before auth or JSON-RPC sees the frame. socket.on("error", handleExecServerSocketError); @@ -165,6 +291,10 @@ async function startOpenClawExecServer(sandbox: SandboxContext): Promise { +async function releaseOpenClawExecServer(execServer: OpenClawLeasedExecServer): Promise { if (execServer.closed) { return; } @@ -201,13 +331,122 @@ function buildEnvironmentId(sandbox: SandboxContext): string { } function isAuthorizedExecServerRequest( - execServer: OpenClawExecServer, + execServer: OpenClawLeasedExecServer, request: IncomingMessage, ): boolean { const url = new URL(request.url ?? "", "ws://127.0.0.1"); return url.pathname === execServer.authPath; } +function readCodexPlacementNodeId(sandbox: SandboxContext): string | undefined { + if ( + !("placementExecutionMode" in sandbox) || + sandbox.placementExecutionMode !== "remote-exec" || + !("placementNodeId" in sandbox) || + typeof sandbox.placementNodeId !== "string" || + !sandbox.placementNodeId + ) { + return undefined; + } + return sandbox.placementNodeId; +} + +function readCodexPlacementWorkspaceIdentity(sandbox: SandboxContext): { + environmentId: string; + sessionId: string; + ownerEpoch: number; + sessionKey: string; +} { + if ( + !("placementEnvironmentId" in sandbox) || + typeof sandbox.placementEnvironmentId !== "string" || + !sandbox.placementEnvironmentId || + sandbox.placementEnvironmentId.trim() !== sandbox.placementEnvironmentId || + !("placementSessionId" in sandbox) || + typeof sandbox.placementSessionId !== "string" || + !sandbox.placementSessionId || + sandbox.placementSessionId.trim() !== sandbox.placementSessionId || + !("placementOwnerEpoch" in sandbox) || + typeof sandbox.placementOwnerEpoch !== "number" || + !Number.isSafeInteger(sandbox.placementOwnerEpoch) || + sandbox.placementOwnerEpoch < 1 || + !sandbox.sessionKey || + sandbox.sessionKey.trim() !== sandbox.sessionKey + ) { + throw new Error( + "Codex paired-device execution requires its exact placement workspace identity.", + ); + } + return { + environmentId: sandbox.placementEnvironmentId, + sessionId: sandbox.placementSessionId, + ownerEpoch: sandbox.placementOwnerEpoch, + sessionKey: sandbox.sessionKey, + }; +} + +function handleNodeConnection( + execServer: OpenClawNodeExecServer, + socket: WebSocket, + request: IncomingMessage, +): void { + const leaseId = new URL(request.url ?? "", "ws://127.0.0.1").searchParams.get("lease"); + const lease = leaseId ? execServer.node.leases.get(leaseId) : undefined; + if (!lease || lease.claimed || lease.closed) { + socket.close(1008, "execution channel unavailable"); + return; + } + // stdio has exactly one connection; a fresh attempt always owns a fresh channel. + lease.claimed = true; + const cleanup = startCodexNodeExecServerRelay({ lease, socket }); + execServer.cleanupTasks.add(cleanup); + void cleanup.then( + () => execServer.cleanupTasks.delete(cleanup), + (error: unknown) => { + execServer.cleanupTasks.delete(cleanup); + embeddedAgentLog.warn("codex paired-device exec-server relay failed", { + error: error instanceof Error ? error.message : String(error), + }); + }, + ); +} + +function closeCodexNodeExecServerLease( + execServer: OpenClawNodeExecServer, + lease: CodexNodeExecServerLease, +): void { + execServer.node.leases.delete(lease.id); + if (!lease.closed) { + lease.closed = true; + lease.closeRelay?.(); + lease.channel.close(); + } +} + +function handleClosedCodexNodeExecServerLease( + execServer: OpenClawNodeExecServer, + lease: CodexNodeExecServerLease, + result: { failed: boolean; error?: unknown }, +): void { + if (lease.closed) { + return; + } + if (lease.onChannelClosed) { + lease.onChannelClosed(result); + return; + } + try { + lease.onDisconnected?.( + createCodexNodeExecServerDisconnectError( + result.failed ? "execution device failed" : "execution device disconnected", + result.error, + ), + ); + } finally { + closeCodexNodeExecServerLease(execServer, lease); + } +} + function handleConnection(execServer: OpenClawExecServer, socket: WebSocket): void { const session = new CodexSandboxExecSession(execServer, { isOpen: () => socket.readyState === socket.OPEN, diff --git a/extensions/codex/src/app-server/sandbox-exec-server/types.ts b/extensions/codex/src/app-server/sandbox-exec-server/types.ts index ac302e730959..39a27cce16e2 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server/types.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server/types.ts @@ -2,6 +2,7 @@ * Shared protocol and runtime state types for the Codex sandbox exec-server * transport-neutral execution session. */ +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SandboxContext } from "openclaw/plugin-sdk/sandbox"; import type { JsonObject, JsonValue } from "../protocol.js"; import type { SandboxChildOwner } from "./sandbox-child.js"; @@ -91,16 +92,14 @@ export type ManagedProcess = { evictProcess: () => void; }; -/** Shared exec-server instance leased by Codex native sandbox environments. */ -export type OpenClawExecServer = { +/** Common loopback server and lease ownership shared by both execution transports. */ +type OpenClawExecServerLease = { environmentId: string; authPath: string; refCount: number; closed: boolean; url: string; sandbox: SandboxContext; - backend: NonNullable; - fsBridge: NonNullable; server: { clients: Iterable<{ close: (code?: number, reason?: string) => void }>; close: (callback: (error?: Error) => void) => void; @@ -108,3 +107,31 @@ export type OpenClawExecServer = { children: Set; cleanupTasks: Set>; }; + +/** Locally interpreted exec-server protocol backed by an OpenClaw sandbox. */ +export type OpenClawExecServer = OpenClawExecServerLease & { + backend: NonNullable; + fsBridge: NonNullable; +}; + +/** One pre-authorized, single-use Codex stdio connection. */ +export type CodexNodeExecServerLease = { + id: string; + channel: Awaited>; + claimed: boolean; + closed: boolean; + closeRelay?: () => void; + onDisconnected?: (error: Error) => void; + onChannelClosed?: (result: { failed: boolean; error?: unknown }) => void; +}; + +/** Opaque exec-server relay backed by the exact prepared paired-device placement. */ +export type OpenClawNodeExecServer = OpenClawExecServerLease & { + node: { + id: string; + leases: Map; + }; +}; + +/** One canonical loopback/refcount owner with either local or node connection handling. */ +export type OpenClawLeasedExecServer = OpenClawExecServer | OpenClawNodeExecServer; diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d472e9ba2089..be9bee34575b 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -210,8 +210,8 @@ const threadBindingSchema = z connectionScope: z.literal("supervision").optional(), supervisionSourceThreadId: z.string().trim().min(1).optional(), authProfileId: optionalStringSchema, - // Freeze external-cwd AGENTS.md at thread creation; bootstrap refreshes must - // not mutate the inherited policy of a resumed native session. + // Freeze OpenClaw-carried AGENTS.md at thread creation; bootstrap refreshes + // must not mutate the inherited policy of a resumed native session. agentWorkspaceDeveloperInstructions: optionalNonBlankStringSchema, model: optionalStringSchema, // Codex App Server owns selection for supervised and adopted threads. Keep diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index a7a7c3bbcf21..f2da4c8146ee 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -690,7 +690,7 @@ describe("runCodexAppServerSideQuestion", () => { expect(injectParams?.items).toHaveLength(1); expect(injectParams?.items?.[0]?.type).toBe("message"); expect(injectParams?.items?.[0]?.role).toBe("user"); - expect(injectCall?.[2]).toEqual({ timeoutMs: 60_000, signal: undefined }); + expect(injectCall?.[2]).toEqual({ timeoutMs: 60_000, signal: expect.any(AbortSignal) }); const injectedItem = injectParams?.items?.[0] as | { content?: Array<{ text?: string }> } | undefined; @@ -720,7 +720,7 @@ describe("runCodexAppServerSideQuestion", () => { }, }, }, - { timeoutMs: 60_000, signal: undefined }, + { timeoutMs: 60_000, signal: expect.any(AbortSignal) }, ]); const turnStartParams = turnStartCall?.[1] as Record | undefined; expect(turnStartParams).not.toHaveProperty("approvalPolicy"); @@ -856,6 +856,42 @@ describe("runCodexAppServerSideQuestion", () => { }); }); + it("rejects paired-device side questions before acquiring a client, channel, or approval", async () => { + const client = createFakeClient(); + getSharedCodexAppServerClientMock.mockResolvedValue(client); + const openDuplex = vi.fn(async () => { + throw new Error("paired-device side-question channel was opened"); + }); + const requestApproval = vi.fn(async () => undefined); + const sandbox = { + ...createSandboxContext({}), + placementExecutionMode: "remote-exec" as const, + placementNodeId: "paired-device-1", + placementEnvironmentId: "environment-1", + placementSessionId: "session-1", + placementOwnerEpoch: 1, + sessionKey: "agent:main:session-1", + }; + + await expect( + runCodexAppServerSideQuestion( + sideParams({ + sandbox, + hostCapabilities: { ...TEST_HOST_CAPABILITIES, requestApproval }, + }), + { runtime: { nodes: { openDuplex } } as never }, + ), + ).rejects.toThrow( + "Normal Codex turns are supported on paired devices, but /btw is not yet bound to the active placement.", + ); + + expect(getSharedCodexAppServerClientMock).not.toHaveBeenCalled(); + expect(openDuplex).not.toHaveBeenCalled(); + expect(requestApproval).not.toHaveBeenCalled(); + expect(client.request).not.toHaveBeenCalled(); + expect(createOpenClawCodingToolsMock).not.toHaveBeenCalled(); + }); + it("rebinds side-question handlers when selection retry replaces the client", async () => { const initialClient = createFakeClient(); const replacementClient = createFakeClient(); diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 1f513ce5a14c..de34ca5c0e1c 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -18,6 +18,7 @@ import { type NativeHookRelayRegistrationHandle, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { loadExecApprovals } from "openclaw/plugin-sdk/exec-approvals-runtime"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import { readStringField as readString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveCodexAppServerForModelProvider } from "./app-server-policy.js"; import { handleCodexAppServerApprovalRequest } from "./approval-bridge.js"; @@ -39,6 +40,7 @@ import { } from "./client.js"; import { canUseCodexModelBackedApprovalsReviewerForModel, + isCodexPairedNodeRemoteExecPlacementSandbox, isCodexRemoteExecPlacementSandbox, isCodexSandboxExecServerEnabled, readCodexPluginConfig, @@ -51,6 +53,7 @@ import { import { resolveCodexExternalSandboxPolicyForOpenClawSandbox, resolveCodexMessageToolProvider, + resolveCodexNodePlacementToolConstructionPlan, resolveCodexSandboxEnvironmentSelection, shouldEnableCodexAppServerNativeToolSurface, shouldRequireCodexSandboxExecServerEnvironment, @@ -171,6 +174,7 @@ export async function runCodexAppServerSideQuestion( params: AgentHarnessSideQuestionParamsV2, options: { bindingStore: CodexAppServerBindingStore; + runtime?: PluginRuntime; pluginConfig?: unknown; /** Private app-server request identity; public side-run identity remains params.model. */ runtimeModelId?: string; @@ -196,6 +200,11 @@ export async function runCodexAppServerSideQuestion( "Codex /btw needs an active Codex thread. Send a normal message first, then try /btw again.", ); } + if (isCodexPairedNodeRemoteExecPlacementSandbox(params.sandbox)) { + throw new Error( + "Normal Codex turns are supported on paired devices, but /btw is not yet bound to the active placement.", + ); + } const pluginConfig = readCodexPluginConfig(options.pluginConfig); const { sessionAgentId } = resolveSessionAgentIds({ sessionKey: params.sessionKey, @@ -413,9 +422,10 @@ export async function runCodexAppServerSideQuestion( if (!sandboxEnvironment) { return; } + const environment = sandboxEnvironment; sandboxEnvironment = undefined; sandboxEnvironmentClient = undefined; - await releaseCodexSandboxExecServerEnvironment(params.sandbox); + await releaseCodexSandboxExecServerEnvironment(params.sandbox, environment); }; const ensureSandboxEnvironment = async (targetClient: CodexAppServerClient) => { if (!sandboxEnvironmentRequired || sandboxEnvironmentClient === targetClient) { @@ -425,9 +435,15 @@ export async function runCodexAppServerSideQuestion( const environment = await ensureCodexSandboxExecServerEnvironment({ client: targetClient, sandbox: params.sandbox ?? null, + runtime: options.runtime, appServerStartOptions: appServer.start, timeoutMs: appServer.requestTimeoutMs, signal: runAbortController.signal, + onExecutionDisconnect: (error) => { + collector.reject(error); + embeddedAgentLog.warn(error.message); + runAbortController.abort("client_closed"); + }, }); if (!environment) { throw new Error( @@ -680,7 +696,7 @@ export async function runCodexAppServerSideQuestion( await withLeasedCodexAppServerClientStartSelectionRetry({ lease: clientLease, options: clientOptions, - signal: params.opts?.abortSignal, + signal: runAbortController.signal, run: async (forkClient, requestOptions) => { await ensureSandboxEnvironment(forkClient); const executionCwd = sandboxEnvironment?.cwd ?? cwd; @@ -725,7 +741,7 @@ export async function runCodexAppServerSideQuestion( threadId: childThreadId, items: [sideBoundaryPromptItem()], }, - { timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal }, + { timeoutMs: appServer.requestTimeoutMs, signal: runAbortController.signal }, ); const effort = usesSupervisionConnection @@ -771,7 +787,7 @@ export async function runCodexAppServerSideQuestion( }, }), }, - { timeoutMs: appServer.requestTimeoutMs, signal: params.opts?.abortSignal }, + { timeoutMs: appServer.requestTimeoutMs, signal: runAbortController.signal }, ) .catch((error: unknown) => { if (isCodexAppServerIndeterminateRequestCancellationError(error)) { @@ -803,7 +819,7 @@ export async function runCodexAppServerSideQuestion( let text: string; try { text = await collector.wait({ - signal: params.opts?.abortSignal, + signal: runAbortController.signal, timeoutMs: Math.max( appServer.turnCompletionIdleTimeoutMs, SIDE_QUESTION_COMPLETION_TIMEOUT_MS, @@ -1042,6 +1058,10 @@ async function createCodexSideToolBridge(input: { sessionKey: sandboxSessionKey, workspaceDir: input.cwd, }); + const toolConstructionPlan = resolveCodexNodePlacementToolConstructionPlan( + sandbox, + input.nativeToolSurfaceEnabled, + ); const allTools = createOpenClawCodingTools({ agentId: input.sessionAgentId, sessionKey: sandboxSessionKey, @@ -1114,6 +1134,7 @@ async function createCodexSideToolBridge(input: { currentChannelId: input.params.currentChannelId, }).channelId, sandbox, + ...(toolConstructionPlan ? { toolConstructionPlan } : {}), emitBeforeToolCallDiagnostics: false, modelHasVision: runtimeModel.input?.includes("image") ?? false, requireExplicitMessageTarget: true, @@ -1413,7 +1434,7 @@ class CodexSideQuestionCollector { settle?.resolve(text); } - private reject(error: string | Error): void { + reject(error: string | Error): void { this.terminalError = error instanceof Error ? error : new Error(error); const settle = this.settle; this.settle = undefined; diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 0cd57bd4d847..19b212d2c2d9 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -355,11 +355,14 @@ describe("Codex ring-zero thread config", () => { it("applies the restriction to both thread start and resume", () => { const params = createAttemptParams({ provider: "openai" }); params.toolsAllow = ["openclaw"]; + params.pluginHarnessToolPolicyRestricted = true; const appServer = createAppServerOptions() as never; + const developerInstructions = "Host-authored ring-zero instructions."; const start = buildThreadStartParams(params, { appServer, cwd: "/repo", dynamicTools: [], + developerInstructions, hostSystemAgentActive: true, nativeCodeModeEnabled: false, config: { project_doc_max_bytes: 64_000 }, @@ -367,6 +370,7 @@ describe("Codex ring-zero thread config", () => { const resume = buildThreadResumeParams(params, { appServer, dynamicTools: [], + developerInstructions, hostSystemAgentActive: true, nativeCodeModeEnabled: false, threadId: "thread-1", @@ -375,6 +379,8 @@ describe("Codex ring-zero thread config", () => { expect(start.environments).toEqual([]); expect(start.baseInstructions).toBe(""); + expect(start.developerInstructions).toBe(developerInstructions); + expect(resume.developerInstructions).toBe(developerInstructions); for (const config of [start.config, resume.config]) { expect(config?.["agents.enabled"]).toBe(false); expect(config?.["tools.experimental_request_user_input.enabled"]).toBe(false); @@ -402,6 +408,47 @@ describe("Codex ring-zero thread config", () => { expect(normal.baseInstructions).toBeUndefined(); expect(normal.config?.["features.goals"]).toBe(false); }); + + it("preserves project documents for ordinary policy-restricted turns", () => { + const params = createAttemptParams({ provider: "openai" }); + params.pluginHarnessToolPolicyRestricted = true; + const appServer = createAppServerOptions() as never; + const start = buildThreadStartParams(params, { + appServer, + cwd: "/repo", + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + }); + const resume = buildThreadResumeParams(params, { + appServer, + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + threadId: "thread-1", + config: { project_doc_max_bytes: 64_000 }, + }); + + expect(start.config?.project_doc_max_bytes).toBe(131_072); + expect(resume.config?.project_doc_max_bytes).toBe(64_000); + for (const threadConfig of [start.config, resume.config]) { + expect(threadConfig?.["features.multi_agent"]).toBe(false); + expect(threadConfig?.["orchestrator.mcp.enabled"]).toBe(false); + } + + const toolsDisabled = createAttemptParams({ provider: "openai" }); + toolsDisabled.disableTools = true; + toolsDisabled.pluginHarnessToolPolicyRestricted = true; + const disabled = buildThreadStartParams(toolsDisabled, { + appServer, + cwd: "/repo", + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + config: { project_doc_max_bytes: 64_000 }, + }); + expect(disabled.config?.project_doc_max_bytes).toBe(0); + }); }); describe("Codex delegation capability", () => { @@ -3620,6 +3667,7 @@ describe("Codex app-server supervised branch lifecycle", () => { | { config?: Record } | undefined; expect(threadRequest?.config).toMatchObject({ + project_doc_max_bytes: 0, mcp_servers: { inherited: { enabled: false }, "request-only": { enabled: false }, diff --git a/extensions/codex/src/app-server/thread-requests.ts b/extensions/codex/src/app-server/thread-requests.ts index 05d5597009bc..a89b5b6ff2a3 100644 --- a/extensions/codex/src/app-server/thread-requests.ts +++ b/extensions/codex/src/app-server/thread-requests.ts @@ -56,7 +56,7 @@ const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = { "features.code_mode_only": false, }; -const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = { +const CODEX_NO_PROJECT_DOCS_CONFIG: JsonObject = { project_doc_max_bytes: 0, }; @@ -126,7 +126,6 @@ const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = { SubagentStop: [], Stop: [], }, - project_doc_max_bytes: 0, notify: [], web_search: "disabled", }; @@ -430,6 +429,10 @@ export function buildCodexRuntimeThreadConfigForRun( const messageOnlySourceReply = isMessageOnlyCodexSourceReply(params); const restrictedToolSurface = ringZeroActive || messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true; + const restrictedTurnDisablesProjectDocs = + ringZeroActive || + messageOnlySourceReply || + (params.pluginHarnessToolPolicyRestricted && params.disableTools); const configMcpServers = config?.mcp_servers; if (restrictedToolSurface && configMcpServers !== undefined && !isJsonObject(configMcpServers)) { throw new Error("Codex restricted tool surface received invalid thread mcp_servers config"); @@ -469,23 +472,21 @@ export function buildCodexRuntimeThreadConfigForRun( ? CODEX_DELEGATION_DISABLED_THREAD_CONFIG : undefined, messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true - ? buildCodexRestrictedToolThreadConfigPatch(restrictedToolSurfaceMcpServerNames) + ? buildRestrictedToolConfigPatch(restrictedToolSurfaceMcpServerNames) : buildCodexRingZeroThreadConfigPatch( params, options.hostSystemAgentActive, restrictedToolSurfaceMcpServerNames, ), + restrictedTurnDisablesProjectDocs ? CODEX_NO_PROJECT_DOCS_CONFIG : undefined, params.authoredContextTokenCap === undefined ? undefined : { model_context_window: params.authoredContextTokenCap }, ) ?? baseConfig; - const contextConfig = - params.bootstrapContextMode !== "lightweight" - ? runtimeConfig - : (mergeCodexThreadConfigs(runtimeConfig, CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG) ?? { - ...runtimeConfig, - ...CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG, - }); + const contextConfig = { + ...runtimeConfig, + ...(params.bootstrapContextMode === "lightweight" ? CODEX_NO_PROJECT_DOCS_CONFIG : {}), + }; return applyCodexManagedShellEnvironment( contextConfig, options.shellEnvironment, @@ -501,15 +502,16 @@ export function buildCodexRingZeroThreadConfigPatch( if (!hostSystemAgentActive || !isSystemAgentOnlyCodexDynamicToolAllowlist(params.toolsAllow)) { return undefined; } - return buildCodexRestrictedToolThreadConfigPatch(inheritedMcpServerNames); + return { + ...buildRestrictedToolConfigPatch(inheritedMcpServerNames), + ...CODEX_NO_PROJECT_DOCS_CONFIG, + }; } -function buildCodexRestrictedToolThreadConfigPatch( - inheritedMcpServerNames: readonly string[], -): JsonObject { - // Restricted turns already send environments: [] and disable native code - // mode. Remove every other configurable Codex-owned source so - // native delegation, installed MCP tools, and utilities cannot escape the cap. +function buildRestrictedToolConfigPatch(inheritedMcpServerNames: readonly string[]): JsonObject { + // Restricted turns already send environments: [] and disable native code mode. + // Remove Codex-owned tool sources here; project-document suppression belongs to + // ring-zero, message-only, and tool-disabled context policy at the caller. const mcpServers = Object.fromEntries( [...new Set(inheritedMcpServerNames)].toSorted().map((name) => [name, { enabled: false }]), ); diff --git a/extensions/codex/src/app-server/transport-stdio.ts b/extensions/codex/src/app-server/transport-stdio.ts index 0340f2635521..3ed66c4bcf5c 100644 --- a/extensions/codex/src/app-server/transport-stdio.ts +++ b/extensions/codex/src/app-server/transport-stdio.ts @@ -2,13 +2,12 @@ * Creates and configures stdio-backed Codex app-server transports, including * Windows spawn normalization and environment filtering. */ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, } from "openclaw/plugin-sdk/windows-spawn"; import type { CodexAppServerStartOptions } from "./config.js"; -import type { CodexAppServerTransport } from "./transport.js"; const UNSAFE_ENVIRONMENT_KEYS = new Set(["__proto__", "constructor", "prototype"]); const RUNTIME_INJECTION_ENVIRONMENT_KEYS = new Set([ @@ -124,8 +123,11 @@ function copySafeEnvironmentEntries( } /** Spawns the Codex app-server process and returns the shared transport interface. */ -export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport { - const env = resolveCodexAppServerSpawnEnv(options); +export function createStdioTransport( + options: CodexAppServerStartOptions, + baseEnv: NodeJS.ProcessEnv = process.env, +): ChildProcessWithoutNullStreams { + const env = resolveCodexAppServerSpawnEnv(options, baseEnv); const invocation = resolveCodexAppServerSpawnInvocation(options, { platform: process.platform, env, diff --git a/extensions/codex/src/node-exec-server.runtime.ts b/extensions/codex/src/node-exec-server.runtime.ts new file mode 100644 index 000000000000..6d7cc416b643 --- /dev/null +++ b/extensions/codex/src/node-exec-server.runtime.ts @@ -0,0 +1,287 @@ +/** Owns approved, connection-bound Codex exec-server processes on paired nodes. */ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { once } from "node:events"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawPluginNodeHostCommandIo } from "openclaw/plugin-sdk/node-host"; +import { killProcessTree } from "openclaw/plugin-sdk/process-runtime"; +import { sanitizeEnvVars } from "openclaw/plugin-sdk/sandbox"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; +import { + isManagedCodexDesktopCommand, + resolveManagedCodexAppServerStartOptions, + resolveManagedCodexNativeCommand, +} from "./app-server/managed-binary.js"; +import { createStdioTransport } from "./app-server/transport-stdio.js"; +import { closeCodexAppServerTransportAndWait } from "./app-server/transport.js"; + +const MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES = 64 * 1024 * 1024; +const MAX_CODEX_EXEC_SERVER_STDERR_BYTES = 4 * 1024; +const CODEX_EXEC_SERVER_TERMINATION_GRACE_MS = 1_000; +const CODEX_EXEC_SERVER_REAP_TIMEOUT_MS = 5_000; +const NODE_EXEC_SERVER_PLATFORM_ENVIRONMENT = + /^(?:SYSTEMROOT|WINDIR|COMSPEC|PATHEXT|TEMP|TMP|TMPDIR)$/iu; + +type CodexNodeExecProcessOwner = { + terminate: () => Promise; +}; + +function validateNodeExecServerMessage(message: Uint8Array): Buffer { + if (message.byteLength === 0 || message.byteLength > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES) { + throw new Error("Codex exec-server JSON-RPC message exceeds its 64 MiB limit."); + } + const encoded = Buffer.from(message.buffer, message.byteOffset, message.byteLength); + if (encoded.includes(0x0a) || encoded.includes(0x0d)) { + throw new Error("Codex exec-server JSON-RPC frames must contain exactly one message."); + } + let decoded: unknown; + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(encoded); + decoded = JSON.parse(text) as unknown; + } catch { + throw new Error("Codex exec-server received malformed UTF-8 or JSON-RPC."); + } + if ( + !isRecord(decoded) || + (decoded.jsonrpc !== undefined && decoded.jsonrpc !== "2.0") || + (typeof decoded.method !== "string" && + !("id" in decoded && ("result" in decoded || "error" in decoded))) + ) { + throw new Error("Codex exec-server received an invalid JSON-RPC message."); + } + return encoded; +} + +function nodeExecServerAbortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error("Codex node exec-server connection closed."); +} + +function writeNodeExecServerMessage( + child: ChildProcessWithoutNullStreams, + message: Buffer, + signal: AbortSignal, +): Promise | void { + if (signal.aborted) { + throw nodeExecServerAbortError(signal); + } + const payload = Buffer.concat([message, Buffer.from("\n")]); + if (!child.stdin.write(payload)) { + return once(child.stdin, "drain", { signal }).then(() => undefined); + } +} + +async function relayNodeExecServerOutput( + child: ChildProcessWithoutNullStreams, + send: (message: Uint8Array) => Promise, +): Promise { + let fragments: Buffer[] = []; + let pendingBytes = 0; + for await (const rawChunk of child.stdout) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk); + let offset = 0; + while (offset < chunk.byteLength) { + const newline = chunk.indexOf(0x0a, offset); + const fragment = chunk.subarray(offset, newline === -1 ? chunk.byteLength : newline); + const nextLength = pendingBytes + fragment.byteLength; + if (nextLength > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES + 1) { + throw new Error("Codex exec-server stdout message exceeds its 64 MiB limit."); + } + if (fragment.byteLength > 0) { + fragments.push(fragment); + } + pendingBytes = nextLength; + if (newline === -1) { + if ( + pendingBytes > MAX_CODEX_EXEC_SERVER_MESSAGE_BYTES && + fragment[fragment.byteLength - 1] !== 0x0d + ) { + throw new Error("Codex exec-server stdout message exceeds its 64 MiB limit."); + } + break; + } + const trailing = fragments.at(-1); + if (trailing?.[trailing.byteLength - 1] === 0x0d) { + pendingBytes -= 1; + if (trailing.byteLength === 1) { + fragments.pop(); + } else { + fragments[fragments.length - 1] = trailing.subarray(0, trailing.byteLength - 1); + } + } + const pending = + fragments.length === 1 ? fragments[0]! : Buffer.concat(fragments, pendingBytes); + const message = validateNodeExecServerMessage(pending); + fragments = []; + pendingBytes = 0; + await send(message); + offset = newline + 1; + } + } + if (pendingBytes > 0) { + throw new Error("Codex exec-server stdout ended with an unterminated JSON-RPC message."); + } +} + +function createNodeExecServerProcessOwner( + child: ChildProcessWithoutNullStreams, + closed: Promise<{ code: number | null; signal: NodeJS.Signals | null }>, +): CodexNodeExecProcessOwner { + let termination: Promise | undefined; + return { + terminate: () => + (termination ??= (async () => { + // The shared transport closes only the root on Windows; taskkill /T + // owns its descendants before that root can disappear. + if (process.platform === "win32" && child.pid) { + killProcessTree(child.pid, { graceMs: CODEX_EXEC_SERVER_TERMINATION_GRACE_MS }); + } + const exited = await closeCodexAppServerTransportAndWait(child, { + forceKillDelayMs: CODEX_EXEC_SERVER_TERMINATION_GRACE_MS, + exitTimeoutMs: CODEX_EXEC_SERVER_REAP_TIMEOUT_MS, + }); + if (!exited) { + throw new Error("Codex node exec-server process tree did not terminate."); + } + await closed; + })()), + }; +} + +/** Runs the one-connection paired-node exec-server after lightweight command admission. */ +export async function runCodexNodeExecServer(params: { + workspaceDir: string; + io: OpenClawPluginNodeHostCommandIo; + activeProcesses: Set<() => Promise>; + onFrameReceiver: (receiver: (message: Uint8Array) => Promise | void) => void; +}): Promise { + const { io } = params; + const frames = io.frames; + if (!frames) { + throw new Error("Codex node exec-server requires duplex frames."); + } + const cwd = params.workspaceDir; + let writes: Promise | undefined; + let rejectDisconnected!: (error: Error) => void; + const disconnected = new Promise((_resolve, reject) => { + rejectDisconnected = reject; + }); + void disconnected.catch(() => {}); + const onAbort = () => { + const error = nodeExecServerAbortError(io.signal); + rejectDisconnected(error); + }; + io.signal.addEventListener("abort", onAbort, { once: true }); + + try { + if (io.signal.aborted) { + throw nodeExecServerAbortError(io.signal); + } + return await withTempWorkspace( + { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "codex-node-exec-server-" }, + async ({ dir }) => { + const codexHome = path.join(dir, ".codex"); + // Codex canonicalizes CODEX_HOME during startup and rejects missing directories. + await mkdir(codexHome, { recursive: true, mode: 0o700 }); + const resolved = await resolveManagedCodexAppServerStartOptions({ + transport: "stdio", + command: "codex", + commandSource: "managed", + managedCommandOrder: "package-first", + args: ["exec-server", "--listen", "stdio"], + headers: {}, + }); + const native = resolveManagedCodexNativeCommand(resolved.command); + if (!native || isManagedCodexDesktopCommand(resolved.command)) { + throw new Error("Codex node exec-server requires the pinned managed package binary."); + } + // The exec-server needs platform/locale basics, never provider, forge, + // cloud, SSH-agent, XDG, or runtime-injection state from its node host. + const baseEnv = sanitizeEnvVars(process.env, { + strictMode: true, + customAllowedPatterns: [NODE_EXEC_SERVER_PLATFORM_ENVIRONMENT], + }).allowed; + if (io.signal.aborted) { + throw nodeExecServerAbortError(io.signal); + } + const child = createStdioTransport( + { + transport: "stdio", + command: native, + commandSource: "resolved-managed", + args: resolved.args, + headers: {}, + cwd, + env: { + HOME: dir, + CODEX_HOME: codexHome, + ...(process.platform === "win32" ? { USERPROFILE: dir } : {}), + }, + clearEnv: ["NODE_OPTIONS"], + }, + baseEnv, + ); + child.stdin.on("error", (error) => { + rejectDisconnected(error); + }); + let stderr = Buffer.alloc(0); + child.stderr.on("data", (chunk: Buffer | string) => { + const next = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const bounded = next.subarray(-MAX_CODEX_EXEC_SERVER_STDERR_BYTES); + stderr = Buffer.concat([stderr, bounded]).subarray(-MAX_CODEX_EXEC_SERVER_STDERR_BYTES); + }); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => { + child.once("close", (code, signal) => resolve({ code, signal })); + }, + ); + child.once("error", (error) => { + rejectDisconnected(error); + }); + const owner = createNodeExecServerProcessOwner(child, closed); + params.activeProcesses.add(owner.terminate); + const output = relayNodeExecServerOutput(child, frames.send.bind(frames)); + void output.catch((error: unknown) => { + rejectDisconnected(error instanceof Error ? error : new Error(String(error))); + }); + try { + if (io.signal.aborted) { + throw nodeExecServerAbortError(io.signal); + } + // Registration publishes framed readiness; avoid promising it before + // the child and every cleanup/error owner can consume incoming frames. + params.onFrameReceiver((message) => { + const encoded = validateNodeExecServerMessage(message); + const operation = writes + ? writes.then(() => writeNodeExecServerMessage(child, encoded, io.signal)) + : writeNodeExecServerMessage(child, encoded, io.signal); + if (!operation) { + return undefined; + } + const observed = operation.catch(() => {}); + writes = observed; + void observed.then(() => { + if (writes === observed) { + writes = undefined; + } + }); + return operation; + }); + const outcome = await Promise.race([closed, disconnected]); + const diagnostic = stderr.toString("utf8").trim(); + throw new Error( + `Codex node exec-server exited (code ${outcome.code ?? "none"}, signal ${outcome.signal ?? "none"})${diagnostic ? `: ${diagnostic}` : "."}`, + ); + } finally { + await owner.terminate(); + params.activeProcesses.delete(owner.terminate); + await Promise.allSettled([output, ...(writes ? [writes] : [])]); + } + }, + ); + } finally { + io.signal.removeEventListener("abort", onAbort); + } +} diff --git a/extensions/codex/src/node-exec-server.test.ts b/extensions/codex/src/node-exec-server.test.ts new file mode 100644 index 000000000000..6181227c3034 --- /dev/null +++ b/extensions/codex/src/node-exec-server.test.ts @@ -0,0 +1,673 @@ +/** Protects paired-node policy, real pinned Codex stdio framing, and child cleanup. */ +import { once } from "node:events"; +import { access, readFile, realpath } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import type { OpenClawPluginNodeHostCommandIo } from "openclaw/plugin-sdk/node-host"; +import type { + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicyContext, +} from "openclaw/plugin-sdk/plugin-entry"; +import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createCodexNodeExecServerCommand, + createCodexNodeExecServerInvokePolicy, +} from "./node-exec-server.js"; + +type JsonRpcRecord = Record; +const CODEX_NODE_EXEC_SERVER_COMMAND = "codex.exec-server.stdio.v1"; + +function createManagedWorkspaceInvocation(cwd: string) { + const placement = { + cwd, + environmentId: "paired-environment", + sessionId: "paired-session", + ownerEpoch: 1, + sessionKey: "agent:main:paired-session", + }; + const release = vi.fn(); + const acquireManagedWorkspace = vi.fn( + (request: { + workspaceDir: string; + environmentId: string; + sessionId: string; + ownerEpoch: number; + sessionKey: string; + }) => { + if ( + request.workspaceDir !== cwd || + request.environmentId !== placement.environmentId || + request.sessionId !== placement.sessionId || + request.ownerEpoch !== placement.ownerEpoch || + request.sessionKey !== placement.sessionKey + ) { + throw new Error("node placement does not own the requested workspace"); + } + return { workspaceDir: cwd, release }; + }, + ); + const context = { + sessionKey: placement.sessionKey, + sendNodeEvent: async () => undefined, + acquireManagedWorkspace, + } satisfies NonNullable[2]>; + return { placement, context, acquireManagedWorkspace, release }; +} + +function createNodeFrames() { + const controller = new AbortController(); + let receive: ((message: Uint8Array) => void | Promise) | undefined; + let signalReady = () => {}; + const ready = new Promise((resolve) => { + signalReady = resolve; + }); + const outbound: JsonRpcRecord[] = []; + const io: OpenClawPluginNodeHostCommandIo = { + signal: controller.signal, + emitChunk: async () => undefined, + onInput: () => undefined, + frames: { + send: async (message) => { + outbound.push(JSON.parse(Buffer.from(message).toString("utf8")) as JsonRpcRecord); + }, + onMessage: (listener) => { + receive = listener; + signalReady(); + return () => { + if (receive === listener) { + receive = undefined; + } + }; + }, + }, + }; + return { + controller, + io, + outbound, + ready, + send: async (message: unknown) => { + if (!receive) { + throw new Error("Codex node command did not register a ready duplex receiver."); + } + await receive(Buffer.from(JSON.stringify(message))); + }, + sendRaw: async (message: Uint8Array) => { + if (!receive) { + throw new Error("Codex node command did not register a ready duplex receiver."); + } + return await receive(message); + }, + }; +} + +async function readNodeResponse( + frames: ReturnType, + id: number, +): Promise { + await vi.waitFor(() => expect(frames.outbound.some((message) => message.id === id)).toBe(true)); + const response = frames.outbound.find((message) => message.id === id); + if (!response || response.error) { + throw new Error(`Codex exec-server request ${id} failed: ${JSON.stringify(response?.error)}`); + } + return response.result as JsonRpcRecord; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("Codex paired-node exec-server", () => { + it("requires exact one-time approval before the dangerous explicit-allowlist command runs", async () => { + const policy = createCodexNodeExecServerInvokePolicy(); + expect(policy.commands).toEqual([CODEX_NODE_EXEC_SERVER_COMMAND]); + expect(policy.dangerous).toBe(true); + expect(policy.defaultPlatforms).toBeUndefined(); + expect(policy.classifyRisk?.({ command: CODEX_NODE_EXEC_SERVER_COMMAND, params: {} })).toEqual({ + level: "high", + family: "codex.exec-server", + }); + + const invokeNode = vi.fn(async () => ({ ok: true as const, payload: { connected: true } })); + const request = vi.fn(); + const { placement } = createManagedWorkspaceInvocation(process.cwd()); + const context = { + nodeId: "paired-node", + command: CODEX_NODE_EXEC_SERVER_COMMAND, + params: placement, + config: {}, + risk: { level: "high", family: "codex.exec-server" }, + approvals: { request }, + invokeNode, + } satisfies OpenClawPluginNodeInvokePolicyContext; + + for (const decision of ["deny", "allow-always", null] as const) { + request.mockResolvedValueOnce({ decision }); + await expect(policy.handle(context)).resolves.toMatchObject({ + ok: false, + code: "CODEX_NODE_EXEC_APPROVAL_DENIED", + }); + expect(invokeNode).not.toHaveBeenCalled(); + } + await expect(policy.handle({ ...context, approvals: undefined })).resolves.toMatchObject({ + ok: false, + code: "CODEX_NODE_EXEC_APPROVAL_REQUIRED", + }); + expect(invokeNode).not.toHaveBeenCalled(); + + await expect( + policy.handle({ ...context, params: { cwd: process.cwd() } }), + ).resolves.toMatchObject({ + ok: false, + code: "CODEX_NODE_EXEC_WORKSPACE_INVALID", + }); + expect(invokeNode).not.toHaveBeenCalled(); + + const approvedPlacement = { ...placement }; + request.mockImplementationOnce(async () => { + placement.cwd = path.parse(process.cwd()).root; + return { decision: "allow-once" }; + }); + await expect(policy.handle(context)).resolves.toEqual({ + ok: true, + payload: { connected: true }, + }); + expect(invokeNode).toHaveBeenCalledOnce(); + expect(invokeNode).toHaveBeenCalledWith({ params: approvedPlacement }); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Run Codex execution on paired device", + description: expect.stringContaining(`paired-node: ${approvedPlacement.cwd}`), + severity: "critical", + allowedDecisions: ["allow-once"], + }), + ); + expect(request.mock.lastCall?.[0].description).toContain( + "arbitrary processes and filesystem access across the paired-device account", + ); + }); + + it("rejects unmanaged placement identities before launch and malformed or oversized frames", async () => { + const command = createCodexNodeExecServerCommand(); + const frames = createNodeFrames(); + const workspace = createManagedWorkspaceInvocation(process.cwd()); + const encodedPlacement = JSON.stringify(workspace.placement); + await expect(command.handle(encodedPlacement)).rejects.toThrow("requires duplex frames"); + await expect( + command.handle( + JSON.stringify({ ...workspace.placement, env: { TOKEN: "canary" } }), + frames.io, + workspace.context, + ), + ).rejects.toThrow("exact managed placement workspace"); + await expect(command.handle(encodedPlacement, frames.io)).rejects.toThrow( + "active managed placement authority", + ); + await expect( + command.handle(encodedPlacement, frames.io, { + ...workspace.context, + sessionKey: "agent:main:different-session", + }), + ).rejects.toThrow("active managed placement authority"); + expect(workspace.acquireManagedWorkspace).not.toHaveBeenCalled(); + for (const replacement of [ + { cwd: path.parse(process.cwd()).root }, + { environmentId: "other-environment" }, + { sessionId: "other-session" }, + { ownerEpoch: 2 }, + ]) { + await expect( + command.handle( + JSON.stringify({ ...workspace.placement, ...replacement }), + frames.io, + workspace.context, + ), + ).rejects.toThrow("node placement does not own the requested workspace"); + } + expect(workspace.release).not.toHaveBeenCalled(); + + const invocation = command.handle(encodedPlacement, frames.io, workspace.context); + void invocation.catch(() => {}); + await Promise.race([frames.ready, invocation]); + await expect(frames.sendRaw(Buffer.from('{"id":1}\n{"id":2}'))).rejects.toThrow( + "exactly one message", + ); + await expect(frames.sendRaw(Uint8Array.of(0xff, 0xfe))).rejects.toThrow("malformed UTF-8"); + await expect(frames.sendRaw(new Uint8Array(64 * 1024 * 1024 + 1))).rejects.toThrow("64 MiB"); + frames.controller.abort(new Error("malformed-frame fixture closed")); + await expect(invocation).rejects.toThrow("malformed-frame fixture closed"); + expect(workspace.release).toHaveBeenCalledOnce(); + }); + + it("relays the actual pinned Codex binary, isolates credentials, and removes its private home", async () => { + vi.stubEnv("OPENAI_API_KEY", "node-provider-canary"); + vi.stubEnv("AWS_ACCESS_KEY_ID", "node-cloud-canary"); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "/node-cloud-canary.json"); + vi.stubEnv("GITHUB_TOKEN", "node-forge-canary"); + vi.stubEnv("SSH_AUTH_SOCK", "/node-ssh-canary.sock"); + vi.stubEnv("NODE_OPTIONS", "--no-warnings"); + + await withTempWorkspace( + { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "codex-node-exec-contract-" }, + async ({ dir }) => { + const cwd = await realpath(dir); + const workspaceUri = pathToFileURL(cwd).href; + const probePath = path.join(cwd, "probe.txt"); + const probeUri = pathToFileURL(probePath).href; + const frames = createNodeFrames(); + const command = createCodexNodeExecServerCommand(); + const workspace = createManagedWorkspaceInvocation(cwd); + const invocation = command.handle( + JSON.stringify(workspace.placement), + frames.io, + workspace.context, + ); + void invocation.catch(() => {}); + let isolatedHome: string | undefined; + + try { + await Promise.race([frames.ready, invocation]); + // Codex deliberately omits jsonrpc:"2.0" from every wire envelope. + await frames.send({ + id: 1, + method: "initialize", + params: { clientName: "openclaw-node" }, + }); + expect(await readNodeResponse(frames, 1)).toMatchObject({ + sessionId: expect.any(String), + }); + await frames.send({ method: "initialized", params: {} }); + + await frames.send({ id: 2, method: "environment/info", params: {} }); + expect(await readNodeResponse(frames, 2)).toMatchObject({ + cwd: workspaceUri, + capabilities: { networkProxyLaunch: true, sandboxedFileStreaming: true }, + }); + + const dataBase64 = Buffer.from("node filesystem proof\n").toString("base64"); + await frames.send({ + id: 3, + method: "fs/writeFile", + params: { path: probeUri, dataBase64, sandbox: null }, + }); + expect(await readNodeResponse(frames, 3)).toEqual({}); + expect(await readFile(probePath, "utf8")).toBe("node filesystem proof\n"); + + await frames.send({ + id: 4, + method: "fs/canonicalize", + params: { path: probeUri, sandbox: null }, + }); + expect(await readNodeResponse(frames, 4)).toEqual({ path: probeUri }); + await frames.send({ + id: 5, + method: "fs/open", + params: { handleId: "node-proof", path: probeUri, sandbox: null }, + }); + expect(await readNodeResponse(frames, 5)).toEqual({ handleId: "node-proof" }); + await frames.send({ + id: 6, + method: "fs/readBlock", + params: { handleId: "node-proof", offset: 0, len: 256 }, + }); + expect(await readNodeResponse(frames, 6)).toEqual({ chunk: dataBase64, eof: true }); + await frames.send({ id: 7, method: "fs/close", params: { handleId: "node-proof" } }); + expect(await readNodeResponse(frames, 7)).toEqual({}); + + const script = [ + "process.stdin.once('data', input => {", + "process.stdout.write(JSON.stringify({", + "input: input.toString().trim(),", + "ordinary: process.env.NODE_EXEC_ORDINARY ?? null,", + "home: process.env.HOME ?? null,", + "codexHome: process.env.CODEX_HOME ?? null,", + "userProfile: process.env.USERPROFILE ?? null,", + "provider: process.env.OPENAI_API_KEY ?? null,", + "cloud: process.env.AWS_ACCESS_KEY_ID ?? null,", + "cloudFile: process.env.GOOGLE_APPLICATION_CREDENTIALS ?? null,", + "forge: process.env.GITHUB_TOKEN ?? null,", + "ssh: process.env.SSH_AUTH_SOCK ?? null,", + "injection: process.env.NODE_OPTIONS ?? null", + "}) + '\\n', () => process.exit(0))", + "})", + ].join("\n"); + await frames.send({ + id: 8, + method: "process/start", + params: { + processId: "node-proof", + argv: [process.execPath, "-e", script], + cwd: workspaceUri, + env: { NODE_EXEC_ORDINARY: "visible" }, + envPolicy: { + inherit: "all", + ignoreDefaultExcludes: true, + exclude: [], + set: {}, + includeOnly: [], + }, + tty: false, + pipeStdin: true, + arg0: null, + }, + }); + expect(await readNodeResponse(frames, 8)).toMatchObject({ processId: "node-proof" }); + await frames.send({ + id: 9, + method: "process/write", + params: { + processId: "node-proof", + chunk: Buffer.from("node carrier\n").toString("base64"), + writeId: "node-proof-write", + }, + }); + await readNodeResponse(frames, 9); + await vi.waitFor(() => + expect(frames.outbound.some((message) => message.method === "process/closed")).toBe( + true, + ), + ); + const notifications = frames.outbound.filter((message) => + String(message.method).startsWith("process/"), + ); + expect(notifications.map((message) => message.method)).toEqual([ + "process/output", + "process/exited", + "process/closed", + ]); + const output = notifications[0]?.params as { chunk: string; seq: number }; + const observed = JSON.parse(Buffer.from(output.chunk, "base64").toString("utf8")) as { + input: string; + ordinary: string; + home: string; + codexHome: string; + userProfile: string | null; + provider: string | null; + cloud: string | null; + cloudFile: string | null; + forge: string | null; + ssh: string | null; + injection: string | null; + }; + expect(observed).toMatchObject({ + input: "node carrier", + ordinary: "visible", + provider: null, + cloud: null, + cloudFile: null, + forge: null, + ssh: null, + injection: null, + }); + expect(observed.codexHome).toBe(path.join(observed.home, ".codex")); + expect(observed.home).not.toBe(process.env.HOME); + if (process.platform === "win32") { + expect(observed.userProfile).toBe(observed.home); + } + isolatedHome = observed.home; + + // A response spanning many pipe chunks must arrive as one intact frame. + const chunkedUri = pathToFileURL(path.join(cwd, "chunked.txt")).href; + const chunkedDataBase64 = Buffer.alloc(256 * 1024, 0x61).toString("base64"); + await frames.send({ + id: 10, + method: "fs/writeFile", + params: { path: chunkedUri, dataBase64: chunkedDataBase64, sandbox: null }, + }); + expect(await readNodeResponse(frames, 10)).toEqual({}); + await frames.send({ + id: 11, + method: "fs/readFile", + params: { path: chunkedUri, sandbox: null }, + }); + expect(await readNodeResponse(frames, 11)).toEqual({ dataBase64: chunkedDataBase64 }); + + await frames.send({ id: 12, method: "environment/status", params: {} }); + expect(await readNodeResponse(frames, 12)).toEqual({ status: "ready" }); + await frames.send({ + id: 13, + method: "fs/walk", + params: { + path: workspaceUri, + options: { + maxDepth: 2, + maxDirectories: 10, + maxEntries: 30, + followDirectorySymlinks: false, + pruneHiddenDirectories: false, + }, + sandbox: null, + }, + }); + expect(await readNodeResponse(frames, 13)).toMatchObject({ + entries: expect.arrayContaining([ + expect.objectContaining({ path: probeUri, kind: "file" }), + expect.objectContaining({ path: chunkedUri, kind: "file" }), + ]), + errors: [], + truncated: false, + }); + await frames.send({ + id: 14, + method: "capabilityRoots/discoverV1", + params: { roots: [{ id: "workspace", path: workspaceUri, sandbox: null }] }, + }); + expect(await readNodeResponse(frames, 14)).toMatchObject({ + roots: [expect.objectContaining({ id: "workspace", path: workspaceUri })], + }); + + for (const control of [ + { id: 15, processId: "node-signal", method: "process/signal", result: {} }, + { + id: 17, + processId: "node-terminate", + method: "process/terminate", + result: { running: true }, + }, + ]) { + await frames.send({ + id: control.id, + method: "process/start", + params: { + processId: control.processId, + argv: [process.execPath, "-e", "setInterval(() => {}, 60_000)"], + cwd: workspaceUri, + env: {}, + tty: false, + pipeStdin: false, + arg0: null, + }, + }); + expect(await readNodeResponse(frames, control.id)).toMatchObject({ + processId: control.processId, + }); + await frames.send({ + id: control.id + 1, + method: control.method, + params: { + processId: control.processId, + ...(control.method === "process/signal" ? { signal: "interrupt" } : {}), + }, + }); + expect(await readNodeResponse(frames, control.id + 1)).toEqual(control.result); + await vi.waitFor(() => + expect( + frames.outbound.some( + (message) => + message.method === "process/closed" && + (message.params as { processId?: string }).processId === control.processId, + ), + ).toBe(true), + ); + expect( + frames.outbound + .filter( + (message) => + String(message.method).startsWith("process/") && + (message.params as { processId?: string }).processId === control.processId, + ) + .map((message) => message.method), + ).toEqual(["process/exited", "process/closed"]); + } + + const httpServer = createServer((_request, response) => { + response.writeHead(200, { "content-type": "text/plain" }); + response.write("alpha"); + setImmediate(() => response.end("beta")); + }); + httpServer.listen(0, "127.0.0.1"); + try { + await once(httpServer, "listening"); + const address = httpServer.address(); + if (!address || typeof address === "string") { + throw new Error("Codex exec-server HTTP fixture did not bind a TCP port."); + } + await frames.send({ + id: 19, + method: "http/request", + params: { + method: "GET", + url: `http://127.0.0.1:${address.port}/`, + headers: [], + bodyBase64: null, + timeoutMs: 3_000, + redirectPolicy: "follow", + requestId: "node-http-proof", + streamResponse: true, + }, + }); + expect(await readNodeResponse(frames, 19)).toMatchObject({ + status: 200, + bodyBase64: "", + }); + await vi.waitFor(() => + expect( + frames.outbound.some( + (message) => + message.method === "http/request/bodyDelta" && + (message.params as { requestId?: string; done?: boolean }).requestId === + "node-http-proof" && + (message.params as { done?: boolean }).done === true, + ), + ).toBe(true), + ); + const chunks = frames.outbound + .filter( + (message) => + message.method === "http/request/bodyDelta" && + (message.params as { requestId?: string }).requestId === "node-http-proof", + ) + .map( + (message) => message.params as { seq: number; deltaBase64: string; done: boolean }, + ); + expect(chunks.map((chunk) => chunk.seq)).toEqual( + chunks.map((_chunk, index) => index + 1), + ); + expect( + Buffer.concat( + chunks.map((chunk) => Buffer.from(chunk.deltaBase64, "base64")), + ).toString("utf8"), + ).toBe("alphabeta"); + expect(chunks.at(-1)?.done).toBe(true); + } finally { + await new Promise((resolve, reject) => { + httpServer.close((error) => (error ? reject(error) : resolve())); + }); + } + + const policyScript = [ + "const net = require('node:net')", + "const proxy = new URL(process.env.HTTP_PROXY)", + "const socket = net.connect(Number(proxy.port), proxy.hostname, () => {", + "socket.write('CONNECT 8.8.8.8:443 HTTP/1.1\\r\\nHost: 8.8.8.8:443\\r\\n\\r\\n')", + "})", + "socket.once('data', chunk => {", + "const line = chunk.toString().split('\\r\\n')[0]", + "process.stdout.write(line + '\\n', () => { socket.end(); process.exit(0) })", + "})", + ].join("\n"); + await frames.send({ + id: 20, + method: "process/start", + params: { + processId: "node-policy", + argv: [process.execPath, "-e", policyScript], + cwd: workspaceUri, + env: {}, + tty: false, + pipeStdin: false, + arg0: null, + networkProxy: { + proxy: { + enabled: true, + enableSocks5: false, + enableSocks5Udp: false, + allowUpstreamProxy: false, + dangerouslyAllowAllUnixSockets: false, + mode: "full", + domains: null, + unixSockets: null, + allowLocalBinding: false, + }, + environmentId: "node-policy-environment", + executionId: "node-policy-execution", + policyDecisionTimeoutMs: 3_000, + }, + }, + }); + expect(await readNodeResponse(frames, 20)).toMatchObject({ processId: "node-policy" }); + await vi.waitFor(() => + expect( + frames.outbound.some((message) => message.method === "network/policyRequest"), + ).toBe(true), + ); + const policyRequest = frames.outbound.find( + (message) => message.method === "network/policyRequest", + ); + expect(policyRequest).toMatchObject({ + id: expect.any(Number), + params: { + processId: "node-policy", + request: { protocol: "https_connect", host: "8.8.8.8", port: 443 }, + }, + }); + await frames.send({ + id: policyRequest?.id, + result: { decision: { type: "deny", reason: "node-policy-proof" } }, + }); + await vi.waitFor(() => + expect( + frames.outbound.some( + (message) => + message.method === "process/closed" && + (message.params as { processId?: string }).processId === "node-policy", + ), + ).toBe(true), + ); + expect(frames.outbound).toEqual( + expect.arrayContaining([ + expect.objectContaining({ method: "network/policyDecision" }), + expect.objectContaining({ + method: "process/output", + params: expect.objectContaining({ + processId: "node-policy", + chunk: Buffer.from("HTTP/1.1 403 Forbidden\n").toString("base64"), + }), + }), + ]), + ); + } finally { + frames.controller.abort(new Error("paired-device attempt completed")); + await expect(invocation).rejects.toThrow("paired-device attempt completed"); + await command.onDisconnect?.(); + expect(workspace.release).toHaveBeenCalledOnce(); + } + + expect(isolatedHome).toEqual(expect.any(String)); + await expect(access(isolatedHome!)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + }); +}); diff --git a/extensions/codex/src/node-exec-server.ts b/extensions/codex/src/node-exec-server.ts new file mode 100644 index 000000000000..45dc02b0906b --- /dev/null +++ b/extensions/codex/src/node-exec-server.ts @@ -0,0 +1,148 @@ +/** Declares the explicitly approved, lazily loaded paired-node Codex exec-server. */ +import type { + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeInvokePolicy, +} from "openclaw/plugin-sdk/plugin-entry"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; + +const CODEX_NODE_EXEC_SERVER_COMMAND = "codex.exec-server.stdio.v1"; + +const CODEX_NODE_EXEC_SERVER_CAPABILITY = "codex.exec-server"; + +function parseCodexNodePlacementWorkspace(value: unknown) { + if ( + !isRecord(value) || + Object.keys(value).length !== 5 || + typeof value.cwd !== "string" || + !value.cwd.trim() || + value.cwd.includes("\0") || + typeof value.environmentId !== "string" || + typeof value.sessionId !== "string" || + ![value.environmentId, value.sessionId].every( + (identifier) => + identifier.length > 0 && + identifier.length <= 256 && + identifier.trim() === identifier && + !identifier.includes("\0"), + ) || + typeof value.sessionKey !== "string" || + !value.sessionKey || + value.sessionKey.trim() !== value.sessionKey || + value.sessionKey.includes("\0") || + typeof value.ownerEpoch !== "number" || + !Number.isSafeInteger(value.ownerEpoch) || + value.ownerEpoch < 1 + ) { + throw new Error("Codex node exec-server requires an exact managed placement workspace."); + } + return { + cwd: value.cwd, + environmentId: value.environmentId, + sessionId: value.sessionId, + ownerEpoch: value.ownerEpoch, + sessionKey: value.sessionKey, + }; +} + +/** Registers the exact pinned exec-server as an explicitly approved duplex node command. */ +export function createCodexNodeExecServerCommand(): OpenClawPluginNodeHostCommand { + const activeProcesses = new Set<() => Promise>(); + return { + command: CODEX_NODE_EXEC_SERVER_COMMAND, + cap: CODEX_NODE_EXEC_SERVER_CAPABILITY, + dangerous: true, + duplex: true, + onDisconnect: async () => { + await Promise.all([...activeProcesses].map(async (terminate) => await terminate())); + }, + handle: async (paramsJSON, io, context) => { + if (!io?.frames) { + throw new Error("Codex node exec-server requires duplex frames."); + } + let request: unknown; + try { + request = JSON.parse(paramsJSON ?? "null") as unknown; + } catch { + throw new Error("Codex node exec-server requires a valid workspace request."); + } + const placement = parseCodexNodePlacementWorkspace(request); + if ( + !context?.acquireManagedWorkspace || + context.sessionKey !== placement.sessionKey || + io.signal.aborted + ) { + throw new Error("Codex node exec-server requires active managed placement authority."); + } + const workspace = context.acquireManagedWorkspace({ + workspaceDir: placement.cwd, + environmentId: placement.environmentId, + sessionId: placement.sessionId, + ownerEpoch: placement.ownerEpoch, + sessionKey: placement.sessionKey, + }); + const frames = io.frames; + let unsubscribe: (() => void) | undefined; + try { + const { runCodexNodeExecServer } = await import("./node-exec-server.runtime.js"); + return await runCodexNodeExecServer({ + workspaceDir: workspace.workspaceDir, + io, + activeProcesses, + // Listener registration announces readiness, so the child must own it first. + onFrameReceiver: (receiver) => { + unsubscribe = frames.onMessage(receiver); + }, + }); + } finally { + try { + unsubscribe?.(); + } finally { + workspace.release(); + } + } + }, + }; +} + +/** Keeps paired-device exec-server launch behind explicit arming and one-time approval. */ +export function createCodexNodeExecServerInvokePolicy(): OpenClawPluginNodeInvokePolicy { + return { + commands: [CODEX_NODE_EXEC_SERVER_COMMAND], + dangerous: true, + classifyRisk: () => ({ level: "high", family: CODEX_NODE_EXEC_SERVER_CAPABILITY }), + handle: async (context) => { + if (!context.approvals || context.risk?.level !== "high") { + return { + ok: false, + code: "CODEX_NODE_EXEC_APPROVAL_REQUIRED", + message: "Codex paired-device execution requires an available approval reviewer.", + }; + } + let placement: ReturnType; + try { + placement = parseCodexNodePlacementWorkspace(context.params); + } catch { + return { + ok: false, + code: "CODEX_NODE_EXEC_WORKSPACE_INVALID", + message: "Codex paired-device execution requires an exact managed placement workspace.", + }; + } + const deviceName = context.node?.displayName ?? context.nodeId; + const approval = await context.approvals.request({ + title: "Run Codex execution on paired device", + description: `${deviceName}: ${placement.cwd}; allows arbitrary processes and filesystem access across the paired-device account, not only this workspace.`, + severity: "critical", + allowedDecisions: ["allow-once"], + }); + if (approval.decision !== "allow-once") { + return { + ok: false, + code: "CODEX_NODE_EXEC_APPROVAL_DENIED", + message: "Codex paired-device execution requires one-time approval.", + }; + } + return await context.invokeNode({ params: placement }); + }, + }; +} diff --git a/extensions/crabbox/src/crabbox-worker-heartbeat.ts b/extensions/crabbox/src/crabbox-worker-heartbeat.ts index ceee77f9218d..655b7c321607 100644 --- a/extensions/crabbox/src/crabbox-worker-heartbeat.ts +++ b/extensions/crabbox/src/crabbox-worker-heartbeat.ts @@ -1,12 +1,12 @@ import type { SpawnResult } from "openclaw/plugin-sdk/process-runtime"; import { crabboxCommandError } from "./crabbox-worker-command-error.js"; -const CRABBOX_HEARTBEAT_UPGRADE = - "upgrade Crabbox to a release that includes `crabbox heartbeat` (added after v0.43.0)"; +const CRABBOX_HEARTBEAT_UPGRADE = "upgrade Crabbox to v0.44.0 or newer for `crabbox heartbeat`"; type HeartbeatContext = { binary: string; heartbeatIntervalMs: number; + heartbeatTimeoutMs: number; id: string; idleTimeout: string; provider: string; diff --git a/extensions/crabbox/src/crabbox-worker-profile.ts b/extensions/crabbox/src/crabbox-worker-profile.ts index 14f3abd35c9f..93d7622e17ad 100644 --- a/extensions/crabbox/src/crabbox-worker-profile.ts +++ b/extensions/crabbox/src/crabbox-worker-profile.ts @@ -7,6 +7,7 @@ import { type WorkerProfile, } from "openclaw/plugin-sdk/plugin-entry"; import { normalizeOptionalString as nonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { CRABBOX_HEARTBEAT_TIMEOUT_MS } from "./crabbox-worker-timeouts.js"; export { nonEmptyString }; @@ -39,6 +40,7 @@ type CrabboxProfile = { class: string; desktop?: boolean; heartbeatIntervalMs: number; + heartbeatTimeoutMs: number; idleTimeout: string; provider: string; ttl: string; @@ -60,14 +62,18 @@ type IsExecutable = (candidate: string) => boolean; export const CRABBOX_WORKER_PROVIDER_ID = "crabbox"; -function requirePositiveDuration(value: unknown, key: string): string { +function requirePositiveDuration( + value: unknown, + key: string, +): { duration: string; milliseconds: number } { const duration = nonEmptyString(value); - if (!duration || parsePositiveGoDurationNanoseconds(duration) === undefined) { + const nanoseconds = duration ? parsePositiveGoDurationNanoseconds(duration) : undefined; + if (!duration || nanoseconds === undefined) { throw new WorkerProviderError( `Crabbox profile ${key} must be a positive Go duration such as 60m`, ); } - return duration; + return { duration, milliseconds: Number(nanoseconds) / 1_000_000 }; } function parsePositiveGoDurationNanoseconds(duration: string): bigint | undefined { @@ -98,12 +104,7 @@ function parsePositiveGoDurationNanoseconds(duration: string): bigint | undefine return total > 0n ? total : undefined; } -function heartbeatIntervalMs(idleTimeout: string): number { - const idleNanoseconds = parsePositiveGoDurationNanoseconds(idleTimeout); - if (idleNanoseconds === undefined) { - throw new Error("Crabbox heartbeat requires a positive idle timeout"); - } - const idleTimeoutMs = Number(idleNanoseconds) / 1_000_000; +function heartbeatIntervalMs(idleTimeoutMs: number): number { const referenceIntervalMs = Math.max(5_000, Math.min(60_000, idleTimeoutMs / 3)); // Crabbox's floor can exceed short accepted timeouts. Keep renewal ahead of // coordinator idle expiry without changing the profile contract. @@ -125,8 +126,11 @@ export function parseCrabboxProfile(profile: WorkerProfile): CrabboxProfile { if (!machineClass) { throw new WorkerProviderError("Crabbox profile class must be a non-empty string"); } - const ttl = requirePositiveDuration(profile.ttl, "ttl"); - const idleTimeout = requirePositiveDuration(profile.idleTimeout, "idleTimeout"); + const { duration: ttl } = requirePositiveDuration(profile.ttl, "ttl"); + const { duration: idleTimeout, milliseconds: idleTimeoutMs } = requirePositiveDuration( + profile.idleTimeout, + "idleTimeout", + ); const binaryValue = profile.binary; const binary = binaryValue === undefined ? undefined : nonEmptyString(binaryValue); if (binaryValue !== undefined && !binary) { @@ -153,7 +157,11 @@ export function parseCrabboxProfile(profile: WorkerProfile): CrabboxProfile { binary, class: machineClass, desktop, - heartbeatIntervalMs: heartbeatIntervalMs(idleTimeout), + heartbeatIntervalMs: heartbeatIntervalMs(idleTimeoutMs), + heartbeatTimeoutMs: Math.min( + CRABBOX_HEARTBEAT_TIMEOUT_MS, + Math.max(1, Math.floor(idleTimeoutMs / 2)), + ), idleTimeout, provider, setup, diff --git a/extensions/crabbox/src/crabbox-worker-provider.test.ts b/extensions/crabbox/src/crabbox-worker-provider.test.ts index 3da9f640ae7c..9d2764888d6c 100644 --- a/extensions/crabbox/src/crabbox-worker-provider.test.ts +++ b/extensions/crabbox/src/crabbox-worker-provider.test.ts @@ -1956,20 +1956,25 @@ describe("Crabbox worker provider", () => { }); it.each([ - { idleTimeout: "1s", idleTimeoutMs: 1_000, intervalMs: 500 }, - { idleTimeout: "2s", idleTimeoutMs: 2_000, intervalMs: 1_000 }, - { idleTimeout: "5s", idleTimeoutMs: 5_000, intervalMs: 2_500 }, - { idleTimeout: "12s", idleTimeoutMs: 12_000, intervalMs: 5_000 }, - { idleTimeout: "30s", idleTimeoutMs: 30_000, intervalMs: 10_000 }, - { idleTimeout: "6m", idleTimeoutMs: 360_000, intervalMs: 60_000 }, + { idleTimeout: "1s", idleTimeoutMs: 1_000, intervalMs: 500, timeoutMs: 500 }, + { idleTimeout: "2s", idleTimeoutMs: 2_000, intervalMs: 1_000, timeoutMs: 1_000 }, + { idleTimeout: "5s", idleTimeoutMs: 5_000, intervalMs: 2_500, timeoutMs: 2_500 }, + { idleTimeout: "12s", idleTimeoutMs: 12_000, intervalMs: 5_000, timeoutMs: 6_000 }, + { idleTimeout: "30s", idleTimeoutMs: 30_000, intervalMs: 10_000, timeoutMs: 15_000 }, + { idleTimeout: "6m", idleTimeoutMs: 360_000, intervalMs: 60_000, timeoutMs: 150_000 }, + { idleTimeout: "45m", idleTimeoutMs: 2_700_000, intervalMs: 60_000, timeoutMs: 150_000 }, ])( "heartbeats an active lease every $intervalMs ms for idleTimeout=$idleTimeout", - async ({ idleTimeout, idleTimeoutMs, intervalMs }) => { + async ({ idleTimeout, idleTimeoutMs, intervalMs, timeoutMs }) => { vi.useFakeTimers(); const calls: string[][] = []; + const heartbeatTimeouts: number[] = []; const profile = { ...PROFILE, idleTimeout }; - const provider = providerWithRunner(async (argv) => { + const provider = providerWithRunner(async (argv, options) => { calls.push(argv); + if (argv[1] === "heartbeat") { + heartbeatTimeouts.push(options.timeoutMs); + } return argv[1] === "inspect" ? commandResult({ stdout: inspectJson({ sshHostKey: HOST_KEY }) }) : commandResult(); @@ -1994,6 +1999,7 @@ describe("Crabbox worker provider", () => { "--json", ], ]); + expect(heartbeatTimeouts).toEqual([timeoutMs]); await vi.advanceTimersByTimeAsync(intervalMs - 1); expect(heartbeatCalls()).toHaveLength(1); @@ -2083,7 +2089,7 @@ describe("Crabbox worker provider", () => { expect(calls.filter((argv) => argv[1] === "heartbeat")).toHaveLength(1); expect(warnings).toEqual([ - `Crabbox heartbeat is unavailable for worker lease ${LEASE_ID}; upgrade Crabbox to a release that includes \`crabbox heartbeat\` (added after v0.43.0); cloud worker machines may be reaped after 60m of coordinator-idle time`, + `Crabbox heartbeat is unavailable for worker lease ${LEASE_ID}; upgrade Crabbox to v0.44.0 or newer for \`crabbox heartbeat\`; cloud worker machines may be reaped after 60m of coordinator-idle time`, ]); } finally { await provider.destroy(lease); diff --git a/extensions/crabbox/src/crabbox-worker-provider.ts b/extensions/crabbox/src/crabbox-worker-provider.ts index da32883f8c42..606a46a261de 100644 --- a/extensions/crabbox/src/crabbox-worker-provider.ts +++ b/extensions/crabbox/src/crabbox-worker-provider.ts @@ -77,7 +77,7 @@ type CrabboxProfile = ReturnType; type LeaseCommandContext = { binary: string; id: string; provider: string }; type LeaseHeartbeatContext = LeaseCommandContext & - Pick; + Pick; type ProvisionInspectContext = Omit & { deadline: number; inspect: ParsedInspect; @@ -433,7 +433,7 @@ export function createCrabboxWorkerProvider( binary: context.binary, runCommand, signal, - timeoutMs: Math.min(CRABBOX_LIFECYCLE_TIMEOUT_MS, context.heartbeatIntervalMs), + timeoutMs: context.heartbeatTimeoutMs, }), warn, }); @@ -466,6 +466,7 @@ export function createCrabboxWorkerProvider( return { binary: resolveBinary(parsed.binary), heartbeatIntervalMs: parsed.heartbeatIntervalMs, + heartbeatTimeoutMs: parsed.heartbeatTimeoutMs, id: lease.leaseId, idleTimeout: parsed.idleTimeout, provider: parsed.provider, @@ -637,6 +638,7 @@ export function createCrabboxWorkerProvider( heartbeats.start({ binary, heartbeatIntervalMs: parsed.heartbeatIntervalMs, + heartbeatTimeoutMs: parsed.heartbeatTimeoutMs, id: leaseId, idleTimeout: parsed.idleTimeout, provider: parsed.provider, diff --git a/extensions/crabbox/src/crabbox-worker-timeouts.ts b/extensions/crabbox/src/crabbox-worker-timeouts.ts index 7ab19458240a..ecfaa3ebd6fb 100644 --- a/extensions/crabbox/src/crabbox-worker-timeouts.ts +++ b/extensions/crabbox/src/crabbox-worker-timeouts.ts @@ -5,6 +5,8 @@ type CrabboxProvisionTimeoutProfile = { export const CRABBOX_WARMUP_TIMEOUT_MS = 240_000; export const CRABBOX_LIFECYCLE_TIMEOUT_MS = 60_000; +// AWS coordinator heartbeat latency reached 107.6 seconds in production measurements. +export const CRABBOX_HEARTBEAT_TIMEOUT_MS = 150_000; // `providers --json` is a static compiled report: no network, no credentials, // measured well under a second. The picker awaits it, so cap it far below the diff --git a/extensions/device-pair/doctor-contract-api.test.ts b/extensions/device-pair/doctor-contract-api.test.ts index 2b3a92dccfe0..919f5c9b0e5f 100644 --- a/extensions/device-pair/doctor-contract-api.test.ts +++ b/extensions/device-pair/doctor-contract-api.test.ts @@ -43,6 +43,7 @@ describe("device-pair doctor notify migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/diagnostics-otel/src/service.otlp-export.test.ts b/extensions/diagnostics-otel/src/service.otlp-export.test.ts index 91c6bce21ae6..b213797d1273 100644 --- a/extensions/diagnostics-otel/src/service.otlp-export.test.ts +++ b/extensions/diagnostics-otel/src/service.otlp-export.test.ts @@ -33,10 +33,8 @@ import { waitForDiagnosticEventsDrained, } from "openclaw/plugin-sdk/diagnostic-runtime"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; -import { - runModelCallAndCaptureTraceparent, - startLocalOtlpReceiver, -} from "../../../test/e2e/qa-lab/runtime/otel-test-support.js"; +import { runModelCallAndCaptureTraceparent } from "../../../test/e2e/qa-lab/runtime/otel-model-call.test-support.js"; +import { startLocalOtlpReceiver } from "../../../test/e2e/qa-lab/runtime/otel-test-support.js"; import { createDiagnosticsOtelService } from "./service.js"; import { createOtelContext, diff --git a/extensions/diffs/plugin-startup-laziness.test.ts b/extensions/diffs/plugin-startup-laziness.test.ts new file mode 100644 index 000000000000..acf3bc0075a1 --- /dev/null +++ b/extensions/diffs/plugin-startup-laziness.test.ts @@ -0,0 +1,11 @@ +import { expect, it, vi } from "vitest"; + +vi.mock("./src/browser.runtime.js", () => { + throw new Error("plugin startup must not load the Playwright renderer"); +}); + +it("imports the plugin entry without loading the Playwright renderer", async () => { + const { default: plugin } = await import("./index.js"); + + expect(plugin.id).toBe("diffs"); +}); diff --git a/extensions/diffs/src/browser.test.ts b/extensions/diffs/src/browser.runtime.test.ts similarity index 99% rename from extensions/diffs/src/browser.test.ts rename to extensions/diffs/src/browser.runtime.test.ts index ec9cb0b78b57..66c5b416ae2a 100644 --- a/extensions/diffs/src/browser.test.ts +++ b/extensions/diffs/src/browser.runtime.test.ts @@ -20,7 +20,7 @@ const { launchMock } = vi.hoisted(() => ({ launchMock: vi.fn(), })); -let PlaywrightDiffScreenshotter: typeof import("./browser.js").PlaywrightDiffScreenshotter; +let PlaywrightDiffScreenshotter: typeof import("./browser.runtime.js").PlaywrightDiffScreenshotter; vi.mock("playwright-core", () => ({ chromium: { @@ -58,7 +58,7 @@ describe("PlaywrightDiffScreenshotter", () => { throw new Error("process.platform descriptor is unavailable"); } originalPlatform = platformDescriptor; - ({ PlaywrightDiffScreenshotter } = await import("./browser.js")); + ({ PlaywrightDiffScreenshotter } = await import("./browser.runtime.js")); ({ rootDir, cleanup: cleanupRootDir } = await createTempDiffRoot("openclaw-diffs-browser-")); outputPath = path.join(rootDir, "preview.png"); launchMock.mockReset(); diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.runtime.ts similarity index 100% rename from extensions/diffs/src/browser.ts rename to extensions/diffs/src/browser.runtime.ts diff --git a/extensions/diffs/src/tool-render-output.test.ts b/extensions/diffs/src/tool-render-output.test.ts index f5695cd8ad2c..2be1598f4a48 100644 --- a/extensions/diffs/src/tool-render-output.test.ts +++ b/extensions/diffs/src/tool-render-output.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi } from "../api.js"; -import type { DiffScreenshotter } from "./browser.js"; +import type { DiffScreenshotter } from "./browser.runtime.js"; import { resolveDiffsPluginDefaults } from "./config.js"; import { createDiffStoreHarness } from "./test-helpers.js"; diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 4d62fd5efe52..e85d98253335 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -4,13 +4,17 @@ import path from "node:path"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; -import type { DiffScreenshotter } from "./browser.js"; +import type { DiffScreenshotter } from "./browser.runtime.js"; import { resolveDiffsPluginDefaults } from "./config.js"; import { DiffArtifactStore } from "./store.js"; import { createDiffStoreHarness } from "./test-helpers.js"; import { createDiffsTool } from "./tool.js"; import type { DiffRenderOptions } from "./types.js"; +vi.mock("./browser.runtime.js", () => { + throw new Error("viewer-only rendering must not load the Playwright renderer"); +}); + const DEFAULT_DIFFS_TOOL_DEFAULTS = resolveDiffsPluginDefaults(undefined); describe("diffs tool", () => { @@ -342,6 +346,26 @@ describe("diffs tool", () => { await expect(fs.readdir(rootDir)).resolves.toEqual([]); }); + it("falls back to view output when the default image renderer cannot load", async () => { + const tool = createDiffsTool({ + api: createApi(), + store, + defaults: DEFAULT_DIFFS_TOOL_DEFAULTS, + }); + + const result = await tool.execute?.("tool-3b", { + before: "one\n", + after: "two\n", + mode: "both", + }); + + expect(readTextContent(result, 0)).toContain("Diff viewer ready."); + expect((result.details as Record).viewerUrl).toEqual(expect.any(String)); + expect((result.details as Record).fileError).toContain( + "viewer-only rendering must not load the Playwright renderer", + ); + }); + it("rejects invalid base URLs as tool input errors", async () => { const tool = createDiffsTool({ api: createApi(), diff --git a/extensions/diffs/src/tool.ts b/extensions/diffs/src/tool.ts index 8b7756e02ecb..a42c6af74fff 100644 --- a/extensions/diffs/src/tool.ts +++ b/extensions/diffs/src/tool.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { optionalFiniteNumberSchema, stringEnum } from "openclaw/plugin-sdk/channel-actions"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { readFiniteNumberParam } from "openclaw/plugin-sdk/param-readers"; import { asNonArrayRecord, @@ -10,7 +11,7 @@ import { import { Type } from "typebox"; import type { Static } from "typebox"; import type { AnyAgentTool, OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js"; -import { PlaywrightDiffScreenshotter, type DiffScreenshotter } from "./browser.js"; +import type { DiffScreenshotter } from "./browser.runtime.js"; import { resolveDiffImageRenderOptions } from "./config.js"; import { DiffRenderInputError, renderDiffDocument } from "./render.js"; import type { DiffArtifactStore } from "./store.js"; @@ -39,6 +40,7 @@ const MAX_TITLE_BYTES = 1_024; const MAX_PATH_BYTES = 2_048; const MAX_LANG_BYTES = 128; const MAX_DIFF_ARTIFACT_TTL_SECONDS = 21_600; +const loadDiffsBrowserRuntime = createLazyRuntimeModule(() => import("./browser.runtime.js")); const DiffsToolSchema = Type.Object( { @@ -125,6 +127,12 @@ export function createDiffsTool(params: { screenshotter?: DiffScreenshotter; context?: OpenClawPluginToolContext; }): AnyAgentTool { + const loadScreenshotter = async () => + params.screenshotter ?? + new (await loadDiffsBrowserRuntime()).PlaywrightDiffScreenshotter({ + config: params.api.config, + }); + return { name: "diffs", label: "Diffs", @@ -188,10 +196,8 @@ export function createDiffsTool(params: { throw error; }); - const screenshotter = - params.screenshotter ?? new PlaywrightDiffScreenshotter({ config: params.api.config }); - if (isArtifactOnlyMode(mode)) { + const screenshotter = await loadScreenshotter(); const artifactFile = await renderDiffArtifactFile({ screenshotter, store: params.store, @@ -271,6 +277,7 @@ export function createDiffsTool(params: { } try { + const screenshotter = await loadScreenshotter(); const artifactFile = await renderDiffArtifactFile({ screenshotter, store: params.store, diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 25b400a0b51d..abc37c80d27b 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -59,6 +59,7 @@ import { probeDiscordStatusAccount, } from "./channel.loaders.js"; import { openDiscordCommandDeployHashStore } from "./command-deploy-store.js"; +import { inspectDiscordConversationRouteOwner } from "./conversation-route-owner.js"; import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js"; import { resolveDiscordGroupRequireMention, @@ -259,6 +260,7 @@ export const discordPlugin: ChannelPlugin ], }, messaging: { + resolveConversationRouteOwner: inspectDiscordConversationRouteOwner, targetPrefixes: ["discord"], directTargetStyle: "user-prefixed", targetIdComparison: "lowercase", @@ -424,6 +426,7 @@ export const discordPlugin: ChannelPlugin }, conversationBindings: { supportsCurrentConversationBinding: true, + bindingStore: "adapter", defaultTopLevelPlacement, createManager: async ({ cfg, accountId }) => (await loadDiscordThreadBindingsManagerModule()).createThreadBindingManager({ diff --git a/extensions/discord/src/conversation-identity.ts b/extensions/discord/src/conversation-identity.ts index 74a23dd55dc7..301959d92511 100644 --- a/extensions/discord/src/conversation-identity.ts +++ b/extensions/discord/src/conversation-identity.ts @@ -34,6 +34,18 @@ export function resolveDiscordConversationIdentity(params: { : buildDiscordConversationIdentity("channel", params.channelId); } +export function resolveDiscordRuntimeBindingConversationId(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + userId?: string | null; + channelId: string; +}): string { + if (params.isDirectMessage && !params.isGroupDm) { + return buildDiscordConversationIdentity("user", params.userId) ?? params.channelId; + } + return params.channelId; +} + export function resolveDiscordCurrentConversationIdentity(params: { chatType?: string | null; from?: string | null; diff --git a/extensions/discord/src/conversation-route-owner.test.ts b/extensions/discord/src/conversation-route-owner.test.ts new file mode 100644 index 000000000000..4d888076a16c --- /dev/null +++ b/extensions/discord/src/conversation-route-owner.test.ts @@ -0,0 +1,149 @@ +import { + registerSessionBindingAdapter, + type SessionBindingAdapter, + testing as sessionBindingTesting, + unregisterSessionBindingAdapter, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { inspectDiscordConversationRouteOwner } from "./conversation-route-owner.js"; + +describe("inspectDiscordConversationRouteOwner", () => { + let adapter: SessionBindingAdapter; + + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + source: "test", + plugin: { + id: "discord", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, + ]), + ); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + adapter = { + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: () => null, + }; + registerSessionBindingAdapter(adapter); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("uses the direct-user runtime identity without touching liveness", () => { + const touch = vi.fn(); + const resolveByConversation = vi.fn((conversation) => ({ + bindingId: "binding-direct", + targetSessionKey: "agent:finance:bound", + targetKind: "session" as const, + conversation, + status: "active" as const, + boundAt: 1, + })); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation, + touch, + }); + + expect( + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "direct", peerId: "user-1", nativeChannelId: "dm-1" }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + expect(resolveByConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "user:user-1" }), + ); + expect(touch).not.toHaveBeenCalled(); + }); + + it.each([ + { kind: "group" as const, peerId: "group-dm-1" }, + { kind: "channel" as const, peerId: "channel-1" }, + ])("uses the native channel runtime identity for $kind conversations", ({ kind, peerId }) => { + const resolveByConversation = vi.fn(() => null); + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation, + }); + + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind, peerId, nativeChannelId: peerId }, + }); + + expect(resolveByConversation).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: peerId }), + ); + }); + + it("reports temporary adapter unavailability only while bindings are enabled", () => { + unregisterSessionBindingAdapter({ channel: "discord", accountId: "default", adapter }); + const conversation = { kind: "channel" as const, peerId: "channel-1" }; + + expect( + inspectDiscordConversationRouteOwner({ cfg: {}, accountId: "default", conversation }), + ).toEqual({ kind: "unavailable" }); + expect( + inspectDiscordConversationRouteOwner({ + cfg: { channels: { discord: { threadBindings: { enabled: false } } } }, + accountId: "default", + conversation, + }), + ).toEqual({ kind: "agent", agentId: "main" }); + }); + + it("preserves explicit plugin ownership independently of the target session key", () => { + registerSessionBindingAdapter({ + channel: "discord", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-plugin", + targetSessionKey: "agent:review:looks-owned", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + metadata: { + pluginBindingOwner: "plugin", + pluginId: "review-plugin", + pluginRoot: "/plugins/review", + }, + }), + }); + + expect( + inspectDiscordConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "channel-1" }, + }), + ).toEqual({ kind: "plugin", pluginId: "review-plugin", fallbackAgentId: "main" }); + }); +}); diff --git a/extensions/discord/src/conversation-route-owner.ts b/extensions/discord/src/conversation-route-owner.ts new file mode 100644 index 000000000000..1772987b465b --- /dev/null +++ b/extensions/discord/src/conversation-route-owner.ts @@ -0,0 +1,72 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { resolveThreadBindingSpawnPolicy } from "openclaw/plugin-sdk/conversation-runtime"; +import { resolveDiscordRuntimeBindingConversationId } from "./conversation-identity.js"; +import { resolveDiscordConversationBindingRoute } from "./monitor/conversation-binding-route.js"; +import { resolveDiscordConversationRoute } from "./monitor/route-resolution.js"; + +export function inspectDiscordConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + threadId?: string; + nativeChannelId?: string; + context?: { + parentPeerId?: string; + guildId?: string; + memberRoleIds?: string[]; + }; + }; +}) { + const direct = params.conversation.kind === "direct"; + const nativeConversationId = params.conversation.nativeChannelId ?? params.conversation.peerId; + const threadConversationId = direct ? undefined : params.conversation.threadId; + const runtimeConversationId = + threadConversationId ?? + resolveDiscordRuntimeBindingConversationId({ + isDirectMessage: direct, + isGroupDm: params.conversation.kind === "group", + userId: direct ? params.conversation.peerId : undefined, + channelId: nativeConversationId, + }); + const route = resolveDiscordConversationRoute({ + cfg: params.cfg, + accountId: params.accountId, + guildId: params.conversation.context?.guildId, + memberRoleIds: params.conversation.context?.memberRoleIds, + peer: { kind: params.conversation.kind, id: params.conversation.peerId }, + parentConversationId: params.conversation.context?.parentPeerId, + }); + const { runtimeRoute, configuredRoute } = resolveDiscordConversationBindingRoute({ + cfg: params.cfg, + route, + accountId: params.accountId, + runtimeConversationId, + configuredConversationId: threadConversationId ?? nativeConversationId, + parentConversationId: params.conversation.context?.parentPeerId, + touchBinding: false, + }); + if ( + !runtimeRoute.bindingOwnerAvailable && + resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel: "discord", + accountId: params.accountId, + kind: "subagent", + }).enabled + ) { + return { kind: "unavailable" as const }; + } + if (runtimeRoute.pluginId) { + return { + kind: "plugin" as const, + pluginId: runtimeRoute.pluginId, + fallbackAgentId: route.agentId, + }; + } + return { + kind: "agent" as const, + agentId: runtimeRoute.boundAgentId ?? configuredRoute?.boundAgentId ?? route.agentId, + }; +} diff --git a/extensions/discord/src/monitor/agent-components.dispatch.ts b/extensions/discord/src/monitor/agent-components.dispatch.ts index 069ab3e823ea..96124be017f1 100644 --- a/extensions/discord/src/monitor/agent-components.dispatch.ts +++ b/extensions/discord/src/monitor/agent-components.dispatch.ts @@ -36,6 +36,7 @@ import { } from "./inbound-context.js"; import { buildDirectLabel, buildGuildLabel } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; +import { buildDiscordConversationRouteContext } from "./route-resolution.js"; const loadConversationRuntime = createLazyRuntimeModule( () => import("./agent-components.runtime.js"), @@ -202,6 +203,14 @@ export async function dispatchDiscordComponentEvent(params: { SessionKey: sessionKey, AccountId: accountId, ChatType: chatType, + ...buildDiscordConversationRouteContext({ + isDirectMessage: interactionCtx.isDirectMessage, + isGroupDm: interactionCtx.isGroupDm, + directUserId: interactionCtx.userId, + conversationId: interactionCtx.channelId, + isThread: channelCtx.isThread, + parentConversationId: channelCtx.parentId, + }), ConversationLabel: fromLabel, SenderName: senderName, SenderId: interactionCtx.userId, diff --git a/extensions/discord/src/monitor/conversation-binding-route.ts b/extensions/discord/src/monitor/conversation-binding-route.ts new file mode 100644 index 000000000000..bffc72693013 --- /dev/null +++ b/extensions/discord/src/monitor/conversation-binding-route.ts @@ -0,0 +1,53 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + resolveConfiguredBindingRoute, + resolveRuntimeConversationBindingRoute, +} from "openclaw/plugin-sdk/conversation-binding-runtime"; +import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { shouldIgnoreStaleDiscordRouteBinding } from "./route-resolution.js"; + +export function resolveDiscordConversationBindingRoute(params: { + cfg: OpenClawConfig; + route: ResolvedAgentRoute; + accountId: string; + runtimeConversationId: string; + configuredConversationId: string; + parentConversationId?: string; + touchBinding?: boolean; +}) { + let runtimeRoute = resolveRuntimeConversationBindingRoute({ + route: params.route, + touchBinding: params.touchBinding, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: params.runtimeConversationId, + parentConversationId: params.parentConversationId, + }, + }); + if ( + shouldIgnoreStaleDiscordRouteBinding({ + bindingRecord: runtimeRoute.bindingRecord, + route: params.route, + }) + ) { + logVerbose( + `discord: ignoring stale route binding for conversation ${params.runtimeConversationId} (${runtimeRoute.bindingRecord?.targetSessionKey} -> ${params.route.sessionKey})`, + ); + runtimeRoute = { bindingOwnerAvailable: true, bindingRecord: null, route: params.route }; + } + const configuredRoute = runtimeRoute.bindingRecord + ? null + : resolveConfiguredBindingRoute({ + cfg: params.cfg, + route: params.route, + conversation: { + channel: "discord", + accountId: params.accountId, + conversationId: params.configuredConversationId, + parentConversationId: params.parentConversationId, + }, + }); + return { runtimeRoute, configuredRoute }; +} diff --git a/extensions/discord/src/monitor/message-handler.context.test.ts b/extensions/discord/src/monitor/message-handler.context.test.ts index c8444f52572b..885d9ced50b8 100644 --- a/extensions/discord/src/monitor/message-handler.context.test.ts +++ b/extensions/discord/src/monitor/message-handler.context.test.ts @@ -31,6 +31,7 @@ describe("discord buildDiscordMessageProcessContext sender bot status", () => { } expect(result.ctxPayload.NativeChannelId).toBe(ctx.messageChannelId); + expect(result.ctxPayload.ConversationRoutePeerId).toBe(ctx.messageChannelId); }); it("projects a cached conversation avatar into channel-owned context", async () => { @@ -55,6 +56,22 @@ describe("discord buildDiscordMessageProcessContext sender bot status", () => { expect(result?.ctxPayload.GroupSpace).toBe("guild-id"); }); + it("records the source channel as the parent of an auto-threaded turn", async () => { + const ctx = await createBaseDiscordMessageContext({ + channelConfig: { allowed: true, autoThread: true }, + client: { + rest: { + get: async () => ({ thread: { id: "auto-thread-1" } }), + }, + }, + }); + + const result = await buildDiscordMessageProcessContext({ ctx, text: "hi", mediaList: [] }); + + expect(result?.ctxPayload.MessageThreadId).toBe("auto-thread-1"); + expect(result?.ctxPayload.ThreadParentId).toBe("c1"); + }); + it("forwards bot author status to ctxPayload.SenderIsBot", async () => { const ctx = await createBaseDiscordMessageContext({ author: { id: "U1", username: "alice", discriminator: "0", globalName: "Alice", bot: true }, diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index ff2d04d95de7..e5545c32a357 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -43,6 +43,7 @@ import { type DiscordMediaInfo, } from "./message-utils.js"; import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js"; +import { buildDiscordRoutePeer } from "./route-resolution.js"; import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js"; import { DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS, @@ -88,6 +89,7 @@ export async function buildDiscordMessageProcessContext(params: { messageChannelId, isGuildMessage, isDirectMessage, + isGroupDm, baseText, preflightAudioTranscript, threadChannel, @@ -330,6 +332,11 @@ export async function buildDiscordMessageProcessContext(params: { const replyTarget = replyPlan.replyTarget; const replyReference = replyPlan.replyReference; const autoThreadContext = replyPlan.autoThreadContext; + const conversationParentId = threadChannel + ? threadParentId + : autoThreadContext + ? messageChannelId + : undefined; const effectiveFrom = isDirectMessage ? `discord:${author.id}` @@ -372,7 +379,7 @@ export async function buildDiscordMessageProcessContext(params: { inboundEventKind: ctx.inboundEventKind, }, { - parentId: threadChannel ? threadParentId : undefined, + parentId: conversationParentId, threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, }, ); @@ -398,15 +405,21 @@ export async function buildDiscordMessageProcessContext(params: { isBot: author.bot && !sender.isPluralKit ? true : undefined, }, conversation: { - kind: isDirectMessage ? "direct" : "channel", + kind: isGroupDm ? "group" : isDirectMessage ? "direct" : "channel", id: messageChannelId, + routePeer: buildDiscordRoutePeer({ + isDirectMessage, + isGroupDm, + directUserId: author.id, + conversationId: messageChannelId, + }), nativeChannelId: messageChannelId, avatar: ctx.conversationAvatar, label: fromLabel, spaceId: isGuildMessage ? (guildInfo?.id ?? data.guild?.id ?? data.guild_id ?? guildSlug) || undefined : undefined, - parentId: threadChannel ? threadParentId : undefined, + parentId: conversationParentId, threadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined, }, route: { diff --git a/extensions/discord/src/monitor/message-handler.routing-preflight.ts b/extensions/discord/src/monitor/message-handler.routing-preflight.ts index 858c82939f7b..9a26346f0f49 100644 --- a/extensions/discord/src/monitor/message-handler.routing-preflight.ts +++ b/extensions/discord/src/monitor/message-handler.routing-preflight.ts @@ -1,14 +1,13 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; // Discord plugin module implements message handler.routing preflight behavior. -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; +import { resolveDiscordRuntimeBindingConversationId } from "../conversation-identity.js"; import type { User } from "../internal/discord.js"; +import { resolveDiscordConversationBindingRoute } from "./conversation-binding-route.js"; import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js"; import { buildDiscordRoutePeer, resolveDiscordConversationRoute, resolveDiscordEffectiveRoute, - shouldIgnoreStaleDiscordRouteBinding, } from "./route-resolution.js"; const loadConversationRuntime = createLazyRuntimeModule( @@ -38,49 +37,21 @@ export async function resolveDiscordPreflightRoute(params: { }), parentConversationId: params.earlyThreadParentId, }); - const bindingConversationId = params.isDirectMessage - ? (resolveDiscordConversationIdentity({ - isDirectMessage: true, - userId: params.author.id, - }) ?? `user:${params.author.id}`) - : params.messageChannelId; - let runtimeRoute = conversationRuntime.resolveRuntimeConversationBindingRoute({ - route, - conversation: { - channel: "discord", - accountId: params.preflight.accountId, - conversationId: bindingConversationId, - parentConversationId: params.earlyThreadParentId, - }, + const bindingConversationId = resolveDiscordRuntimeBindingConversationId({ + isDirectMessage: params.isDirectMessage, + isGroupDm: params.isGroupDm, + userId: params.author.id, + channelId: params.messageChannelId, + }); + const { runtimeRoute, configuredRoute } = resolveDiscordConversationBindingRoute({ + cfg: params.preflight.cfg, + route, + accountId: params.preflight.accountId, + runtimeConversationId: bindingConversationId, + configuredConversationId: params.messageChannelId, + parentConversationId: params.earlyThreadParentId, }); - if ( - shouldIgnoreStaleDiscordRouteBinding({ - bindingRecord: runtimeRoute.bindingRecord, - route, - }) - ) { - logVerbose( - `discord: ignoring stale route binding for conversation ${bindingConversationId} (${runtimeRoute.bindingRecord?.targetSessionKey} -> ${route.sessionKey})`, - ); - runtimeRoute = { - bindingRecord: null, - route, - }; - } let threadBinding = runtimeRoute.bindingRecord ?? undefined; - const configuredRoute = - threadBinding == null - ? conversationRuntime.resolveConfiguredBindingRoute({ - cfg: params.preflight.cfg, - route, - conversation: { - channel: "discord", - accountId: params.preflight.accountId, - conversationId: params.messageChannelId, - parentConversationId: params.earlyThreadParentId, - }, - }) - : null; const configuredBinding = configuredRoute?.bindingResolution ?? null; if (!threadBinding && configuredBinding) { threadBinding = configuredBinding.record; diff --git a/extensions/discord/src/monitor/native-command-context.test.ts b/extensions/discord/src/monitor/native-command-context.test.ts index 2afdca5d8568..41785eef85ca 100644 --- a/extensions/discord/src/monitor/native-command-context.test.ts +++ b/extensions/discord/src/monitor/native-command-context.test.ts @@ -35,6 +35,10 @@ describe("buildDiscordNativeCommandContext", () => { expect(ctx.ConversationLabel).toBe("Tester"); expect(ctx.SessionKey).toBe("agent:codex:discord:slash:user-1"); expect(ctx.CommandTargetSessionKey).toBe("agent:codex:discord:direct:user-1"); + expect(ctx.ConversationRouteContextObserved).toBe(true); + expect(ctx.ConversationRoutePeerId).toBe("user-1"); + expect(ctx.NativeChannelId).toBe("dm-1"); + expect(ctx.InboundAccessAuthorized).toBe(true); expect(ctx.OriginatingTo).toBe("user:user-1"); expect(ctx.ChannelPromptContext).toBeUndefined(); expect(ctx.ChannelStructuredContext).toBeUndefined(); @@ -87,6 +91,10 @@ describe("buildDiscordNativeCommandContext", () => { expect(ctx.GroupSubject).toBe("Ops"); expect(ctx.GroupSpace).toBe("guild-1"); expect(ctx.MemberRoleIds).toEqual(["admin"]); + expect(ctx.ConversationRouteContextObserved).toBe(true); + expect(ctx.ConversationRoutePeerId).toBe("chan-1"); + expect(ctx.NativeChannelId).toBe("chan-1"); + expect(ctx.InboundAccessAuthorized).toBe(true); expect(ctx.GroupSystemPrompt).toBe("Use the runbook."); expect(ctx.OwnerAllowFrom).toEqual(["user-1"]); expect(ctx.MessageThreadId).toBe("chan-1"); diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts index 527147f7afb4..52099d4549a6 100644 --- a/extensions/discord/src/monitor/native-command-context.ts +++ b/extensions/discord/src/monitor/native-command-context.ts @@ -4,6 +4,7 @@ import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-dispatch-runti import { resolveDiscordConversationIdentity } from "../conversation-identity.js"; import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js"; import { buildDiscordInboundAccessContext } from "./inbound-context.js"; +import { buildDiscordConversationRouteContext } from "./route-resolution.js"; type BuildDiscordNativeCommandContextParams = { prompt: string; @@ -69,6 +70,14 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma CommandTargetSessionKey: params.commandTargetSessionKey, AccountId: params.accountId ?? undefined, ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", + ...buildDiscordConversationRouteContext({ + isDirectMessage: params.isDirectMessage, + isGroupDm: params.isGroupDm, + directUserId: params.user.id, + conversationId: params.channelId, + isThread: params.isThreadChannel, + parentConversationId: params.threadParentId, + }), ConversationLabel: conversationLabel, GroupSubject: params.isGuild ? params.guildName : undefined, GroupSpace: params.isGuild @@ -86,7 +95,6 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma Surface: "discord" as const, WasMentioned: true, MessageSid: params.interactionId, - MessageThreadId: params.isThreadChannel ? params.channelId : undefined, Timestamp: params.timestampMs ?? Date.now(), CommandAuthorized: params.commandAuthorized, CommandTurn: { @@ -106,6 +114,5 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma userId: params.user.id, channelId: params.channelId, }) ?? (params.isDirectMessage ? `user:${params.user.id}` : `channel:${params.channelId}`), - ThreadParentId: params.isThreadChannel ? params.threadParentId : undefined, }); } diff --git a/extensions/discord/src/monitor/route-resolution.test.ts b/extensions/discord/src/monitor/route-resolution.test.ts index 9612a10f6622..9300df98d987 100644 --- a/extensions/discord/src/monitor/route-resolution.test.ts +++ b/extensions/discord/src/monitor/route-resolution.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing"; import { describe, expect, it } from "vitest"; import { + buildDiscordConversationRouteContext, buildDiscordRoutePeer, resolveDiscordBoundConversationRoute, resolveDiscordConversationRoute, @@ -46,6 +47,53 @@ describe("discord route resolution helpers", () => { }); }); + it("keeps a group DM keyed by its conversation instead of one sender", () => { + expect( + buildDiscordRoutePeer({ + isDirectMessage: true, + isGroupDm: true, + directUserId: "user-1", + conversationId: "group-dm-1", + }), + ).toEqual({ kind: "group", id: "group-dm-1" }); + }); + + it("records the direct routing peer separately from the native DM channel", () => { + expect( + buildDiscordConversationRouteContext({ + isDirectMessage: true, + isGroupDm: false, + directUserId: "user-1", + conversationId: "dm-1", + isThread: false, + }), + ).toEqual({ + ConversationRouteContextObserved: true, + ConversationRoutePeerId: "user-1", + NativeChannelId: "dm-1", + InboundAccessAuthorized: true, + MessageThreadId: undefined, + ThreadParentId: undefined, + }); + }); + + it("records a thread and its routing parent", () => { + expect( + buildDiscordConversationRouteContext({ + isDirectMessage: false, + isGroupDm: false, + conversationId: "thread-1", + isThread: true, + parentConversationId: "parent-1", + }), + ).toMatchObject({ + ConversationRoutePeerId: "thread-1", + NativeChannelId: "thread-1", + MessageThreadId: "thread-1", + ThreadParentId: "parent-1", + }); + }); + it("resolves bound session keys on top of the routed session", () => { const route: ResolvedAgentRoute = { agentId: "main", diff --git a/extensions/discord/src/monitor/route-resolution.ts b/extensions/discord/src/monitor/route-resolution.ts index 3556754a3f7e..110a8d8b6cfd 100644 --- a/extensions/discord/src/monitor/route-resolution.ts +++ b/extensions/discord/src/monitor/route-resolution.ts @@ -19,10 +19,29 @@ export function buildDiscordRoutePeer(params: { conversationId: string; }): RoutePeer { return { - kind: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", - id: params.isDirectMessage - ? params.directUserId?.trim() || params.conversationId - : params.conversationId, + kind: params.isGroupDm ? "group" : params.isDirectMessage ? "direct" : "channel", + id: + params.isDirectMessage && !params.isGroupDm + ? params.directUserId?.trim() || params.conversationId + : params.conversationId, + }; +} + +export function buildDiscordConversationRouteContext(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + directUserId?: string | null; + conversationId: string; + isThread: boolean; + parentConversationId?: string; +}) { + return { + ConversationRouteContextObserved: true as const, + ConversationRoutePeerId: buildDiscordRoutePeer(params).id, + NativeChannelId: params.conversationId, + InboundAccessAuthorized: true as const, + MessageThreadId: params.isThread ? params.conversationId : undefined, + ThreadParentId: params.isThread ? params.parentConversationId : undefined, }; } diff --git a/extensions/discord/src/send.components.ts b/extensions/discord/src/send.components.ts index 83ac25d54c7e..445af5f91c07 100644 --- a/extensions/discord/src/send.components.ts +++ b/extensions/discord/src/send.components.ts @@ -34,7 +34,6 @@ import { createDiscordMessageNonce, resolveChannelId, resolveDiscordChannel, - stripUndefinedFields, SUPPRESS_NOTIFICATIONS_FLAG, type DiscordAllowedMentions, } from "./send.shared.js"; @@ -201,10 +200,7 @@ async function buildDiscordComponentPayload(params: { spec: DiscordComponentMessageSpec; opts: DiscordComponentSendOpts; accountId: string; -}): Promise<{ - body: ReturnType; - buildResult: ReturnType; -}> { +}) { const messageReference = params.opts.reply ? { message_id: params.opts.reply.messageId, fail_if_not_exists: false } : undefined; @@ -260,10 +256,10 @@ async function buildDiscordComponentPayload(params: { ...(finalFlags ? { flags: finalFlags } : {}), ...(files ? { files } : {}), }; - const body = stripUndefinedFields({ + const body = { ...serializePayload(payload), ...(messageReference ? { message_reference: messageReference } : {}), - }); + }; return { body, buildResult }; } diff --git a/extensions/discord/src/send.message-request.ts b/extensions/discord/src/send.message-request.ts index 6db1dccb80be..513db7ed69a0 100644 --- a/extensions/discord/src/send.message-request.ts +++ b/extensions/discord/src/send.message-request.ts @@ -8,9 +8,8 @@ import { type MessagePayloadObject, type TopLevelComponents, } from "./internal/discord.js"; -import { stripUndefinedFields } from "./internal/undefined-fields.js"; -export { stripUndefinedFields }; +export { stripUndefinedFields } from "./internal/undefined-fields.js"; const SUPPRESS_EMBEDS_FLAG = MessageFlags.SuppressEmbeds; export const SUPPRESS_NOTIFICATIONS_FLAG = MessageFlags.SuppressNotifications; @@ -123,14 +122,14 @@ export function buildDiscordMessageRequest(params: DiscordMessageRequestParams) params.endpoint === "create-message" ? (params.nonce ?? createDiscordMessageNonce()) : undefined; - return stripUndefinedFields({ + return { ...serializePayload(payload), ...(params.replyTo ? { message_reference: { message_id: params.replyTo, fail_if_not_exists: false } } : {}), - nonce, - enforce_nonce: nonce ? true : undefined, - }); + ...(nonce !== undefined ? { nonce } : {}), + ...(nonce ? { enforce_nonce: true } : {}), + }; } function hasV2Components(components?: TopLevelComponents[]): boolean { diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 914b0070b9c0..f6da4ff6c4f3 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -598,6 +598,9 @@ describe("handleFeishuMessage ACP routing", () => { expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1); expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ ConversationRoutePeerId: "ou_sender_1" }), + ); }); it("surfaces configured ACP initialization failures to the Feishu conversation", async () => { @@ -687,6 +690,12 @@ describe("handleFeishuMessage ACP routing", () => { expect(conversationRef.channel).toBe("feishu"); expect(conversationRef.conversationId).toBe("oc_group_chat:topic:om_topic_root"); expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root"); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + ConversationRoutePeerId: "oc_group_chat:topic:om_topic_root", + ThreadParentId: "oc_group_chat", + }), + ); }); it("records Feishu DM last-route updates on the resolved session", async () => { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index ffe53aee5c79..c139f19935c4 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1424,7 +1424,9 @@ export async function handleFeishuMessage(params: { conversation: { kind: isGroup ? "group" : "direct", id: ctx.chatId, + routePeer: { kind: isGroup ? "group" : "direct", id: peerId }, nativeChannelId: ctx.chatId, + parentId: parentPeer?.id, label: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined, threadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined, }, diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 99267b707e73..8f3c121ccc22 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -993,6 +993,7 @@ export const feishuPlugin: ChannelPlugin buildFeishuModelOverrideParentCandidates(parentConversationId), diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index 7c6f00eb4d65..39b1e2ede421 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -6,6 +6,7 @@ import { gzipSync } from "node:zlib"; import { expectDefined } from "@openclaw/normalization-core"; import { toErrorObject as toLintErrorObject } from "openclaw/plugin-sdk/error-runtime"; import type { Model, ProviderContext } from "openclaw/plugin-sdk/llm"; +import { withProviderAcceptanceObserver } from "openclaw/plugin-sdk/provider-transport-runtime"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resetGoogleVertexAdcState } from "./google-oauth.test-support.js"; @@ -630,6 +631,53 @@ describe("google transport stream", () => { expect(guardedFetchMock).not.toHaveBeenCalled(); }); + it("reports the real HTTP response before consuming Gemini SSE output", async () => { + mockGoogleTextResponse(); + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver); + + const result = await runGeminiStreamResult({ options }); + + expect(result.stopReason).toBe("stop"); + expect(acceptanceObserver).toHaveBeenCalledWith({ + kind: "http_response", + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + expect(onResponse).toHaveBeenCalledWith( + { status: 200, headers: { "content-type": "text/event-stream" } }, + expect.objectContaining({ provider: "google" }), + ); + }); + + it("reports rejected HTTP responses without marking them accepted", async () => { + guardedFetchMock.mockResolvedValueOnce( + new Response('{"error":{"message":"rate limited"}}', { + status: 429, + headers: { + "content-type": "application/json", + "x-request-id": "req-rejected", + }, + }), + ); + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver); + + const result = await runGeminiStreamResult({ options }); + + expect(result.stopReason).toBe("error"); + expect(acceptanceObserver).not.toHaveBeenCalled(); + expect(onResponse).toHaveBeenCalledWith( + { + status: 429, + headers: expect.objectContaining({ "x-request-id": "req-rejected" }), + }, + expect.objectContaining({ provider: "google" }), + ); + }); + it("uses the guarded fetch transport and parses Gemini SSE output", async () => { guardedFetchMock.mockResolvedValueOnce( buildSseResponse([ @@ -1419,6 +1467,124 @@ describe("google transport stream", () => { }, ); + it("does not retry when provider acceptance observation fails", async () => { + vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10"); + let cancelCalled = false; + guardedFetchMock.mockResolvedValueOnce( + buildOpenRawSseResponse({ + sse: 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n', + onCancel: () => { + cancelCalled = true; + }, + }), + ); + + const options = withProviderAcceptanceObserver({ reasoning: "high" }, () => { + throw new Error("acceptance observer failed"); + }); + const result = await runGeminiStreamResult({ + model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }), + options, + }); + + expect(result).toMatchObject({ + stopReason: "error", + errorMessage: "acceptance observer failed", + }); + expect(guardedFetchMock).toHaveBeenCalledOnce(); + expect(cancelCalled).toBe(true); + }); + + it("aborts a pending response callback without retrying", async () => { + vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "1000"); + const controller = new AbortController(); + const cancel = vi.fn(); + guardedFetchMock.mockResolvedValueOnce( + buildOpenRawSseResponse({ + sse: 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n', + onCancel: cancel, + }), + ); + let markHookStarted!: () => void; + const hookStarted = new Promise((resolve) => { + markHookStarted = resolve; + }); + const onResponse = vi.fn(() => { + markHookStarted(); + return new Promise(() => {}); + }); + const options = { + reasoning: "high", + signal: controller.signal, + onResponse, + }; + const resultPromise = runGeminiStreamResult({ + model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }), + options, + }); + await hookStarted; + controller.abort( + Object.assign(new Error("operator canceled the request"), { + code: "OPERATOR_CANCELLED", + }), + ); + + await expect(resultPromise).resolves.toMatchObject({ + stopReason: "aborted", + errorCode: "OPERATOR_CANCELLED", + errorMessage: "operator canceled the request", + }); + expect(onResponse).toHaveBeenCalledOnce(); + expect(guardedFetchMock).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("retries when a pending response callback reaches the Gemini first-response deadline", async () => { + vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10"); + const controller = new AbortController(); + const cancel = vi.fn(); + guardedFetchMock + .mockResolvedValueOnce( + buildOpenRawSseResponse({ + sse: 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n', + onCancel: cancel, + }), + ) + .mockResolvedValueOnce( + buildSseResponse([ + { + candidates: [{ content: { parts: [{ text: "recovered" }] }, finishReason: "STOP" }], + }, + ]), + ); + let responseCount = 0; + const onResponse = vi.fn(() => { + responseCount += 1; + return responseCount === 1 ? new Promise(() => {}) : undefined; + }); + const safetyTimeout = setTimeout(() => { + controller.abort(new Error("test safety deadline reached")); + }, 5000); + + try { + const result = await runGeminiStreamResult({ + model: buildGeminiModel({ id: "gemini-3.1-pro-preview" }), + options: { + reasoning: "high", + signal: controller.signal, + onResponse, + }, + }); + + expect(result.content).toEqual([{ type: "text", text: "recovered" }]); + expect(onResponse).toHaveBeenCalledTimes(2); + expect(guardedFetchMock).toHaveBeenCalledTimes(2); + expect(cancel).toHaveBeenCalledOnce(); + } finally { + clearTimeout(safetyTimeout); + } + }); + it("keeps oversized-video shedding in the Gemini 3 retry payload", async () => { vi.stubEnv("OPENCLAW_GOOGLE_GEMINI_FIRST_RESPONSE_RETRY_MS", "10"); guardedFetchMock diff --git a/extensions/google/transport-stream.ts b/extensions/google/transport-stream.ts index 84499320a02a..62019047d947 100644 --- a/extensions/google/transport-stream.ts +++ b/extensions/google/transport-stream.ts @@ -34,6 +34,7 @@ import { failTransportStream, finalizeTransportStream, mergeTransportHeaders, + notifyProviderHttpResponse, sanitizeTransportPayloadText, sortPromptCacheToolsByName, stripSystemPromptCacheBoundary, @@ -1052,9 +1053,10 @@ function createChildSignal(parent: AbortSignal | undefined, timeoutMs: number) { parent.addEventListener("abort", abortFromParent, { once: true }); } } - if (timeoutMs > 0) { + if (!controller.signal.aborted && timeoutMs > 0) { timeout = setTimeout(() => { timedOut = true; + timeout = undefined; controller.abort(new Error("Google Gemini first response retry deadline reached")); }, timeoutMs); timeout.unref?.(); @@ -1104,6 +1106,20 @@ type GoogleSseAttempt = } | { type: "timeout" }; +async function notifyGoogleTransportHttpResponse( + model: GoogleTransportModel, + options: GoogleTransportOptions | undefined, + response: Response, + signal?: AbortSignal, +): Promise { + await notifyProviderHttpResponse({ + options, + response, + model: canonicalGoogleModel(model), + signal, + }); +} + async function openGoogleSseAttempt(params: { guardedFetch: ReturnType; url: string; @@ -1113,44 +1129,63 @@ async function openGoogleSseAttempt(params: { parentSignal?: AbortSignal; firstResponseTimeoutMs: number; errorPrefix: string; + model: GoogleTransportModel; + options: GoogleTransportOptions | undefined; }): Promise { const attemptSignal = params.firstResponseTimeoutMs > 0 ? createChildSignal(params.parentSignal, params.firstResponseTimeoutMs) : undefined; const signal = attemptSignal?.signal ?? params.parentSignal; - try { - const response = await params.guardedFetch(params.url, { - method: "POST", - headers: params.headers, - body: serializeGoogleRequest(params.request, params.videoSlots), - signal, - }); - if (!response.ok) { - throw await createProviderHttpError(response, params.errorPrefix); - } - const chunks = parseGoogleSseChunks(response, signal); - const iterator = chunks[Symbol.asyncIterator](); - const first = await iterator.next(); - attemptSignal?.clearDeadline(); - if (first.done) { - return { - type: "ready", - chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup), - }; - } - return { - type: "ready", - firstChunk: first.value, - chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup), - }; - } catch (error) { + const handleTimedOperationError = (error: unknown): GoogleSseAttempt => { attemptSignal?.cleanup(); if (attemptSignal?.timedOut() && !params.parentSignal?.aborted) { return { type: "timeout" }; } throw error; + }; + let response: Response; + try { + response = await params.guardedFetch(params.url, { + method: "POST", + headers: params.headers, + body: serializeGoogleRequest(params.request, params.videoSlots), + signal, + }); + } catch (error) { + return handleTimedOperationError(error); } + try { + // Response hooks share the first-response deadline. A stalled hook must cancel + // the unread body and enter the same Gemini fallback as a stalled fetch or body. + await notifyGoogleTransportHttpResponse(params.model, params.options, response, signal); + } catch (error) { + return handleTimedOperationError(error); + } + if (!response.ok) { + attemptSignal?.cleanup(); + throw await createProviderHttpError(response, params.errorPrefix); + } + const chunks = parseGoogleSseChunks(response, signal); + const iterator = chunks[Symbol.asyncIterator](); + let first: IteratorResult; + try { + first = await iterator.next(); + } catch (error) { + return handleTimedOperationError(error); + } + attemptSignal?.clearDeadline(); + if (first.done) { + return { + type: "ready", + chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup), + }; + } + return { + type: "ready", + firstChunk: first.value, + chunks: iteratorToAsyncGenerator(iterator, attemptSignal?.cleanup), + }; } async function openGoogleSseChunks(params: { @@ -1174,6 +1209,12 @@ async function openGoogleSseChunks(params: { body: serializeGoogleRequest(params.request, params.videoSlots), signal: params.options?.signal, }); + await notifyGoogleTransportHttpResponse( + params.model, + params.options, + response, + params.options?.signal, + ); if (!response.ok) { throw await createProviderHttpError(response, errorPrefix); } @@ -1191,6 +1232,12 @@ async function openGoogleSseChunks(params: { body: serializeGoogleRequest(params.request, params.videoSlots), signal: params.options?.signal, }); + await notifyGoogleTransportHttpResponse( + params.model, + params.options, + response, + params.options?.signal, + ); if (!response.ok) { throw await createProviderHttpError(response, errorPrefix); } @@ -1209,6 +1256,8 @@ async function openGoogleSseChunks(params: { parentSignal: params.options?.signal, firstResponseTimeoutMs: retryMs, errorPrefix, + model: params.model, + options: params.options, }); if (firstAttempt.type === "ready") { return firstAttempt; @@ -1229,6 +1278,8 @@ async function openGoogleSseChunks(params: { parentSignal: params.options?.signal, firstResponseTimeoutMs: 0, errorPrefix, + model: params.model, + options: params.options, }); if (retryAttempt.type === "timeout") { throw new Error("Google Gemini first response retry timed out unexpectedly"); diff --git a/extensions/googlechat/src/format.test.ts b/extensions/googlechat/src/format.test.ts index d2fd5fc2fba1..325ba3eefd80 100644 --- a/extensions/googlechat/src/format.test.ts +++ b/extensions/googlechat/src/format.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { formatGoogleChatTextChunks, GOOGLE_CHAT_FORMAT_PROFILE } from "./format.js"; const formatGoogleChatText = (text: string) => formatGoogleChatTextChunks(text).join(""); @@ -104,6 +104,26 @@ describe("formatGoogleChatText", () => { expect(formatGoogleChatText(" - top-level")).toBe("* top-level"); }); + it("keeps dense bullet-list formatting work bounded", () => { + const input = Array.from({ length: 1_000 }, (_, index) => `- row ${index}`).join("\n"); + const expected = input.replace(/^- /gmu, "* "); + const sliceSpy = vi.spyOn(String.prototype, "slice"); + let fullMessagePrefixSlices = 0; + try { + expect(formatGoogleChatText(input)).toBe(expected); + fullMessagePrefixSlices = sliceSpy.mock.calls.reduce((count, [start, end], index) => { + const source = sliceSpy.mock.contexts[index]; + return String(source).length === input.length && start === 0 && end !== undefined + ? count + 1 + : count; + }, 0); + } finally { + sliceSpy.mockRestore(); + } + + expect(fullMessagePrefixSlices).toBeLessThan(50); + }); + it("neutralizes nested markup inside native link labels", () => { expect(formatGoogleChatText("[x > y](https://example.com)")).toBe( "", diff --git a/extensions/googlechat/src/format.ts b/extensions/googlechat/src/format.ts index b7af92228549..cde1e4eee0c5 100644 --- a/extensions/googlechat/src/format.ts +++ b/extensions/googlechat/src/format.ts @@ -113,15 +113,23 @@ function projectGoogleChatLinkLabels(ir: MarkdownIR): MarkdownIR { } function markGoogleChatBulletLists(ir: MarkdownIR, markerToken: string): MarkdownIR { - let text = ir.text; + let characters: string[] | undefined; for (const item of ir.listItems ?? []) { const marker = item.listMarker; - if (item.kind !== "bullet" || !marker || text.slice(marker.start, marker.end) !== "• ") { + if ( + item.kind !== "bullet" || + !marker || + marker.end !== marker.start + 2 || + ir.text[marker.start] !== "•" || + ir.text[marker.start + 1] !== " " + ) { continue; } - text = `${text.slice(0, marker.start)}${markerToken}${text.slice(marker.end)}`; + characters ??= ir.text.split(""); + characters[marker.start] = markerToken[0] ?? ""; + characters[marker.start + 1] = markerToken[1] ?? ""; } - return text === ir.text ? ir : { ...ir, text }; + return characters ? { ...ir, text: characters.join("") } : ir; } function projectUnsafeCodeFallbacks(ir: MarkdownIR): MarkdownIR { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 121587c03072..897e582c784a 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -318,6 +318,7 @@ export const imessagePlugin: ChannelPlugin createIMessageConversationBindingManager({ cfg, diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts index d6f2c15a8e0b..5ff0f14e1c9f 100644 --- a/extensions/imessage/src/monitor/inbound-processing.test.ts +++ b/extensions/imessage/src/monitor/inbound-processing.test.ts @@ -900,6 +900,7 @@ describe("resolveIMessageInboundDecision command auth", () => { }); expect(ctxPayload.CommandAuthorized).toBe(true); + expect(ctxPayload.ConversationRoutePeerId).toBe("+15555550123"); expect(ctxPayload.CommandSource).toBe("text"); expect(ctxPayload.CommandTurn).toMatchObject({ kind: "text-slash", diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts index 984c847171c8..77ed7d280056 100644 --- a/extensions/imessage/src/monitor/inbound-processing.ts +++ b/extensions/imessage/src/monitor/inbound-processing.ts @@ -1039,6 +1039,14 @@ export async function buildIMessageInboundContext(params: { conversation: { kind: decision.isGroup ? "group" : "direct", id: chatId != null ? String(chatId) : decision.sender, + ...(decision.isGroup && chatId == null + ? {} + : { + routePeer: { + kind: decision.isGroup ? ("group" as const) : ("direct" as const), + id: decision.isGroup ? String(chatId) : decision.senderNormalized, + }, + }), label: fromLabel, }, route: { diff --git a/extensions/llama-cpp/index.test.ts b/extensions/llama-cpp/index.test.ts index 032401eb003b..3d9dac91f60c 100644 --- a/extensions/llama-cpp/index.test.ts +++ b/extensions/llama-cpp/index.test.ts @@ -18,6 +18,7 @@ import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ + discoverServer: vi.fn(), ensureModel: vi.fn(), prepareServer: vi.fn(), inspectRuntime: vi.fn(), @@ -36,6 +37,11 @@ vi.mock("./src/managed-server.js", async (importOriginal) => ({ inspectLlamaServerRuntime: mocks.inspectRuntime, })); +vi.mock("./src/external-server/discovery.js", async (importOriginal) => ({ + ...(await importOriginal()), + discoverLlamaServer: mocks.discoverServer, +})); + import llamaCppPlugin from "./index.js"; import { DEFAULT_LLAMA_CPP_EMBEDDING_CACHE_FILE, @@ -52,6 +58,7 @@ let previousPluginRegistry: ReturnType; beforeEach(() => { previousPluginRegistry = getActivePluginRegistry(); + mocks.discoverServer.mockReset(); mocks.ensureModel.mockResolvedValue("/models/model.gguf"); mocks.prepareServer.mockResolvedValue({}); mocks.inspectRuntime.mockResolvedValue({ @@ -171,6 +178,23 @@ describe("llama.cpp provider plugin", () => { expect(provider).not.toHaveProperty("createStreamFn"); }); + it("never discovers external models for a managed local service", async () => { + const provider = registerTextProvider(); + const prepareDynamicModel = expectDefined(provider.prepareDynamicModel, "dynamic model hook"); + const { config } = configuredOptions(); + + await expect( + prepareDynamicModel({ + config, + provider: LLAMA_CPP_PROVIDER_ID, + modelId: "gemma-4-e4b-it-q4_k_m", + modelRegistry: {} as never, + providerConfig: config.models.providers[LLAMA_CPP_PROVIDER_ID], + }), + ).resolves.toBeUndefined(); + expect(mocks.discoverServer).not.toHaveBeenCalled(); + }); + it("registers local embeddings through the generic provider contract", () => { const { config, registry } = createPluginRegistryFixture(); registerVirtualTestPlugin({ diff --git a/extensions/llama-cpp/src/external-server/provider.test.ts b/extensions/llama-cpp/src/external-server/provider.test.ts index e0cd543f0c64..43e0a79db26f 100644 --- a/extensions/llama-cpp/src/external-server/provider.test.ts +++ b/extensions/llama-cpp/src/external-server/provider.test.ts @@ -3,11 +3,7 @@ import type { ProviderPrepareDynamicModelContext, } from "openclaw/plugin-sdk/plugin-entry"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - discoverLlamaServerProvider, - prepareLlamaServerDynamicModels, - resolveLlamaServerDynamicModel, -} from "./provider.js"; +import { discoverLlamaServerProvider, prepareLlamaServerDynamicModel } from "./provider.js"; const discoverMock = vi.hoisted(() => vi.fn()); const runtimeApiKeyMock = vi.hoisted(() => vi.fn()); @@ -64,6 +60,22 @@ function catalogContext(): ProviderCatalogContext { }; } +function dynamicContext( + overrides: Partial = {}, +): ProviderPrepareDynamicModelContext { + return { + config: {}, + provider: "llama-cpp", + modelId: "org/model:Q4", + modelRegistry: {} as never, + providerConfig: { + baseUrl: "http://localhost:8080/v1", + api: "openai-completions", + }, + ...overrides, + }; +} + describe("llama-server provider discovery", () => { beforeEach(() => { discoverMock.mockReset(); @@ -131,7 +143,29 @@ describe("llama-server provider discovery", () => { }); }); - it("scopes dynamic catalogs by agent runtime and auth profile", async () => { + it("returns only the requested discovered model directly to its preparation owner", async () => { + discoverMock.mockResolvedValue({ + ...success(), + models: [ + model(), + { + ...model(), + config: { ...model().config, id: "org/requested:Q8", name: "Requested model" }, + }, + ], + }); + await expect( + prepareLlamaServerDynamicModel(dynamicContext({ modelId: "org/requested:Q8" })), + ).resolves.toMatchObject({ + provider: "llama-cpp", + id: "org/requested:Q8", + name: "Requested model", + baseUrl: "http://localhost:8080/v1", + api: "openai-completions", + }); + }); + + it("keeps requested models and API keys isolated by agent runtime and auth profile", async () => { const first = success(); const second = { ...success(), @@ -143,29 +177,20 @@ describe("llama-server provider discovery", () => { ], }; discoverMock.mockResolvedValueOnce(first).mockResolvedValueOnce(second); - const base = { - config: {}, - provider: "llama-cpp", - modelId: "org/model:Q4", - modelRegistry: {}, - providerConfig: { - baseUrl: "http://localhost:8080/v1", - api: "openai-completions", - }, - }; - const firstCtx = { - ...base, + runtimeApiKeyMock + .mockResolvedValueOnce("first-profile-key") + .mockResolvedValueOnce("second-profile-key"); + const firstCtx = dynamicContext({ agentRuntimeId: "runtime-one", authProfileId: "profile-one", - } as unknown as ProviderPrepareDynamicModelContext; - const secondCtx = { - ...base, + }); + const secondCtx = dynamicContext({ agentRuntimeId: "runtime-two", authProfileId: "profile-two", - } as unknown as ProviderPrepareDynamicModelContext; + }); - await prepareLlamaServerDynamicModels(firstCtx); - await prepareLlamaServerDynamicModels(secondCtx); + const firstModel = await prepareLlamaServerDynamicModel(firstCtx); + const secondModel = await prepareLlamaServerDynamicModel(secondCtx); expect(runtimeApiKeyMock).toHaveBeenNthCalledWith( 1, @@ -175,111 +200,87 @@ describe("llama-server provider discovery", () => { 2, expect.objectContaining({ profileId: "profile-two" }), ); - expect(resolveLlamaServerDynamicModel(firstCtx)?.name).toBe("org/model:Q4"); - expect(resolveLlamaServerDynamicModel(secondCtx)?.name).toBe("second scope"); - }); - - it("bounds dynamic model snapshots by scope", async () => { - discoverMock.mockResolvedValue(success()); - const contexts = Array.from( - { length: 101 }, - (_, index) => - ({ - config: {}, - provider: "llama-cpp", - modelId: "org/model:Q4", - modelRegistry: {}, - agentRuntimeId: `runtime-${index}`, - providerConfig: { - baseUrl: "http://localhost:8080/v1", - api: "openai-completions", - }, - }) as unknown as ProviderPrepareDynamicModelContext, + expect(discoverMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ apiKey: "first-profile-key" }), ); - - for (const ctx of contexts) { - await prepareLlamaServerDynamicModels(ctx); - } - - expect(resolveLlamaServerDynamicModel(contexts[0]!)).toBeUndefined(); - expect(resolveLlamaServerDynamicModel(contexts.at(-1)!)).toMatchObject({ - id: "org/model:Q4", - }); + expect(discoverMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ apiKey: "second-profile-key" }), + ); + expect(firstModel?.name).toBe("org/model:Q4"); + expect(secondModel?.name).toBe("second scope"); }); - it("keeps dynamic snapshots separate when only the endpoint changes", async () => { + it("keeps requested models separate when only the endpoint changes", async () => { discoverMock.mockResolvedValueOnce(success()).mockResolvedValueOnce({ ...success(), models: [{ ...model(), config: { ...model().config, name: "second endpoint" } }], }); const base = { - config: {}, - provider: "llama-cpp", - modelId: "org/model:Q4", - modelRegistry: {}, agentRuntimeId: "endpoint-runtime", authProfileId: "endpoint-profile", }; - const first = { + const first = dynamicContext({ ...base, providerConfig: { baseUrl: "http://localhost:8080/v1", api: "openai-completions" }, - } as unknown as ProviderPrepareDynamicModelContext; - const second = { + }); + const second = dynamicContext({ ...base, providerConfig: { baseUrl: "http://localhost:8081/v1", api: "openai-completions" }, - } as unknown as ProviderPrepareDynamicModelContext; - - await prepareLlamaServerDynamicModels(first); - await prepareLlamaServerDynamicModels(second); - - expect(resolveLlamaServerDynamicModel(first)?.name).toBe("org/model:Q4"); - expect(resolveLlamaServerDynamicModel(second)?.name).toBe("second endpoint"); - }); - - it("clears a scope snapshot when its refresh cannot discover the server", async () => { - discoverMock.mockResolvedValueOnce(success()).mockResolvedValueOnce({ - kind: "unreachable", - endpoint: { origin: "http://localhost:8080", inferenceBaseUrl: "http://localhost:8080/v1" }, - error: new Error("offline"), }); - const ctx = { - config: {}, - provider: "llama-cpp", - modelId: "org/model:Q4", - modelRegistry: {}, - agentRuntimeId: "failed-refresh-runtime", - providerConfig: { - baseUrl: "http://localhost:8080/v1", - api: "openai-completions", - }, - } as unknown as ProviderPrepareDynamicModelContext; - await prepareLlamaServerDynamicModels(ctx); - expect(resolveLlamaServerDynamicModel(ctx)).toMatchObject({ id: "org/model:Q4" }); - await prepareLlamaServerDynamicModels(ctx); - expect(resolveLlamaServerDynamicModel(ctx)).toBeUndefined(); - }); + const firstModel = await prepareLlamaServerDynamicModel(first); + const secondModel = await prepareLlamaServerDynamicModel(second); - it("refreshes and resolves dynamic model ids containing slashes", async () => { - discoverMock.mockResolvedValue(success()); - const ctx = { - config: {}, - provider: "llama-cpp", - modelId: "org/model:Q4", - modelRegistry: {}, - providerConfig: { - baseUrl: "http://localhost:8080/v1", - api: "openai-completions", - }, - } as unknown as ProviderPrepareDynamicModelContext; - - await prepareLlamaServerDynamicModels(ctx); - - expect(resolveLlamaServerDynamicModel(ctx)).toMatchObject({ - provider: "llama-cpp", - id: "org/model:Q4", + expect(firstModel).toMatchObject({ + name: "org/model:Q4", baseUrl: "http://localhost:8080/v1", - api: "openai-completions", }); + expect(secondModel).toMatchObject({ + name: "second endpoint", + baseUrl: "http://localhost:8081/v1", + }); + }); + + it("prefers explicit Authorization over the profile API key during model preparation", async () => { + runtimeApiKeyMock.mockResolvedValue("profile-key"); + discoverMock.mockResolvedValue(success()); + const headers = { Authorization: "Bearer endpoint-key" }; + + await prepareLlamaServerDynamicModel( + dynamicContext({ + providerConfig: { baseUrl: "http://localhost:8080/v1", headers }, + }), + ); + + expect(discoverMock).toHaveBeenCalledWith( + expect.objectContaining({ apiKey: undefined, headers, cacheTtlMs: 0 }), + ); + }); + + it.each([ + { + label: "the server is unavailable", + discovery: { + kind: "unreachable" as const, + endpoint: { origin: "http://localhost:8080", inferenceBaseUrl: "http://localhost:8080/v1" }, + error: new Error("offline"), + }, + }, + { + label: "the requested model disappears", + discovery: { ...success(), models: [] }, + }, + ])("returns no stale model when $label", async ({ discovery }) => { + discoverMock.mockResolvedValueOnce(success()).mockResolvedValueOnce(discovery); + const ctx = dynamicContext({ + agentRuntimeId: "failed-refresh-runtime", + }); + + await expect(prepareLlamaServerDynamicModel(ctx)).resolves.toMatchObject({ + id: "org/model:Q4", + }); + await expect(prepareLlamaServerDynamicModel(ctx)).resolves.toBeUndefined(); }); }); diff --git a/extensions/llama-cpp/src/external-server/provider.ts b/extensions/llama-cpp/src/external-server/provider.ts index 2138214413a2..080d9eb2e73f 100644 --- a/extensions/llama-cpp/src/external-server/provider.ts +++ b/extensions/llama-cpp/src/external-server/provider.ts @@ -1,8 +1,6 @@ -import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import type { ProviderCatalogContext, ProviderPrepareDynamicModelContext, - ProviderResolveDynamicModelContext, ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; @@ -14,47 +12,7 @@ import { } from "./auth.js"; import { discoverLlamaServer } from "./discovery.js"; import { resolveLlamaServerEndpoint } from "./endpoint.js"; -import { buildLlamaServerProviderConfig, type LlamaServerDiscoveredModel } from "./models.js"; - -const dynamicModels = new Map(); -const LLAMA_SERVER_DYNAMIC_MODEL_MAX_SCOPES = 100; - -function cacheDynamicModels(key: string, models: ProviderRuntimeModel[]): void { - dynamicModels.delete(key); - dynamicModels.set(key, models); - pruneMapToMaxSize(dynamicModels, LLAMA_SERVER_DYNAMIC_MODEL_MAX_SCOPES); -} - -function dynamicModelScopeKey( - ctx: Pick< - ProviderResolveDynamicModelContext, - "agentRuntimeId" | "agentDir" | "authProfileId" | "providerConfig" - >, -): string { - return [ - ctx.agentRuntimeId ?? ctx.agentDir ?? "", - ctx.authProfileId ?? "", - ctx.providerConfig?.baseUrl ?? "", - ].join("\u0000"); -} - -function toRuntimeModel( - model: LlamaServerDiscoveredModel, - providerConfig: { - baseUrl?: string; - api?: ProviderRuntimeModel["api"]; - }, -): ProviderRuntimeModel { - return { - ...model.config, - provider: LLAMA_CPP_PROVIDER_ID, - api: providerConfig.api ?? "openai-completions", - baseUrl: resolveLlamaServerEndpoint(providerConfig.baseUrl).inferenceBaseUrl, - input: model.config.input.filter( - (entry): entry is "text" | "image" => entry === "text" || entry === "image", - ), - }; -} +import { buildLlamaServerProviderConfig } from "./models.js"; /** Discovers external llama-server models for provider runtime resolution. */ export async function discoverLlamaServerProvider( @@ -96,9 +54,9 @@ export async function discoverLlamaServerProvider( }; } -export async function prepareLlamaServerDynamicModels( +export async function prepareLlamaServerDynamicModel( ctx: ProviderPrepareDynamicModelContext, -): Promise { +): Promise { const apiKey = await resolveLlamaServerRuntimeApiKey({ config: ctx.config, agentDir: ctx.agentDir, @@ -115,19 +73,20 @@ export async function prepareLlamaServerDynamicModels( headers, cacheTtlMs: 0, }); - const key = dynamicModelScopeKey(ctx); - cacheDynamicModels( - key, + const model = discovery.kind === "success" - ? discovery.models.map((model) => toRuntimeModel(model, ctx.providerConfig ?? {})) - : [], - ); -} - -export function resolveLlamaServerDynamicModel( - params: ProviderResolveDynamicModelContext, -): ProviderRuntimeModel | undefined { - return dynamicModels - .get(dynamicModelScopeKey(params)) - ?.find((model) => model.id === params.modelId); + ? discovery.models.find((entry) => entry.config.id === ctx.modelId) + : undefined; + if (!model) { + return undefined; + } + return { + ...model.config, + provider: LLAMA_CPP_PROVIDER_ID, + api: ctx.providerConfig?.api ?? "openai-completions", + baseUrl: resolveLlamaServerEndpoint(ctx.providerConfig?.baseUrl).inferenceBaseUrl, + input: model.config.input.filter( + (entry): entry is "text" | "image" => entry === "text" || entry === "image", + ), + }; } diff --git a/extensions/llama-cpp/src/managed-provider.ts b/extensions/llama-cpp/src/managed-provider.ts index 607348df4e2d..0b3f76b67614 100644 --- a/extensions/llama-cpp/src/managed-provider.ts +++ b/extensions/llama-cpp/src/managed-provider.ts @@ -21,8 +21,7 @@ import { import { normalizeLlamaServerProviderConfig } from "./external-server/endpoint.js"; import { discoverLlamaServerProvider, - prepareLlamaServerDynamicModels, - resolveLlamaServerDynamicModel, + prepareLlamaServerDynamicModel, } from "./external-server/provider.js"; import { configureLlamaServerNonInteractive, @@ -119,15 +118,10 @@ export function registerLlamaCppProvider(api: OpenClawPluginApi): void { providerConfig.localService ? providerConfig : normalizeLlamaServerProviderConfig(providerConfig), - prepareDynamicModel: async (ctx) => { - if (!ctx.config?.models?.providers?.[LLAMA_CPP_PROVIDER_ID]?.localService) { - await prepareLlamaServerDynamicModels(ctx); - } - }, - resolveDynamicModel: (ctx) => + prepareDynamicModel: async (ctx) => ctx.config?.models?.providers?.[LLAMA_CPP_PROVIDER_ID]?.localService ? undefined - : resolveLlamaServerDynamicModel(ctx), + : await prepareLlamaServerDynamicModel(ctx), wrapStreamFn: (ctx) => { const providerConfig = ctx.config?.models?.providers?.[LLAMA_CPP_PROVIDER_ID]; if (!providerConfig?.localService) { diff --git a/extensions/lmstudio/api.ts b/extensions/lmstudio/api.ts index 4b332008df89..ce2cd4d9b5fd 100644 --- a/extensions/lmstudio/api.ts +++ b/extensions/lmstudio/api.ts @@ -25,7 +25,7 @@ export { normalizeLmstudioConfiguredCatalogEntry, normalizeLmstudioProviderConfig, prepareAppGuidedLmstudioSetup, - prepareLmstudioDynamicModels, + prepareLmstudioDynamicModel, promptAndConfigureLmstudioInteractive, resolveLmstudioConfiguredApiKey, resolveLmstudioInferenceBase, diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts index 2cb99c2d52ea..71fa72c9722a 100644 --- a/extensions/lmstudio/index.test.ts +++ b/extensions/lmstudio/index.test.ts @@ -1,17 +1,29 @@ // Lmstudio tests cover index plugin behavior. -import type { OpenClawConfig, ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import { createDeferred } from "openclaw/plugin-sdk/extension-shared"; +import type { + OpenClawConfig, + ProviderAuthMethod, + ProviderPrepareDynamicModelContext, +} from "openclaw/plugin-sdk/plugin-entry"; import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; -import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; import { beforeEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./src/defaults.js"; const fetchLmstudioModelsMock = vi.hoisted(() => vi.fn()); +const discoverLmstudioModelsMock = vi.hoisted(() => + vi.fn(), +); vi.mock("./src/models.fetch.js", async (importOriginal) => ({ ...(await importOriginal()), fetchLmstudioModels: fetchLmstudioModelsMock, + discoverLmstudioModels: discoverLmstudioModelsMock, })); function registerProvider() { @@ -83,9 +95,42 @@ function createRemoteProviderConfig(overrides?: Partial): M }; } +function createDynamicModelContext( + profile: "first" | "second", +): ProviderPrepareDynamicModelContext { + return { + provider: "lmstudio", + modelId: "shared-model", + modelRegistry: { + getAll: () => [], + getAvailable: () => [], + find: () => undefined, + hasConfiguredAuth: () => false, + }, + authProfileId: `lmstudio:${profile}`, + providerConfig: { + baseUrl: "http://lmstudio.internal:1234/v1", + headers: { Authorization: `Bearer ${profile}-profile` }, + }, + }; +} + +function createDiscoveredModel(name: string, contextWindow: number): ModelDefinitionConfig { + return { + id: "shared-model", + name, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: 8192, + }; +} + describe("lmstudio plugin", () => { beforeEach(() => { fetchLmstudioModelsMock.mockReset(); + discoverLmstudioModelsMock.mockReset(); }); it("registers llama.cpp GBNF tool-schema projection", () => { @@ -95,6 +140,71 @@ describe("lmstudio plugin", () => { }); }); + it("keeps concurrent model preparations isolated when shared-endpoint profiles finish in reverse order", async () => { + const provider = registerProvider(); + const prepareDynamicModel = provider.prepareDynamicModel; + if (!prepareDynamicModel) { + throw new Error("expected the LM Studio provider to prepare dynamic models"); + } + const firstDiscovery = createDeferred(); + const secondDiscovery = createDeferred(); + discoverLmstudioModelsMock.mockImplementation(({ headers }) => + headers?.Authorization === "Bearer first-profile" + ? firstDiscovery.promise + : secondDiscovery.promise, + ); + + const firstPreparation = prepareDynamicModel(createDynamicModelContext("first")); + const secondPreparation = prepareDynamicModel(createDynamicModelContext("second")); + await vi.waitFor(() => expect(discoverLmstudioModelsMock).toHaveBeenCalledTimes(2)); + + secondDiscovery.resolve([createDiscoveredModel("Second profile model", 65_536)]); + const secondPrepared = await secondPreparation; + firstDiscovery.resolve([createDiscoveredModel("First profile model", 32_768)]); + const firstPrepared = await firstPreparation; + + expect(secondPrepared).toMatchObject({ + id: "shared-model", + name: "Second profile model", + contextWindow: 65_536, + }); + expect(firstPrepared).toMatchObject({ + id: "shared-model", + name: "First profile model", + contextWindow: 32_768, + }); + }); + + it("returns only the requested discovered model without retaining stale endpoint results", async () => { + const provider = registerProvider(); + const prepareDynamicModel = provider.prepareDynamicModel; + if (!prepareDynamicModel) { + throw new Error("expected the LM Studio provider to prepare dynamic models"); + } + const otherModel = { ...createDiscoveredModel("Other model", 16_384), id: "other-model" }; + const requestedModel = createDiscoveredModel("Requested model", 32_768); + discoverLmstudioModelsMock + .mockResolvedValueOnce([otherModel, requestedModel]) + .mockResolvedValueOnce([otherModel]); + + const context = createDynamicModelContext("first"); + await expect(prepareDynamicModel(context)).resolves.toMatchObject({ + id: "shared-model", + name: "Requested model", + provider: "lmstudio", + api: "openai-completions", + baseUrl: "http://lmstudio.internal:1234/v1", + contextWindow: 32_768, + }); + await expect(prepareDynamicModel(context)).resolves.toBeUndefined(); + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://lmstudio.internal:1234/v1", + apiKey: "", + headers: { Authorization: "Bearer first-profile" }, + quiet: true, + }); + }); + it("preflights the requested LM Studio model before destructive non-interactive reset", async () => { fetchLmstudioModelsMock.mockResolvedValue({ reachable: true, diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts index 5590607b7414..dd21fac56fa6 100644 --- a/extensions/lmstudio/index.ts +++ b/extensions/lmstudio/index.ts @@ -8,7 +8,6 @@ import { type ProviderAuthMethod, type ProviderAuthMethodNonInteractiveContext, type ProviderAuthResult, - type ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; import { CUSTOM_LOCAL_AUTH_MARKER, @@ -33,8 +32,6 @@ import { shouldUseLmstudioSyntheticAuth } from "./src/provider-auth.js"; import { wrapLmstudioInferencePreload } from "./src/stream.js"; const PROVIDER_ID = "lmstudio"; -// Intentional: dynamic models are cached per LM Studio endpoint (`baseUrl`) only. -const cachedDynamicModels = new Map(); type LmstudioNonInteractiveValidationContext = Parameters< NonNullable @@ -233,15 +230,8 @@ export default definePluginEntry({ normalizeConfig: ({ providerConfig }) => normalizeLmstudioProviderConfig(providerConfig), prepareDynamicModel: async (ctx) => { const providerSetup = await loadProviderSetup(); - cachedDynamicModels.set( - ctx.providerConfig?.baseUrl ?? "", - await providerSetup.prepareLmstudioDynamicModels(ctx), - ); + return await providerSetup.prepareLmstudioDynamicModel(ctx); }, - resolveDynamicModel: (ctx) => - cachedDynamicModels - .get(ctx.providerConfig?.baseUrl ?? "") - ?.find((model) => model.id === ctx.modelId), augmentModelCatalog: (ctx) => resolveLmstudioAugmentedCatalogEntries(ctx.config), wrapStreamFn: wrapLmstudioInferencePreload, ...buildProviderToolCompatFamilyHooks("llamacpp-gbnf"), diff --git a/extensions/lmstudio/src/api.ts b/extensions/lmstudio/src/api.ts index 730801c24bf4..e601b34351ba 100644 --- a/extensions/lmstudio/src/api.ts +++ b/extensions/lmstudio/src/api.ts @@ -40,6 +40,6 @@ export { detectAppGuidedLmstudioAvailability, discoverLmstudioProvider, prepareAppGuidedLmstudioSetup, - prepareLmstudioDynamicModels, + prepareLmstudioDynamicModel, promptAndConfigureLmstudioInteractive, } from "./setup.js"; diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts index 97694a40a82c..4dd8dcbc53ca 100644 --- a/extensions/lmstudio/src/setup.ts +++ b/extensions/lmstudio/src/setup.ts @@ -1046,9 +1046,9 @@ export async function discoverLmstudioProvider(ctx: ProviderCatalogContext): Pro }; } -export async function prepareLmstudioDynamicModels( +export async function prepareLmstudioDynamicModel( ctx: ProviderPrepareDynamicModelContext, -): Promise { +): Promise { const baseUrl = resolveLmstudioInferenceBase(ctx.providerConfig?.baseUrl); const { apiKey, headers } = await resolveLmstudioRequestContext({ config: ctx.config, @@ -1062,15 +1062,18 @@ export async function prepareLmstudioDynamicModels( headers, quiet: true, }); - return discoveredModels.map((model) => - Object.assign({}, model, { - provider: PROVIDER_ID, - api: ctx.providerConfig?.api ?? `openai-completions`, - baseUrl, - input: model.input.filter( - (entry): entry is "text" | "image" => entry === "text" || entry === "image", - ), - }), - ); + const model = discoveredModels.find((candidate) => candidate.id === ctx.modelId); + if (!model) { + return undefined; + } + return { + ...model, + provider: PROVIDER_ID, + api: ctx.providerConfig?.api ?? "openai-completions", + baseUrl, + input: model.input.filter( + (entry): entry is "text" | "image" => entry === "text" || entry === "image", + ), + }; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/extensions/matrix/doctor-contract-api.credentials.test.ts b/extensions/matrix/doctor-contract-api.credentials.test.ts new file mode 100644 index 000000000000..96c1684b9239 --- /dev/null +++ b/extensions/matrix/doctor-contract-api.credentials.test.ts @@ -0,0 +1,199 @@ +// Matrix tests cover credential-state migrations owned by the doctor contract. +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { + OpenKeyedStoreOptions, + PluginStateKeyedStore, +} from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + getPluginStateCapacityForTests, + importPluginStateEntriesForDoctorForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import type { PluginDoctorStateMigrationContext } from "openclaw/plugin-sdk/runtime-doctor-migrations"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; +import { + MATRIX_CREDENTIALS_MAX_ENTRIES, + MATRIX_CREDENTIALS_NAMESPACE, + matrixCredentialsStoreKey, + type MatrixCredentialStateRecord, + type MatrixStoredCredentialRecord, +} from "./src/matrix/credentials-state.js"; +import { installMatrixTestRuntime } from "./src/test-runtime.js"; +import { useAutoCleanupTempDirTracker } from "./test-support.js"; + +function createContext(env?: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { + return { + getPluginStateCapacity() { + return getPluginStateCapacityForTests("matrix", env); + }, + importPluginStateEntries(options, entries) { + importPluginStateEntriesForDoctorForTests("matrix", options, entries); + }, + openPluginStateKeyedStore: (options: OpenKeyedStoreOptions): PluginStateKeyedStore => + createPluginStateKeyedStoreForTests("matrix", options), + }; +} + +function createMigrationParams(stateDir: string) { + const env = { OPENCLAW_STATE_DIR: stateDir }; + return { + config: {} as OpenClawConfig, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context: createContext(env), + }; +} + +function migrationById(id: string) { + const migration = stateMigrations.find((entry) => entry.id === id); + if (!migration) { + throw new Error(`missing migration ${id}`); + } + return migration; +} + +describe("matrix doctor credential state migrations", () => { + const tempDirs = useAutoCleanupTempDirTracker(afterEach); + + beforeEach(() => { + resetPluginStateStoreForTests(); + installMatrixTestRuntime(); + }); + + afterEach(() => { + resetPluginStateStoreForTests(); + }); + + it("imports account credentials into SQLite before archiving the JSON", async () => { + const stateDir = tempDirs.make("openclaw-matrix-doctor-"); + const credentialsDir = path.join(stateDir, "credentials", "matrix"); + const filePath = path.join(credentialsDir, "credentials-ops.json"); + const credentials = { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "secret-token", + deviceId: "DEVICE123", + createdAt: "2026-07-01T12:00:00.000Z", + lastUsedAt: "2026-07-02T12:00:00.000Z", + }; + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(credentials)); + const migration = migrationById("matrix-credentials-json-to-plugin-state"); + const params = createMigrationParams(stateDir); + + await expect(migration.detectLegacyState(params)).resolves.toEqual({ + preview: ["Matrix credential JSON can migrate to SQLite (1 file)"], + }); + const result = await migration.migrateLegacyState(params); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Migrated Matrix credentials for account ops to SQLite", + expect.stringContaining("Archived Matrix credentials legacy source"), + ]); + const store = params.context.openPluginStateKeyedStore({ + namespace: MATRIX_CREDENTIALS_NAMESPACE, + maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES, + overflowPolicy: "reject-new", + }); + await expect(store.lookup(matrixCredentialsStoreKey("ops"))).resolves.toEqual({ + accountId: "ops", + ...credentials, + }); + expect(fs.existsSync(`${filePath}.migrated`)).toBe(true); + }); + + it("archives legacy credentials without restoring an explicitly cleared account", async () => { + const stateDir = tempDirs.make("openclaw-matrix-doctor-"); + const credentialsDir = path.join(stateDir, "credentials", "matrix"); + const filePath = path.join(credentialsDir, "credentials-ops.json"); + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "legacy-token", + createdAt: "2026-07-01T12:00:00.000Z", + }), + ); + const params = createMigrationParams(stateDir); + const credentialStore = params.context.openPluginStateKeyedStore({ + namespace: MATRIX_CREDENTIALS_NAMESPACE, + maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES, + overflowPolicy: "reject-new", + }); + await credentialStore.register(matrixCredentialsStoreKey("ops"), { + accountId: "ops", + kind: "revoked", + revokedAt: "2026-07-02T12:00:00.000Z", + }); + + const result = await migrationById( + "matrix-credentials-json-to-plugin-state", + ).migrateLegacyState(params); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Archived revoked Matrix credential legacy source for account ops", + expect.stringContaining("Archived Matrix credentials legacy source"), + ]); + expect(fs.existsSync(`${filePath}.migrated`)).toBe(true); + }); + + it("keeps canonical SQLite credentials and archives a differing legacy source", async () => { + const stateDir = tempDirs.make("openclaw-matrix-doctor-"); + const credentialsDir = path.join(stateDir, "credentials", "matrix"); + const filePath = path.join(credentialsDir, "credentials-agent1.json"); + fs.mkdirSync(credentialsDir, { recursive: true }); + fs.writeFileSync( + filePath, + JSON.stringify({ + homeserver: "https://matrix.example.org", + userId: "@agent1:example.org", + accessToken: "legacy-token", + deviceId: "LEGACYDEVICE", + createdAt: "2026-07-02T12:00:00.000Z", + }), + ); + const params = createMigrationParams(stateDir); + const credentialStore = params.context.openPluginStateKeyedStore({ + namespace: MATRIX_CREDENTIALS_NAMESPACE, + maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES, + overflowPolicy: "reject-new", + }); + const canonical: MatrixStoredCredentialRecord = { + accountId: "agent1", + homeserver: "https://matrix.example.org", + userId: "@agent1:example.org", + accessToken: "canonical-token", + deviceId: "CANONICALDEVICE", + createdAt: "2026-07-01T12:00:00.000Z", + }; + await credentialStore.register(matrixCredentialsStoreKey("agent1"), canonical); + + const result = await migrationById( + "matrix-credentials-json-to-plugin-state", + ).migrateLegacyState(params); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Kept existing Matrix credentials for account agent1", + expect.stringContaining("Archived Matrix credentials legacy source"), + ]); + await expect(credentialStore.lookup(matrixCredentialsStoreKey("agent1"))).resolves.toEqual( + canonical, + ); + expect(fs.existsSync(filePath)).toBe(false); + expect(fs.existsSync(`${filePath}.migrated`)).toBe(true); + expect(JSON.parse(fs.readFileSync(`${filePath}.migrated`, "utf8"))).toMatchObject({ + accessToken: "legacy-token", + deviceId: "LEGACYDEVICE", + }); + }); +}); diff --git a/extensions/matrix/doctor-contract-api.test.ts b/extensions/matrix/doctor-contract-api.test.ts index 628f9c0bac23..fc04f60dee67 100644 --- a/extensions/matrix/doctor-contract-api.test.ts +++ b/extensions/matrix/doctor-contract-api.test.ts @@ -25,13 +25,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { stateMigrations } from "./doctor-contract-api.js"; import { SqliteBackedMatrixSyncStore } from "./src/matrix/client/file-sync-store.js"; import { openMatrixStorageMetaStoreOptions } from "./src/matrix/client/storage.js"; -import { - MATRIX_CREDENTIALS_MAX_ENTRIES, - MATRIX_CREDENTIALS_NAMESPACE, - matrixCredentialsStoreKey, - type MatrixCredentialStateRecord, - type MatrixStoredCredentialRecord, -} from "./src/matrix/credentials-state.js"; import { MATRIX_IDB_SNAPSHOT_FILENAME, MATRIX_RECOVERY_KEY_FILENAME, @@ -104,83 +97,6 @@ describe("matrix doctor contract state migrations", () => { resetPluginStateStoreForTests(); }); - it("imports account credentials into SQLite before archiving the JSON", async () => { - const stateDir = tempDirs.make("openclaw-matrix-doctor-"); - const credentialsDir = path.join(stateDir, "credentials", "matrix"); - const filePath = path.join(credentialsDir, "credentials-ops.json"); - const credentials = { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "secret-token", - deviceId: "DEVICE123", - createdAt: "2026-07-01T12:00:00.000Z", - lastUsedAt: "2026-07-02T12:00:00.000Z", - }; - fs.mkdirSync(credentialsDir, { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(credentials)); - const migration = migrationById("matrix-credentials-json-to-plugin-state"); - const params = createMigrationParams(stateDir); - - await expect(migration.detectLegacyState(params)).resolves.toEqual({ - preview: ["Matrix credential JSON can migrate to SQLite (1 file)"], - }); - const result = await migration.migrateLegacyState(params); - - expect(result.warnings).toEqual([]); - expect(result.changes).toEqual([ - "Migrated Matrix credentials for account ops to SQLite", - expect.stringContaining("Archived Matrix credentials legacy source"), - ]); - const store = params.context.openPluginStateKeyedStore({ - namespace: MATRIX_CREDENTIALS_NAMESPACE, - maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES, - overflowPolicy: "reject-new", - }); - await expect(store.lookup(matrixCredentialsStoreKey("ops"))).resolves.toEqual({ - accountId: "ops", - ...credentials, - }); - expect(fs.existsSync(`${filePath}.migrated`)).toBe(true); - }); - - it("archives legacy credentials without restoring an explicitly cleared account", async () => { - const stateDir = tempDirs.make("openclaw-matrix-doctor-"); - const credentialsDir = path.join(stateDir, "credentials", "matrix"); - const filePath = path.join(credentialsDir, "credentials-ops.json"); - fs.mkdirSync(credentialsDir, { recursive: true }); - fs.writeFileSync( - filePath, - JSON.stringify({ - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "legacy-token", - createdAt: "2026-07-01T12:00:00.000Z", - }), - ); - const params = createMigrationParams(stateDir); - const credentialStore = params.context.openPluginStateKeyedStore({ - namespace: MATRIX_CREDENTIALS_NAMESPACE, - maxEntries: MATRIX_CREDENTIALS_MAX_ENTRIES, - overflowPolicy: "reject-new", - }); - await credentialStore.register(matrixCredentialsStoreKey("ops"), { - accountId: "ops", - kind: "revoked", - revokedAt: "2026-07-02T12:00:00.000Z", - }); - - const result = await migrationById( - "matrix-credentials-json-to-plugin-state", - ).migrateLegacyState(params); - - expect(result.warnings).toEqual([]); - expect(result.changes).toEqual([ - "Archived revoked Matrix credential legacy source for account ops", - expect.stringContaining("Archived Matrix credentials legacy source"), - ]); - expect(fs.existsSync(`${filePath}.migrated`)).toBe(true); - }); - it("migrates legacy sync cache JSON to SQLite plugin state", async () => { const stateDir = tempDirs.make("openclaw-matrix-doctor-"); const storageRootDir = path.join( diff --git a/extensions/matrix/doctor-contract-api.ts b/extensions/matrix/doctor-contract-api.ts index 524d6bf9f9a8..9cfde279ee9f 100644 --- a/extensions/matrix/doctor-contract-api.ts +++ b/extensions/matrix/doctor-contract-api.ts @@ -270,9 +270,13 @@ export const stateMigrations: PluginDoctorStateMigration[] = [ } const existing = normalizeMatrixStoredCredentials(stored, source.accountId); if (existing && JSON.stringify(existing) !== JSON.stringify(credentials)) { - warnings.push( - `Kept existing Matrix credentials for account ${source.accountId}; left differing legacy source in place`, - ); + changes.push(`Kept existing Matrix credentials for account ${source.accountId}`); + await archiveLegacyStateSource({ + filePath: source.filePath, + label: "Matrix credentials", + changes, + warnings, + }); continue; } if (!existing) { diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index fe2d42038d1b..6b5e4af221f2 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -58,6 +58,7 @@ import { resolveMatrixAccountConfig, type ResolvedMatrixAccount, } from "./matrix/accounts.js"; +import { resolveMatrixConversationRouteOwner } from "./matrix/conversation-route-owner.js"; import { normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; import type { MatrixProbe } from "./matrix/probe.js"; import { @@ -435,6 +436,7 @@ export const matrixPlugin: ChannelPlugin = }, conversationBindings: { supportsCurrentConversationBinding: true, + bindingStore: "adapter", defaultTopLevelPlacement, setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) => setMatrixThreadBindingIdleTimeoutBySessionKey({ @@ -463,6 +465,7 @@ export const matrixPlugin: ChannelPlugin = resolveDeliveryTarget: ({ conversationId, parentConversationId }) => resolveMatrixDeliveryTarget({ conversationId, parentConversationId }), resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params), + resolveConversationRouteOwner: resolveMatrixConversationRouteOwner, targetResolver: { looksLikeId: (raw) => { const trimmed = raw.trim(); diff --git a/extensions/matrix/src/matrix/conversation-route-owner.test.ts b/extensions/matrix/src/matrix/conversation-route-owner.test.ts new file mode 100644 index 000000000000..a15efe7f26ce --- /dev/null +++ b/extensions/matrix/src/matrix/conversation-route-owner.test.ts @@ -0,0 +1,90 @@ +import { + registerSessionBindingAdapter, + type SessionBindingAdapter, + testing as sessionBindingTesting, + unregisterSessionBindingAdapter, +} from "openclaw/plugin-sdk/conversation-runtime"; +import { + createTestRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveMatrixConversationRouteOwner } from "./conversation-route-owner.js"; + +describe("resolveMatrixConversationRouteOwner", () => { + let adapter: SessionBindingAdapter; + + beforeEach(() => { + resetPluginRuntimeStateForTest(); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "matrix", + source: "test", + plugin: { + id: "matrix", + meta: { aliases: [] }, + conversationBindings: { + supportsCurrentConversationBinding: true, + createManager: () => ({ stop: () => undefined }), + }, + }, + }, + ]), + ); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + adapter = { + channel: "matrix", + accountId: "default", + listBySession: () => [], + resolveByConversation: (conversation) => ({ + bindingId: "binding-room", + targetSessionKey: "agent:finance:bound", + targetKind: "session", + conversation, + status: "active", + boundAt: 1, + }), + }; + registerSessionBindingAdapter(adapter); + }); + + afterEach(() => { + resetPluginRuntimeStateForTest(); + sessionBindingTesting.resetSessionBindingAdaptersForTests(); + }); + + it("uses the native DM room and a channel peer's canonical room identity", () => { + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { + kind: "direct", + peerId: "@alice:example.org", + nativeChannelId: "!dm:example.org", + }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "!room:example.org" }, + }), + ).toEqual({ kind: "agent", agentId: "finance" }); + }); + + it("reports temporary binding-store unavailability", () => { + unregisterSessionBindingAdapter({ channel: "matrix", accountId: "default", adapter }); + + expect( + resolveMatrixConversationRouteOwner({ + cfg: {}, + accountId: "default", + conversation: { kind: "channel", peerId: "!room:example.org" }, + }), + ).toEqual({ kind: "unavailable" }); + }); +}); diff --git a/extensions/matrix/src/matrix/conversation-route-owner.ts b/extensions/matrix/src/matrix/conversation-route-owner.ts new file mode 100644 index 000000000000..d96ba2d99daa --- /dev/null +++ b/extensions/matrix/src/matrix/conversation-route-owner.ts @@ -0,0 +1,43 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { parseAgentSessionKey, resolveAgentRoute } from "openclaw/plugin-sdk/routing"; +import { resolveMatrixAccount } from "./accounts.js"; +import { resolveMatrixInboundRoute } from "./monitor/route.js"; + +export function resolveMatrixConversationRouteOwner(params: { + cfg: OpenClawConfig; + accountId: string; + conversation: { + kind: "direct" | "group" | "channel"; + peerId: string; + threadId?: string; + nativeChannelId?: string; + }; +}) { + const { cfg, accountId, conversation } = params; + const roomId = + conversation.nativeChannelId?.trim() || + (conversation.kind === "direct" ? "" : conversation.peerId.trim()); + if (!roomId) { + return null; + } + const isDirectMessage = conversation.kind === "direct"; + const result = resolveMatrixInboundRoute({ + cfg, + accountId, + roomId, + senderId: conversation.peerId, + isDirectMessage, + dmSessionScope: resolveMatrixAccount({ cfg, accountId }).config.dm?.sessionScope, + threadId: conversation.threadId, + resolveAgentRoute, + }); + if (!result.bindingOwnerAvailable) { + return { kind: "unavailable" as const }; + } + if (result.runtimeBindingId && !parseAgentSessionKey(result.route.sessionKey)?.agentId) { + // Matrix's store cannot project plugin metadata. A non-agent runtime target therefore + // cannot authorize detached delivery through an inferred fallback owner. + return null; + } + return { kind: "agent" as const, agentId: result.route.agentId }; +} diff --git a/extensions/matrix/src/matrix/monitor/route.ts b/extensions/matrix/src/matrix/monitor/route.ts index 0c8d9c3d39bb..7cfa99edf2ca 100644 --- a/extensions/matrix/src/matrix/monitor/route.ts +++ b/extensions/matrix/src/matrix/monitor/route.ts @@ -6,7 +6,7 @@ import { deriveLastRoutePolicy, resolveAgentIdFromSessionKey, } from "openclaw/plugin-sdk/routing"; -import { getSessionBindingService } from "openclaw/plugin-sdk/session-binding-runtime"; +import { inspectSessionBindingByConversation } from "openclaw/plugin-sdk/session-binding-runtime"; import type { CoreConfig } from "../../types.js"; import { resolveMatrixThreadSessionKeys } from "./threads.js"; @@ -53,6 +53,7 @@ export function resolveMatrixInboundRoute(params: { }): { route: MatrixResolvedRoute; configuredBinding: ReturnType; + bindingOwnerAvailable: boolean; runtimeBindingId: string | null; } { const baseRoute = params.resolveAgentRoute({ @@ -74,13 +75,15 @@ export function resolveMatrixInboundRoute(params: { }); const bindingConversationId = params.threadId ?? params.roomId; const bindingParentConversationId = params.threadId ? params.roomId : undefined; - const sessionBindingService = getSessionBindingService(); - const runtimeBinding = sessionBindingService.resolveByConversation({ + const bindingRef = { channel: "matrix", accountId: params.accountId, conversationId: bindingConversationId, parentConversationId: bindingParentConversationId, - }); + }; + const bindingInspection = inspectSessionBindingByConversation(bindingRef); + const runtimeBinding = + bindingInspection.status === "available" ? bindingInspection.binding : null; const boundSessionKey = runtimeBinding?.targetSessionKey?.trim(); if (runtimeBinding && boundSessionKey) { @@ -96,6 +99,7 @@ export function resolveMatrixInboundRoute(params: { matchedBy: "binding.channel", }, configuredBinding: null, + bindingOwnerAvailable: true, runtimeBindingId: runtimeBinding.bindingId, }; } @@ -168,13 +172,15 @@ export function resolveMatrixInboundRoute(params: { }), }, configuredBinding, - runtimeBindingId: null, + bindingOwnerAvailable: bindingInspection.status === "available", + runtimeBindingId: runtimeBinding?.bindingId ?? null, }; } return { route: routeWithDmScope, configuredBinding, - runtimeBindingId: null, + bindingOwnerAvailable: bindingInspection.status === "available", + runtimeBindingId: runtimeBinding?.bindingId ?? null, }; } diff --git a/extensions/mattermost/src/mattermost/directory.test.ts b/extensions/mattermost/src/mattermost/directory.test.ts index d67288a858d4..bb95cac1a775 100644 --- a/extensions/mattermost/src/mattermost/directory.test.ts +++ b/extensions/mattermost/src/mattermost/directory.test.ts @@ -62,6 +62,31 @@ describe("mattermost directory", () => { expect(createMattermostClientMock).toHaveBeenCalledOnce(); }); + it("uses only the requested account for scoped directory discovery", async () => { + const personalClient = { token: "token-personal", request: vi.fn().mockResolvedValue([]) }; + listMattermostAccountIdsMock.mockReturnValue(["personal", "finance"]); + resolveMattermostAccountMock.mockImplementation(({ accountId }) => ({ + enabled: true, + botToken: `token-${accountId}`, + baseUrl: "https://chat.example.com", + })); + createMattermostClientMock.mockReturnValue(personalClient); + fetchMattermostMeMock.mockResolvedValue({ id: "me-1" }); + + await expect( + listMattermostDirectoryGroups({ + cfg: {} as never, + accountId: "personal", + runtime: {} as never, + }), + ).resolves.toEqual([]); + expect(resolveMattermostAccountMock).toHaveBeenCalledOnce(); + expect(resolveMattermostAccountMock).toHaveBeenCalledWith({ + cfg: {}, + accountId: "personal", + }); + }); + it("deduplicates channels across enabled accounts and skips failing accounts", async () => { const clientA = { token: "token-a", diff --git a/extensions/mattermost/src/mattermost/directory.ts b/extensions/mattermost/src/mattermost/directory.ts index 07914bfa4229..a762b139a4c1 100644 --- a/extensions/mattermost/src/mattermost/directory.ts +++ b/extensions/mattermost/src/mattermost/directory.ts @@ -35,17 +35,12 @@ function buildClient(params: { }); } -/** - * Build clients from ALL enabled accounts (deduplicated by token). - * - * We always scan every account because: - * - Private channels are only visible to bots that are members - * - The requesting agent's account may have an expired/invalid token - * - * This means a single healthy bot token is enough for directory discovery. - */ +/** Build the requested account client, or aggregate accounts for an explicitly unscoped lookup. */ function buildClients(params: MattermostDirectoryParams): MattermostClient[] { - const accountIds = listMattermostAccountIds(params.cfg); + const requestedAccountId = params.accountId?.trim(); + const accountIds = requestedAccountId + ? [requestedAccountId] + : listMattermostAccountIds(params.cfg); const seen = new Set(); const clients: MattermostClient[] = []; for (const id of accountIds) { diff --git a/extensions/mattermost/src/mattermost/monitor-event-plan.ts b/extensions/mattermost/src/mattermost/monitor-event-plan.ts index 6fa3a74927b1..cc3a45ace343 100644 --- a/extensions/mattermost/src/mattermost/monitor-event-plan.ts +++ b/extensions/mattermost/src/mattermost/monitor-event-plan.ts @@ -87,6 +87,8 @@ export async function buildMattermostEventPlan( ParentSessionKey: thread.parentSessionKey, AccountId: route.accountId, ChatType: kind, + ConversationRouteContextObserved: true, + ConversationRoutePeerId: kind === "direct" ? params.senderId : params.channelId, GroupChannel: channelName ? `#${channelName}` : undefined, GroupSpace: teamId, SenderId: params.senderId, @@ -94,6 +96,8 @@ export async function buildMattermostEventPlan( Surface: "mattermost" as const, ReplyToId: thread.effectiveReplyToId, MessageThreadId: thread.effectiveReplyToId, + NativeChannelId: params.channelId, + InboundAccessAuthorized: true, OriginatingChannel: "mattermost" as const, OriginatingTo: to, }), diff --git a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts index 5e1d3c50577d..560de63afd68 100644 --- a/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts @@ -860,6 +860,11 @@ describe("mattermost inbound user posts", () => { expect(ctx?.BodyForAgent).toBe("hello from mattermost"); expect(ctx?.ConversationLabel).toBe("Town Square id:chan-1"); expect(ctx?.MessageSid).toBe("post-inbound-system-event-regular"); + expect(ctx?.ConversationRouteContextObserved).toBe(true); + expect(ctx?.ConversationRoutePeerId).toBe("chan-1"); + expect(ctx?.GroupSpace).toBe("team-1"); + expect(ctx?.NativeChannelId).toBe("chan-1"); + expect(ctx?.InboundAccessAuthorized).toBe(true); expect(ctx?.OriginatingChannel).toBe("mattermost"); expect(ctx?.Provider).toBe("mattermost"); }); diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index d4f66b4c447f..826525683b3e 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -19,7 +19,7 @@ const mockState = vi.hoisted(() => ({ })), resolveCommandText: vi.fn((_trigger: string, text: string) => text), buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })), - resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })), + resolveMattermostModelPickerEntry: vi.fn((): { kind: string } | null => ({ kind: "summary" })), authorizeMattermostCommandInvocation: vi.fn(() => ({ ok: true, commandAuthorized: true, @@ -49,6 +49,7 @@ const mockState = vi.hoisted(() => ({ delete_at: 0, })), listMattermostCommands: vi.fn(async () => []), + dispatchInbound: vi.fn(async () => undefined), })); vi.mock("./runtime-api.js", () => { @@ -83,7 +84,10 @@ vi.mock("../runtime.js", () => ({ }, text: { hasControlCommand: () => false, + resolveTextChunkLimit: () => 4000, + resolveMarkdownTableMode: () => "off", }, + inbound: { dispatch: mockState.dispatchInbound }, pairing: { readAllowFromStore: vi.fn(async () => []), }, @@ -123,6 +127,11 @@ vi.mock("./monitor-auth.js", () => ({ })); vi.mock("./reply-delivery.js", () => ({ + createMattermostReplyDeliveryBarrier: vi.fn(() => ({ + markDeliverySettled: vi.fn(), + resolveTimeoutPolicy: vi.fn(), + trackDmChannelResolution: vi.fn(), + })), deliverMattermostReplyPayload: vi.fn(), })); @@ -225,6 +234,7 @@ describe("slash-http cfg threading", () => { mockState.normalizeMattermostAllowList.mockClear(); mockState.getMattermostCommand.mockClear(); mockState.listMattermostCommands.mockClear(); + mockState.dispatchInbound.mockClear(); ({ createSlashCommandHttpHandler } = await import("./slash-http.js")); }); @@ -267,6 +277,66 @@ describe("slash-http cfg threading", () => { ); }); + it("keeps the slash team scope on direct conversations", async () => { + mockState.resolveMattermostModelPickerEntry.mockReturnValueOnce(null); + mockState.parseSlashCommandPayload.mockReturnValueOnce({ + token: "valid-token", + command: "/oc_status", + text: "status", + channel_id: "dm-1", + user_id: "user-1", + user_name: "alice", + team_id: "team-1", + }); + mockState.getMattermostCommand.mockResolvedValueOnce({ + id: "cmd-status", + token: "valid-token", + team_id: "team-1", + trigger: "oc_status", + method: "P", + url: callbackUrlFixture, + delete_at: 0, + }); + mockState.authorizeMattermostCommandInvocation.mockReturnValueOnce({ + ok: true, + commandAuthorized: true, + channelInfo: { id: "dm-1", type: "D", name: "alice", display_name: "Alice" }, + kind: "direct", + chatType: "direct", + channelName: "alice", + channelDisplay: "Alice", + roomLabel: "Alice", + }); + const handler = createSlashCommandHttpHandler({ + account: accountFixture, + cfg: {} as OpenClawConfig, + runtime: {} as RuntimeEnv, + registeredCommands: [ + { + id: "cmd-status", + teamId: "team-1", + trigger: "oc_status", + token: "valid-token", + url: callbackUrlFixture, + managed: false, + }, + ], + }); + + await handler(createRequest(), createResponse().res); + + expect(mockState.dispatchInbound).toHaveBeenCalledWith( + expect.objectContaining({ + ctxPayload: expect.objectContaining({ + ChatType: "direct", + ConversationRouteContextObserved: true, + ConversationRoutePeerId: "user-1", + GroupSpace: "team-1", + }), + }), + ); + }); + it("rejects a callback when Mattermost reports a different current command token", async () => { mockState.parseSlashCommandPayload.mockReturnValueOnce({ token: "old-token", diff --git a/extensions/mattermost/src/mattermost/slash-http.ts b/extensions/mattermost/src/mattermost/slash-http.ts index dec992722100..db53b80f9091 100644 --- a/extensions/mattermost/src/mattermost/slash-http.ts +++ b/extensions/mattermost/src/mattermost/slash-http.ts @@ -845,7 +845,10 @@ async function handleSlashCommandAsync(params: { SessionKey: route.sessionKey, AccountId: route.accountId, ChatType: chatType, + ConversationRouteContextObserved: true, + ConversationRoutePeerId: kind === "direct" ? senderId : channelId, ConversationLabel: fromLabel, + GroupSpace: teamId, GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined, SenderName: senderName, SenderId: senderId, @@ -855,6 +858,7 @@ async function handleSlashCommandAsync(params: { Timestamp: Date.now(), WasMentioned: true, CommandAuthorized: commandAuthorized, + InboundAccessAuthorized: true, CommandSource: "native" as const, OriginatingChannel: "mattermost" as const, OriginatingTo: to, diff --git a/extensions/memory-core/doctor-contract-api.test.ts b/extensions/memory-core/doctor-contract-api.test.ts index 90d08a55a737..38e45c064bf6 100644 --- a/extensions/memory-core/doctor-contract-api.test.ts +++ b/extensions/memory-core/doctor-contract-api.test.ts @@ -488,6 +488,7 @@ describe("memory-core doctor dreaming migration", () => { afterEach(async () => { resetMemoryCoreDreamingStateForTests(); + resetPluginStateStoreForTests(); await fs.rm(rootDir, { recursive: true, force: true }); }); diff --git a/extensions/msteams/doctor-contract-api.test.ts b/extensions/msteams/doctor-contract-api.test.ts index 0c66fde14c69..9d19d44418eb 100644 --- a/extensions/msteams/doctor-contract-api.test.ts +++ b/extensions/msteams/doctor-contract-api.test.ts @@ -83,6 +83,7 @@ describe("msteams doctor state migration", () => { }); afterEach(async () => { + resetPluginStateStoreForTests(); await fs.rm(stateDir, { recursive: true, force: true }); }); diff --git a/extensions/msteams/src/channel.actions.test.ts b/extensions/msteams/src/channel.actions.test.ts index 94f8fe688c12..de835c6f3ddc 100644 --- a/extensions/msteams/src/channel.actions.test.ts +++ b/extensions/msteams/src/channel.actions.test.ts @@ -81,7 +81,6 @@ const currentChannelId = "conversation:19:ctx@thread.tacv2"; const graphTeamId = "11111111-1111-1111-1111-111111111111"; const graphChannelId = "19:channel-1@thread.tacv2"; const graphChannelTarget = `${graphTeamId}/${graphChannelId}`; -const reactChannelId = "conversation:19:react@thread.tacv2"; const targetChannelId = "conversation:19:target@thread.tacv2"; const editedConversationId = "19:edited@thread.tacv2"; const editedMessageId = "msg-edit-1"; @@ -1056,37 +1055,61 @@ describe("msteamsPlugin message actions", () => { expect(properties).toHaveProperty("pinnedMessageId"); }); - it("reuses currentChannelId fallback for react actions", async () => { - await expectSuccessfulAction({ - mockFn: reactMessageMSTeamsMock, - mockResult: { ok: true }, - action: "react", - cfg: unrestrictedReadCfg, - accountId: "default", - requesterAccountId: "default", - actionParams: { - messageId: padded("msg-3"), - emoji: padded(reactionType), - }, - toolContext: { - currentChannelId: padded(reactChannelId), - }, - runtimeParams: { - to: reactChannelId, - messageId: "msg-3", - reactionType, - }, - details: okMSTeamsActionDetails("react", { - reactionType, - }), - contentDetails: { - channel: "msteams", + it.each([ + { + chatType: "channel", + conversationTarget: "conversation:19:c@thread.tacv2", + currentMessagingTarget: "team-1/19:c@thread.tacv2", + expectedTarget: "team-1/19:c@thread.tacv2", + }, + { + chatType: "group", + conversationTarget: "conversation:19:g@thread.v2", + currentMessagingTarget: undefined, + expectedTarget: "conversation:19:g@thread.v2", + }, + { + chatType: "direct", + conversationTarget: "conversation:a:dm", + currentMessagingTarget: undefined, + expectedTarget: "conversation:a:dm", + }, + ] as const)( + "routes agent react actions and preserves their result shape for $chatType turns", + async ({ chatType, conversationTarget, currentMessagingTarget, expectedTarget }) => { + await expectSuccessfulAction({ + mockFn: reactMessageMSTeamsMock, + mockResult: { ok: true }, action: "react", - reactionType, - ok: true, - }, - }); - }); + cfg: unrestrictedReadCfg, + accountId: "default", + requesterAccountId: "default", + actionParams: { + ...(chatType === "channel" ? { target: conversationTarget } : {}), + messageId: padded("msg-react"), + emoji: padded(reactionType), + }, + toolContext: { + currentChannelProvider: "msteams", + currentChannelId: padded(conversationTarget), + currentChatType: chatType, + ...(currentMessagingTarget ? { currentMessagingTarget } : {}), + }, + runtimeParams: { + to: expectedTarget, + messageId: "msg-react", + reactionType, + }, + details: okMSTeamsActionDetails("react", { reactionType }), + contentDetails: { + channel: "msteams", + action: "react", + reactionType, + ok: true, + }, + }); + }, + ); it("shares the missing target and messageId validation across actions", async () => { await expectActionParamError("delete", {}, deleteMissingTargetError); @@ -1309,46 +1332,6 @@ describe("msteamsPlugin message actions", () => { expect(testCase.runtimeMock).not.toHaveBeenCalled(); }); - it("restores the Graph route from a core-materialized channel target", async () => { - // Core materializes an omitted target from currentChannelId before plugin - // dispatch. Teams must restore the prepared Graph target for channel turns. - const teamChannelTarget = "team-1/19:channel-abc@thread.tacv2"; - const conversationTarget = "conversation:19:channel-abc@thread.tacv2"; - await expectSuccessfulAction({ - mockFn: reactMessageMSTeamsMock, - mockResult: { ok: true }, - action: "react", - cfg: unrestrictedReadCfg, - accountId: "default", - requesterAccountId: "default", - actionParams: { - target: conversationTarget, - messageId: "msg-channel-react", - emoji: reactionType, - }, - toolContext: { - currentChannelProvider: "msteams", - currentChannelId: conversationTarget, - currentChatType: "channel", - currentMessagingTarget: teamChannelTarget, - }, - runtimeParams: { - to: teamChannelTarget, - messageId: "msg-channel-react", - reactionType, - }, - details: okMSTeamsActionDetails("react", { - reactionType, - }), - contentDetails: { - channel: "msteams", - action: "react", - reactionType, - ok: true, - }, - }); - }); - it("preserves explicit teamId/channelId target over toolContext fallback", async () => { // Even in a channel context with a compound currentChannelId, an // explicit `target` param must take precedence. @@ -1384,41 +1367,6 @@ describe("msteamsPlugin message actions", () => { }, }); }); - - it("keeps chat conversation fallback targets as-is for DM react actions", async () => { - // DM/group-chat turns continue to set currentChannelId to a - // `conversation:` string (no `teamId/` prefix), which the runtime - // will resolve through `/chats/{id}`. - const dmFallback = "conversation:19:chat-dm@thread.skype"; - await expectSuccessfulAction({ - mockFn: reactMessageMSTeamsMock, - mockResult: { ok: true }, - action: "react", - cfg: unrestrictedReadCfg, - actionParams: { - messageId: "msg-dm-react", - emoji: reactionType, - }, - toolContext: { - currentChannelId: dmFallback, - currentChatType: "direct", - }, - runtimeParams: { - to: dmFallback, - messageId: "msg-dm-react", - reactionType, - }, - details: okMSTeamsActionDetails("react", { - reactionType, - }), - contentDetails: { - channel: "msteams", - action: "react", - reactionType, - ok: true, - }, - }); - }); }); describe("msteamsPlugin.threading.buildToolContext", () => { diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts index c85a30cf44f7..aad939abe5c4 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts @@ -224,14 +224,14 @@ describe("msteams thread parent context injection", () => { expect(runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1); }); - it("does not fetch parent for DM replyToId", async () => { + it("keeps inbound DM reply targets flat under threaded reply configuration", async () => { fetchChannelMessageMock.mockResolvedValue({ id: "x", from: { user: { displayName: "Alice" } }, body: { content: "should-not-happen", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createMessageHandlerDeps({ - channels: { msteams: { allowFrom: ["*"] } }, + const { conversationStore, deps, enqueueSystemEvent } = createMessageHandlerDeps({ + channels: { msteams: { allowFrom: ["*"], replyStyle: "thread" } }, } as OpenClawConfig); const handler = createMSTeamsMessageHandler(deps); @@ -248,6 +248,26 @@ describe("msteams thread parent context injection", () => { expect(fetchChannelMessageMock).not.toHaveBeenCalled(); expect(findParentSystemEventCall(enqueueSystemEvent)).toBeUndefined(); + expect(conversationStore.upsert).toHaveBeenCalledWith( + "a:dm-conversation", + expect.objectContaining({ + conversation: expect.objectContaining({ + id: "a:dm-conversation", + conversationType: "personal", + }), + }), + ); + expect(conversationStore.upsert).not.toHaveBeenCalledWith( + "a:dm-conversation", + expect.objectContaining({ threadId: expect.any(String) }), + ); + const dispatchContext = + runtimeApiMockState.dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0].ctx; + expect(dispatchContext).toMatchObject({ + To: "user:user-aad", + OriginatingTo: "conversation:a:dm-conversation", + }); + expect(dispatchContext?.MessageThreadId).toBeUndefined(); }); it("does not fetch parent for top-level channel messages without replyToId", async () => { diff --git a/extensions/msteams/src/monitor-handler/reaction-handler.test.ts b/extensions/msteams/src/monitor-handler/reaction-handler.test.ts index 7464a14527b0..d9c37ebe6224 100644 --- a/extensions/msteams/src/monitor-handler/reaction-handler.test.ts +++ b/extensions/msteams/src/monitor-handler/reaction-handler.test.ts @@ -57,7 +57,7 @@ function createReactionTestHarness() { setMSTeamsRuntime(mockRuntime); const cfg: OpenClawConfig = { - channels: { msteams: { allowFrom: ["allowed-aad"] } }, + channels: { msteams: { allowFrom: ["allowed-aad"], groupPolicy: "open" } }, } as OpenClawConfig; const deps = buildDeps(cfg, mockRuntime); @@ -83,14 +83,6 @@ function firstEnqueueLabel(enqueue: ReturnType): string { return label; } -function firstEnqueueMeta(enqueue: ReturnType): Record { - const [, meta] = firstEnqueueCall(enqueue); - if (!meta || typeof meta !== "object") { - throw new Error("Expected enqueueSystemEvent metadata"); - } - return meta as Record; -} - async function invokeReactionEvent( handler: ReturnType, activity: Record, @@ -190,25 +182,34 @@ describe("createMSTeamsReactionHandler", () => { }); describe("inbound reaction events", () => { - it("enqueues system event for reactionsAdded", async () => { - const { handler, enqueue } = createReactionTestHarness(); - await invokeReactionEvent( - handler, - { - reactionsAdded: [{ type: "like" }], - from: { id: "u1", aadObjectId: "allowed-aad", name: "User" }, - replyToId: "msg-1", - }, - "added", - ); + it.each([ + { conversationType: "personal", conversationId: "a:dm" }, + { conversationType: "groupChat", conversationId: "19:g@thread.v2" }, + { conversationType: "channel", conversationId: "19:c@thread.tacv2" }, + ] as const)( + "enqueues the exact inbound reaction event label for $conversationType conversations", + async ({ conversationType, conversationId }) => { + const { handler, enqueue } = createReactionTestHarness(); + await invokeReactionEvent( + handler, + { + reactionsAdded: [{ type: "like" }], + from: { id: "u1", aadObjectId: "allowed-aad", name: "User" }, + conversation: { id: conversationId, conversationType }, + replyToId: "msg-1", + }, + "added", + ); - expect(enqueue).toHaveBeenCalledOnce(); - const label = firstEnqueueLabel(enqueue); - const meta = firstEnqueueMeta(enqueue); - expect(label).toContain("added"); - expect(meta.sessionKey).toBe("test-session"); - expect(meta.contextKey).toContain("added"); - }); + expect(enqueue).toHaveBeenCalledExactlyOnceWith( + "Teams reaction 👍 added by User on message msg-1", + { + sessionKey: "test-session", + contextKey: `msteams:reaction:${conversationId}:msg-1:allowed-aad:like:added`, + }, + ); + }, + ); it("enqueues system event for reactionsRemoved", async () => { const { handler, enqueue } = createReactionTestHarness(); diff --git a/extensions/msteams/src/send-context.test.ts b/extensions/msteams/src/send-context.test.ts index 512dc6ac74ce..f41fdbc92362 100644 --- a/extensions/msteams/src/send-context.test.ts +++ b/extensions/msteams/src/send-context.test.ts @@ -3,9 +3,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { MSTeamsConfig, OpenClawConfig } from "../runtime-api.js"; import type { StoredConversationReference } from "./conversation-store.js"; import { resolveMSTeamsSendContext } from "./send-context.js"; +import { sendMessageMSTeams } from "./send.js"; const sendContextMockState = vi.hoisted(() => { const getAccessToken = vi.fn(); + const createActivity = vi.fn(async () => ({ id: "message-1" })); + const getActivities = vi.fn(() => ({ create: createActivity })); const store = { upsert: vi.fn(), get: vi.fn(), @@ -15,9 +18,20 @@ const sendContextMockState = vi.hoisted(() => { }; return { store, - loadMSTeamsSdkWithAuth: vi.fn(async () => ({ app: { id: "mock-app" } })), + loadMSTeamsSdkWithAuth: vi.fn(async () => ({ + app: { + id: "mock-app", + api: { + serviceUrl: "https://smba.trafficmanager.net/amer/", + conversations: { activities: getActivities }, + }, + }, + })), createMSTeamsTokenProvider: vi.fn(() => ({ getAccessToken })), + createActivity, + getActivities, getAccessToken, + logInfo: vi.fn(), logWarn: vi.fn(), }; }); @@ -29,7 +43,10 @@ vi.mock("./conversation-store-state.js", () => ({ vi.mock("./runtime.js", () => ({ getMSTeamsRuntime: () => ({ logging: { - getChildLogger: () => ({ warn: sendContextMockState.logWarn }), + getChildLogger: () => ({ + info: sendContextMockState.logInfo, + warn: sendContextMockState.logWarn, + }), }, }), })); @@ -94,7 +111,10 @@ beforeEach(() => { sendContextMockState.store.findPreferredDmByUserId.mockReset(); sendContextMockState.loadMSTeamsSdkWithAuth.mockClear(); sendContextMockState.createMSTeamsTokenProvider.mockClear(); + sendContextMockState.createActivity.mockClear(); + sendContextMockState.getActivities.mockClear(); sendContextMockState.getAccessToken.mockReset(); + sendContextMockState.logInfo.mockReset(); sendContextMockState.logWarn.mockReset(); vi.unstubAllEnvs(); }); @@ -162,6 +182,56 @@ describe("resolveMSTeamsSendContext", () => { expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); + it.each([ + { conversationType: "personal", conversationId: "a:dm", expectedSuffix: "" }, + { conversationType: "groupChat", conversationId: "19:g@thread.v2", expectedSuffix: "" }, + { + conversationType: "channel", + conversationId: "19:c@thread.tacv2", + expectedSuffix: ";messageid=root-1", + }, + ] as const)( + "sends explicit threaded $conversationType targets to the correct SDK conversation", + async ({ conversationType, conversationId, expectedSuffix }) => { + sendContextMockState.store.get.mockResolvedValue( + channelRef({ + serviceUrl: "https://smba.trafficmanager.net/amer/", + threadId: "root-1", + conversation: { id: conversationId, conversationType }, + }), + ); + + await sendMessageMSTeams({ + cfg: { + channels: { + msteams: { + enabled: true, + appId: "app-id", + appPassword: "app-password", + tenantId: "tenant-id", + replyStyle: "thread", + }, + }, + } as OpenClawConfig, + to: `conversation:${conversationId};messageid=root-1`, + text: "parity proof", + }); + + expect(sendContextMockState.store.get).toHaveBeenCalledWith(conversationId); + expect(sendContextMockState.getActivities).toHaveBeenCalledExactlyOnceWith( + `${conversationId}${expectedSuffix}`, + ); + expect(sendContextMockState.createActivity).toHaveBeenCalledWith( + expect.objectContaining({ + conversation: expect.objectContaining({ + id: `${conversationId}${expectedSuffix}`, + conversationType, + }), + }), + ); + }, + ); + it("resolves Graph team/channel targets through the stored channel conversation", async () => { sendContextMockState.store.get.mockResolvedValue( channelRef({ diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 0a20239cf55b..e9175aef0bab 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -1084,9 +1084,7 @@ describe("ollama plugin", () => { const context = createDynamicModelContext("llama3.2:latest"); try { - await provider.prepareDynamicModel?.(context as never); - - const resolved = provider.resolveDynamicModel?.(context as never); + const resolved = await provider.prepareDynamicModel?.(context as never); expect(resolved?.provider).toBe("ollama"); expect(resolved?.id).toBe("llama3.2:latest"); expect(resolved?.api).toBe("ollama"); @@ -1134,9 +1132,7 @@ describe("ollama plugin", () => { }; const context = createDynamicModelContext("qwen3-coder:cloud", config); - await provider.prepareDynamicModel?.(context as never); - - const resolved = provider.resolveDynamicModel?.(context as never); + const resolved = await provider.prepareDynamicModel?.(context as never); expect(resolved?.provider).toBe("ollama"); expect(resolved?.id).toBe("qwen3-coder:cloud"); expect(resolved?.api).toBe("openai-completions"); @@ -1172,7 +1168,7 @@ describe("ollama plugin", () => { mockDiscoveredOllamaProvider([], { baseUrl, once: true }); const context = createDynamicModelContext("private-dynamic-model", config); - await provider.prepareDynamicModel?.(context as never); + const resolved = await provider.prepareDynamicModel?.(context as never); expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, { quiet: true, @@ -1181,10 +1177,10 @@ describe("ollama plugin", () => { expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith(baseUrl, "private-dynamic-model", { apiKey: "dynamic-discovery-access", }); - expect(provider.resolveDynamicModel?.(context as never)?.id).toBe("private-dynamic-model"); + expect(resolved?.id).toBe("private-dynamic-model"); }); - it("scopes dynamic Ollama model caches to the effective credential", async () => { + it("returns the exact prepared Ollama model for concurrent credential profiles", async () => { const provider = registerProvider(); const baseUrl = "https://shared-dynamic-ollama.example.com"; const modelId = "tenant-dynamic-model"; @@ -1197,25 +1193,34 @@ describe("ollama plugin", () => { }); const discoveredFor = (name: string) => ({ baseUrl, - api: "ollama", + api: "ollama" as const, models: [{ id: modelId, name, contextWindow: 8192, maxTokens: 2048 }], }); - buildOllamaProviderMock - .mockResolvedValueOnce(discoveredFor("First tenant model")) - .mockResolvedValueOnce(discoveredFor("Second tenant model")); + const completeDiscovery: Array<(result: ReturnType) => void> = []; + buildOllamaProviderMock.mockImplementation( + () => + new Promise>((resolve) => { + completeDiscovery.push(resolve); + }), + ); - for (const config of [configFor("first-tenant-access"), configFor("second-tenant-access")]) { - await provider.prepareDynamicModel?.(createDynamicModelContext(modelId, config) as never); - } + const prepareFor = (apiKey: string, authProfileId: string) => + provider.prepareDynamicModel?.({ + ...createDynamicModelContext(modelId, configFor(apiKey)), + authProfileId, + } as never); + const firstPrepared = prepareFor("first-tenant-access", "ollama:first"); + const secondPrepared = prepareFor("second-tenant-access", "ollama:second"); - const resolveFor = (apiKey: string) => - provider.resolveDynamicModel?.( - createDynamicModelContext(modelId, configFor(apiKey)) as never, - ); + await vi.waitFor(() => expect(buildOllamaProviderMock).toHaveBeenCalledTimes(2)); + completeDiscovery[1]?.(discoveredFor("Second tenant model")); + await expect(secondPrepared).resolves.toMatchObject({ + id: modelId, + name: "Second tenant model", + }); + completeDiscovery[0]?.(discoveredFor("First tenant model")); + await expect(firstPrepared).resolves.toMatchObject({ id: modelId, name: "First tenant model" }); - expect(resolveFor("first-tenant-access")?.name).toBe("First tenant model"); - expect(resolveFor("second-tenant-access")?.name).toBe("Second tenant model"); - expect(resolveFor("unprepared-tenant-access")).toBeUndefined(); expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, { quiet: true, apiKey: "first-tenant-access", @@ -1250,8 +1255,9 @@ describe("ollama plugin", () => { const context = createDynamicModelContext("secretref-dynamic-model", config); try { - await provider.prepareDynamicModel?.(context as never); + const resolved = await provider.prepareDynamicModel?.(context as never); + expect(resolved?.id).toBe("secretref-dynamic-model"); expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, { quiet: true, apiKey: secretValue, @@ -1290,7 +1296,7 @@ describe("ollama plugin", () => { }); try { - await provider.prepareDynamicModel?.(context as never); + await expect(provider.prepareDynamicModel?.(context as never)).resolves.toBeUndefined(); expect(buildOllamaProviderMock).not.toHaveBeenCalled(); expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); @@ -1301,7 +1307,7 @@ describe("ollama plugin", () => { } }); - it("invalidates managed dynamic model caches when their SecretRef stops resolving", async () => { + it("keeps rotated managed SecretRefs request-owned and fails closed when unavailable", async () => { const provider = registerProvider(); const baseUrl = "https://managed-dynamic-ollama.example.com"; const modelId = "managed-private-model"; @@ -1319,20 +1325,32 @@ describe("ollama plugin", () => { }; resolveConfiguredSecretInputStringMock .mockResolvedValueOnce({ value: "managed-dynamic-access" }) + .mockResolvedValueOnce({ value: "rotated-managed-access" }) .mockResolvedValueOnce({ unresolvedRefReason: "managed credential is unavailable" }); mockDiscoveredOllamaProvider( [{ id: modelId, name: "Managed private model", contextWindow: 8192 }], { baseUrl, once: true }, ); + mockDiscoveredOllamaProvider( + [{ id: modelId, name: "Rotated managed model", contextWindow: 8192 }], + { baseUrl, once: true }, + ); const context = createDynamicModelContext(modelId, config); - await provider.prepareDynamicModel?.(context as never); - expect(provider.resolveDynamicModel?.(context as never)?.id).toBe(modelId); - - await provider.prepareDynamicModel?.(context as never); - - expect(provider.resolveDynamicModel?.(context as never)).toBeUndefined(); - expect(buildOllamaProviderMock).toHaveBeenCalledOnce(); + await expect(provider.prepareDynamicModel?.(context as never)).resolves.toMatchObject({ + id: modelId, + name: "Managed private model", + }); + await expect(provider.prepareDynamicModel?.(context as never)).resolves.toMatchObject({ + id: modelId, + name: "Rotated managed model", + }); + await expect(provider.prepareDynamicModel?.(context as never)).resolves.toBeUndefined(); + expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(2, baseUrl, { + quiet: true, + apiKey: "rotated-managed-access", + }); + expect(buildOllamaProviderMock).toHaveBeenCalledTimes(2); }); it("isolates identically named managed SecretRefs by their resolved configuration", async () => { @@ -1371,15 +1389,11 @@ describe("ollama plugin", () => { ); const contextFor = (config: typeof firstConfig) => createDynamicModelContext(modelId, config); - await provider.prepareDynamicModel?.(contextFor(firstConfig) as never); - await provider.prepareDynamicModel?.(contextFor(secondConfig) as never); + const firstModel = await provider.prepareDynamicModel?.(contextFor(firstConfig) as never); + const secondModel = await provider.prepareDynamicModel?.(contextFor(secondConfig) as never); - expect(provider.resolveDynamicModel?.(contextFor(firstConfig) as never)?.name).toBe( - "First managed tenant model", - ); - expect(provider.resolveDynamicModel?.(contextFor(secondConfig) as never)?.name).toBe( - "Second managed tenant model", - ); + expect(firstModel?.name).toBe("First managed tenant model"); + expect(secondModel?.name).toBe("Second managed tenant model"); expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, { quiet: true, apiKey: "first-managed-tenant-access", @@ -1412,13 +1426,12 @@ describe("ollama plugin", () => { const context = createDynamicModelContext("deepseek-v4-pro:cloud"); try { - await provider.prepareDynamicModel?.(context as never); + const resolved = await provider.prepareDynamicModel?.(context as never); expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( "http://127.0.0.1:11434", "deepseek-v4-pro:cloud", ); - const resolved = provider.resolveDynamicModel?.(context as never); expect(resolved?.provider).toBe("ollama"); expect(resolved?.id).toBe("deepseek-v4-pro:cloud"); expect(resolved?.api).toBe("ollama"); @@ -1971,9 +1984,7 @@ describe("ollama plugin", () => { const context = createDynamicModelContext("depseek-v4-pro:cloud"); try { - await provider.prepareDynamicModel?.(context as never); - - expect(provider.resolveDynamicModel?.(context as never)).toBeUndefined(); + await expect(provider.prepareDynamicModel?.(context as never)).resolves.toBeUndefined(); } finally { if (previous === undefined) { delete process.env.OLLAMA_API_KEY; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 9fb7b6c74ab4..40dd4356de03 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,5 +1,4 @@ // Ollama plugin entrypoint registers its OpenClaw integration. -import { createHash } from "node:crypto"; import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; @@ -161,8 +160,6 @@ function classifyOllamaFailoverReason(errorMessage: string): "server_error" | un return errorMessage.trim() === OLLAMA_INCOMPLETE_STREAM_ERROR ? "server_error" : undefined; } -const dynamicModelCache = new Map(); -const dynamicManagedCredentialFingerprints = new WeakMap>(); const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0].id}`; const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4; const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8; @@ -426,44 +423,6 @@ async function discoverAppGuidedOllamaModel( }; } -function buildDynamicManagedSecretScope( - provider: string, - baseUrl: string | undefined, - configuredApiKey: unknown, -): string | undefined { - const secretRef = coerceSecretRef(configuredApiKey); - if (!secretRef || secretRef.source === "env") { - return undefined; - } - return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${secretRef.source}\0${secretRef.provider}\0${secretRef.id}`; -} - -function buildDynamicCacheKey( - provider: string, - baseUrl: string | undefined, - configuredApiKey: unknown, - config?: OpenClawConfig, -): string { - const secretRef = coerceSecretRef(configuredApiKey); - const managedSecretScope = buildDynamicManagedSecretScope(provider, baseUrl, configuredApiKey); - const apiKey = readUsableOllamaShowApiKey({ - env: process.env, - allowAmbientEnvFallback: !isLocalOllamaBaseUrl(baseUrl), - explicitApiKey: configuredApiKey, - }); - // Managed secrets resolve asynchronously; retain their resolved fingerprint - // per config so synchronous lookups cannot cross secret-provider ownership. - const managedCredentialFingerprint = - managedSecretScope && config - ? dynamicManagedCredentialFingerprints.get(config)?.get(managedSecretScope) - : undefined; - const credentialScope = - apiKey ?? (secretRef ? `${secretRef.source}\0${secretRef.provider}\0${secretRef.id}` : ""); - const credentialFingerprint = - managedCredentialFingerprint ?? createHash("sha256").update(credentialScope).digest("hex"); - return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${credentialFingerprint}`; -} - function hasOllamaDiscoverySignal(providerConfig: ModelProviderConfig | undefined): boolean { return ( Boolean(process.env.OLLAMA_API_KEY?.trim()) || @@ -1173,20 +1132,9 @@ export default definePluginEntry({ providerId: ctx.provider, }); if (!hasOllamaDiscoverySignal(providerConfig)) { - return; + return undefined; } const baseUrl = readProviderBaseUrl(providerConfig); - const managedSecretScope = buildDynamicManagedSecretScope( - ctx.provider, - baseUrl, - providerConfig?.apiKey, - ); - let dynamicCacheKey = buildDynamicCacheKey( - ctx.provider, - baseUrl, - providerConfig?.apiKey, - ctx.config, - ); let discoveryApiKey: string | undefined; if (providerConfig?.apiKey !== undefined && providerConfig.apiKey !== null) { const resolved = await resolveConfiguredSecretInputString({ @@ -1197,11 +1145,7 @@ export default definePluginEntry({ unresolvedReasonStyle: "detailed", }); if (resolved.unresolvedRefReason) { - dynamicModelCache.delete(dynamicCacheKey); - if (managedSecretScope && ctx.config) { - dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope); - } - return; + return undefined; } const resolvedApiKey = readConfiguredOllamaApiKey(resolved.value); const configuredSecretRef = coerceSecretRef(providerConfig.apiKey); @@ -1211,38 +1155,11 @@ export default definePluginEntry({ ? readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY) : readConcreteOllamaApiKey(resolvedApiKey); if (configuredSecretRef && !discoveryApiKey) { - dynamicModelCache.delete(dynamicCacheKey); - if (managedSecretScope && ctx.config) { - dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope); - } - return; + return undefined; } } else if (!isLocalOllamaBaseUrl(baseUrl)) { discoveryApiKey = readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY); } - if (managedSecretScope && ctx.config && discoveryApiKey) { - let fingerprints = dynamicManagedCredentialFingerprints.get(ctx.config); - if (!fingerprints) { - fingerprints = new Map(); - dynamicManagedCredentialFingerprints.set(ctx.config, fingerprints); - } - const resolvedCredentialFingerprint = createHash("sha256") - .update(discoveryApiKey) - .digest("hex"); - if ( - fingerprints.has(managedSecretScope) && - fingerprints.get(managedSecretScope) !== resolvedCredentialFingerprint - ) { - dynamicModelCache.delete(dynamicCacheKey); - } - fingerprints.set(managedSecretScope, resolvedCredentialFingerprint); - dynamicCacheKey = buildDynamicCacheKey( - ctx.provider, - baseUrl, - providerConfig?.apiKey, - ctx.config, - ); - } const provider = await buildLocalOllamaProvider(baseUrl, { quiet: true, ...(discoveryApiKey ? { apiKey: discoveryApiKey } : {}), @@ -1257,42 +1174,21 @@ export default definePluginEntry({ }), api: dynamicApi, }; - const dynamicModels = (dynamicProvider.models ?? []).map((model) => - toDynamicOllamaModel({ + const discoveredModel = dynamicProvider.models?.find((model) => model.id === ctx.modelId); + if (discoveredModel) { + return toDynamicOllamaModel({ provider: ctx.provider, providerConfig: dynamicProvider, - model, - }), - ); - if (!dynamicModels.some((model) => model.id === ctx.modelId)) { - const requestedModel = await resolveRequestedDynamicOllamaModel({ - provider: ctx.provider, - providerConfig: dynamicProvider, - modelId: ctx.modelId, - showApiKey: discoveryApiKey, - capContextTokens: true, + model: discoveredModel, }); - if (requestedModel) { - dynamicModels.push(requestedModel); - } } - dynamicModelCache.set(dynamicCacheKey, dynamicModels); - }, - resolveDynamicModel: (ctx) => { - const providerConfig = resolveConfiguredOllamaProviderConfig({ - config: ctx.config, - providerId: ctx.provider, + return await resolveRequestedDynamicOllamaModel({ + provider: ctx.provider, + providerConfig: dynamicProvider, + modelId: ctx.modelId, + showApiKey: discoveryApiKey, + capContextTokens: true, }); - return dynamicModelCache - .get( - buildDynamicCacheKey( - ctx.provider, - readProviderBaseUrl(providerConfig), - providerConfig?.apiKey, - ctx.config, - ), - ) - ?.find((model) => model.id === ctx.modelId); }, buildUnknownModelHint: () => "Ollama requires authentication to be registered as a provider. " + diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index e297b341604c..239f194fe913 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1,5 +1,6 @@ import { expectDefined } from "@openclaw/normalization-core"; // Ollama tests cover stream runtime plugin behavior. +import { withProviderAcceptanceObserver } from "openclaw/plugin-sdk/provider-transport-runtime"; import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -456,6 +457,30 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { }, ); + it("reports the real HTTP response before consuming native Ollama output", async () => { + await withSuccessfulOllamaFetch(async () => { + const acceptanceObserver = vi.fn(); + const onResponse = vi.fn(); + const options = withProviderAcceptanceObserver({ onResponse }, acceptanceObserver); + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options, + }); + + await collectStreamEvents(stream); + + expect(acceptanceObserver).toHaveBeenCalledWith({ + kind: "http_response", + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + expect(onResponse).toHaveBeenCalledWith( + { status: 200, headers: { "content-type": "application/x-ndjson" } }, + expect.objectContaining({ provider: "custom-ollama" }), + ); + }); + }); + it("passes resolved provider request timeouts to native Ollama chat fetches", async () => { await withMockNdjsonFetch( [ diff --git a/extensions/ollama/src/stream.runtime.ts b/extensions/ollama/src/stream.runtime.ts index 702d853d0b33..a1803ba7ee2f 100644 --- a/extensions/ollama/src/stream.runtime.ts +++ b/extensions/ollama/src/stream.runtime.ts @@ -26,6 +26,7 @@ import { formatToolResultText, isImageWithMediaPayload, MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, + notifyProviderHttpResponse, parseTerminalToolCallArguments, } from "openclaw/plugin-sdk/provider-transport-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; @@ -80,36 +81,6 @@ function throwIfOllamaStreamAborted(signal?: AbortSignal): void { } } -async function runOllamaResponseHook(params: { - hook: (() => void | Promise) | undefined; - signal: AbortSignal | undefined; -}): Promise { - const { hook, signal } = params; - if (!hook) { - return; - } - throwIfOllamaStreamAborted(signal); - if (!signal) { - await hook(); - return; - } - let onAbort: (() => void) | undefined; - try { - await Promise.race([ - Promise.resolve().then(hook), - new Promise((_resolve, reject) => { - onAbort = () => reject(new Error("Request was aborted")); - signal.addEventListener("abort", onAbort, { once: true }); - }), - ]); - } finally { - if (onAbort) { - signal.removeEventListener("abort", onAbort); - } - } - throwIfOllamaStreamAborted(signal); -} - function createOllamaStreamCooperativeScheduler( signal?: AbortSignal, ): OllamaStreamCooperativeScheduler { @@ -1079,26 +1050,7 @@ function createRawOllamaStreamFn( }); try { - const responseHook = options?.onResponse; - try { - await runOllamaResponseHook({ - hook: responseHook - ? () => - responseHook( - { - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - }, - model, - ) - : undefined, - signal: options?.signal, - }); - } catch (error) { - // A pending body cancel must not stall release or the terminal error. - void response.body?.cancel().catch(() => undefined); - throw error; - } + await notifyProviderHttpResponse({ options, response, model }); if (!response.ok) { const errorText = await readResponseTextLimited( response, diff --git a/extensions/openai/openai-chatgpt-provider.ts b/extensions/openai/openai-chatgpt-provider.ts index abc8cffb168d..148e69e43617 100644 --- a/extensions/openai/openai-chatgpt-provider.ts +++ b/extensions/openai/openai-chatgpt-provider.ts @@ -327,6 +327,25 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS, cost: OPENAI_CODEX_GPT_54_MINI_COST, }; + } else if ( + ctx.agentRuntimeId === "codex" && + ctx.authProfileId === undefined && + ctx.authProfileMode === undefined && + ctx.providerConfig?.auth === undefined + ) { + // Codex owns its account-scoped model catalog. When that catalog is not yet + // available, keep the requested identity intact and let the native runtime + // decide whether the account can actually use it. + templateIds = OPENAI_CODEX_GPT_56_MODEL_IDS; + patch = { + reasoning: true, + input: ["text", "image"], + thinkingLevelMap: OPENAI_CODEX_GPT_56_THINKING_LEVEL_MAP, + compat: { + supportsReasoningEffort: true, + supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }, + }; } else { return undefined; } @@ -365,6 +384,8 @@ function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, contextTokens: patch?.contextTokens, maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, + ...(patch?.thinkingLevelMap ? { thinkingLevelMap: patch.thinkingLevelMap } : {}), + ...(patch?.compat ? { compat: patch.compat } : {}), } as ProviderRuntimeModel) ); } diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts index 828469f6a9a3..ffafd1088f8a 100644 --- a/extensions/openai/openai-provider.test.ts +++ b/extensions/openai/openai-provider.test.ts @@ -1766,6 +1766,74 @@ describe("buildOpenAIProvider", () => { ).toEqual({ effort: "high", transport: "sse" }); }); + it("delegates an unlisted first-party model to its explicitly selected Codex runtime", () => { + const provider = buildOpenAIProvider(); + const model = provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-future", + modelRegistry: { find: () => null }, + agentRuntimeId: "codex", + } as never); + + expect(model).toMatchObject({ + provider: "openai", + id: "gpt-future", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] }, + }); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + api: model?.api, + compat: model?.compat, + } as never) + ?.levels.map((level) => level.id), + ).toContain("max"); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + } as never) + ?.levels.map((level) => level.id), + ).toEqual(expect.arrayContaining(["xhigh", "max"])); + }); + + it("does not invent an unlisted model for authored Platform credentials", () => { + const provider = buildOpenAIProvider(); + + expect( + provider.resolveDynamicModel?.({ + provider: "openai", + modelId: "gpt-future", + modelRegistry: { find: () => null }, + agentRuntimeId: "codex", + authProfileId: "openai:platform", + authProfileMode: "api_key", + providerConfig: { + auth: "api-key", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }, + } as never), + ).toBeUndefined(); + expect( + provider + .resolveThinkingProfile?.({ + provider: "openai", + modelId: "gpt-future", + agentRuntime: "codex", + api: "openai-responses", + } as never) + ?.levels.map((level) => level.id), + ).not.toContain("max"); + }); + it("restores gpt-5.3-codex-spark only through ChatGPT/Codex OAuth routing", () => { const provider = buildOpenAIProvider(); diff --git a/extensions/openai/thinking-policy.ts b/extensions/openai/thinking-policy.ts index 648ef4feccaf..a354e5565e01 100644 --- a/extensions/openai/thinking-policy.ts +++ b/extensions/openai/thinking-policy.ts @@ -110,6 +110,12 @@ function buildOpenAIThinkingProfile(params: { (agentRuntime === "openclaw" || agentRuntime === "auto" || (agentRuntime === "codex" && codexSupportsUltra)); + const nativeCodexNeedsAccountEffortValidation = + agentRuntime === "codex" && + params.compat?.supportedReasoningEfforts === undefined && + (params.api === undefined || params.api === "openai-chatgpt-responses") && + !matchesExactOrPrefix(params.modelId, params.xhighModelIds) && + !modelId.startsWith("gpt-5.6"); const defaultLevel = isGpt56Variant ? "medium" : undefined; const fallbackLevels: ProviderThinkingProfile["levels"] = [ ...OPENAI_THINKING_BASE_LEVELS, @@ -118,6 +124,9 @@ function buildOpenAIThinkingProfile(params: { : []), ...(supportsMax ? [{ id: "max" as const }] : []), ...(supportsUltra ? [{ id: "ultra" as const }] : []), + ...(nativeCodexNeedsAccountEffortValidation + ? [{ id: "xhigh" as const }, { id: "max" as const }] + : []), ]; const levels = agentRuntime === "codex" && resolvedCodexEfforts !== undefined diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index d50989b5cbcc..a09f37cb051b 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -13,6 +13,7 @@ import { installProviderHttpMockCleanup, } from "openclaw/plugin-sdk/provider-http-test-mocks"; import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts"; +import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { beforeAll, describe, expect, it, vi } from "vitest"; const { @@ -171,6 +172,10 @@ describe("openai video generation provider", () => { } else { process.env.OPENAI_API_KEY = previousOpenAIKey; } + // Saving the profile store opens the per-agent database under the temporary agent + // dir, and clearing the snapshots does not release it, so Windows fails the removal + // with EBUSY unless the cached handles are closed first. + closeOpenClawAgentDatabasesForTest(); fs.rmSync(agentDir, { recursive: true, force: true }); } }); diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts index 6665a76d3cc8..dda23a88d0c7 100644 --- a/extensions/qa-lab/src/coverage-report.test.ts +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -156,9 +156,13 @@ function scenarioWithCoverage(params: { } describe("qa coverage report", () => { + let catalogInventory: ReturnType | undefined; + const readCatalogInventory = () => + (catalogInventory ??= buildQaCoverageInventory(readQaScenarioPack().scenarios)); + it("groups scenario coverage metadata by theme and surface", () => { const scenarios = readQaScenarioPack().scenarios; - const inventory = buildQaCoverageInventory(scenarios); + const inventory = readCatalogInventory(); expect(inventory.scenarioCount).toBeGreaterThan(0); expect(inventory.coverageIdCount).toBeGreaterThan(0); @@ -312,7 +316,7 @@ describe("qa coverage report", () => { { assert: { expr: expect.stringContaining("config.blockedMarker") } }, ]); - const inventory = buildQaCoverageInventory(scenarios); + const inventory = readCatalogInventory(); const coverage = expectDefined( inventory.coverageIds.find((candidate) => candidate.id === coverageId), "session turn ordering coverage inventory", @@ -368,9 +372,7 @@ describe("qa coverage report", () => { }); it("renders a compact markdown inventory", () => { - const report = renderQaCoverageMarkdownReport( - buildQaCoverageInventory(readQaScenarioPack().scenarios), - ); + const report = renderQaCoverageMarkdownReport(readCatalogInventory()); expect(report).toContain("# QA Coverage Inventory"); expect(report).toContain("- Missing coverage metadata: 0"); @@ -454,13 +456,16 @@ describe("qa coverage report", () => { it("finds every cataloged native scenario by its authoritative execution path", () => { const scenarios = readQaScenarioPack().scenarios; + const matchesByExecutionPath = new Map(); for (const scenario of scenarios) { - if (scenario.execution.kind !== "flow") { - expect( - findQaScenarioMatches(scenarios, scenario.execution.path).map(({ id }) => id), - scenario.id, - ).toContain(scenario.id); + if (scenario.execution.kind === "flow") { + continue; } + const matchedIds = + matchesByExecutionPath.get(scenario.execution.path) ?? + findQaScenarioMatches(scenarios, scenario.execution.path).map(({ id }) => id); + matchesByExecutionPath.set(scenario.execution.path, matchedIds); + expect(matchedIds, scenario.id).toContain(scenario.id); } }); diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index bb520cbc7084..9467834cd53f 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -205,6 +205,38 @@ afterEach(() => { }); describe("QA Lab runner browser interactions", () => { + it("labels every execution configuration select", async () => { + const root = await mountRunner({ + alternateModel: "mock-openai/gpt-5.6-luna-alt", + channel: null, + channelDriver: "qa-channel", + evidenceMode: "full", + fastMode: false, + primaryModel: "mock-openai/gpt-5.6-luna", + profile: "all", + providerMode: "mock-openai", + runtimePair: null, + runtimePairLane: null, + scenarioIds: ["dm-chat-baseline"], + }); + + root.querySelector("[data-sidebar-panel='config']")?.click(); + const selects = [...root.querySelectorAll(".config-field select")]; + + expect(selects).toHaveLength(9); + expect(selects.map((select) => select.labels?.[0]?.textContent?.trim())).toEqual([ + "Profile", + "Provider lane", + "Channel driver", + "Execution channel", + "Evidence mode", + "Runtime pair", + "Runtime-pair lane", + "Primary model", + "Alternate model", + ]); + }); + it("sends group conversation messages from the interactive chat composer", async () => { const root = await mountRunner( { diff --git a/extensions/qa-lab/web/src/ui-render-shell.ts b/extensions/qa-lab/web/src/ui-render-shell.ts index e760372c9960..1ff93ac76e85 100644 --- a/extensions/qa-lab/web/src/ui-render-shell.ts +++ b/extensions/qa-lab/web/src/ui-render-shell.ts @@ -72,7 +72,7 @@ function renderModelSelect(params: { } return `
- ${esc(params.label)} + ${profiles .map( @@ -128,14 +128,14 @@ export function renderSidebar(state: UiState): string {
- Provider lane +
- Channel driver +
- Execution channel +
- Evidence mode +
- Runtime pair +
- Runtime-pair lane + - props.onRunsFiltersChange({ - cronRunsSortDir: (e.target as HTMLSelectElement).value as CronSortDir, - })} - > - - - +
+ ) => { + const value = event.detail.item.value; + if (value === "asc" || value === "desc") { + void props.onRunsFiltersChange({ cronRunsSortDir: value }); + } + }} + > + + + ${t("cron.runs.newestFirst")} + + + + ${t("cron.runs.oldestFirst")} + + + +
${runs.length === 0 ? hasRunFilters ? html`
${t("cron.runs.noMatching")}
` : html`
-
${t("cron.runs.emptyTitle")}
-
${t("cron.runs.emptyHint")}
+
+ ${props.conditionActivity + ? t("cron.runs.emptyConditionTitle") + : t("cron.runs.emptyTitle")} +
+
+ ${props.conditionActivity + ? conditionEmptyHint(props.conditionActivity) + : t("cron.runs.emptyHint")} +
` : html` diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index b79e2c425a72..00cdb56ad1ab 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -116,7 +116,22 @@ describe("cron view list pane", () => { expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); - it("renders table rows with schedule and status cells and selects on click", () => { + it("does not expose table rows without complete table semantics", () => { + const container = renderView({ jobs: [createJob("job-1")] }); + + for (const row of container.querySelectorAll('[role="row"]')) { + expect(row.closest('[role="table"], [role="grid"], [role="treegrid"]')).not.toBeNull(); + expect( + Array.from(row.children).every((child) => + child.matches( + '[role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"]', + ), + ), + ).toBe(true); + } + }); + + it("renders table rows with independent native buttons for opening tasks", () => { const onSelectJob = vi.fn(); const job = createJob("job-1", { trigger: { script: "json({ fire: true })" }, @@ -134,10 +149,13 @@ describe("cron view list pane", () => { const rows = Array.from(container.querySelectorAll(".cron-table__row")); expect(rows).toHaveLength(3); + expect(rows[0]?.getAttribute("role")).toBeNull(); expect(rows[0]?.textContent).toContain("Cron 0 9 * * *"); expect(rows[1]?.classList.contains("cron-table__row--paused")).toBe(true); expect(rows[1]?.textContent).toContain("Paused"); - expect(rows[2]?.querySelector(".cron-table__dot--error")).not.toBeNull(); + expect(rows[2]?.querySelector(".cron-table__state--error")?.getAttribute("aria-label")).toBe( + "Error", + ); expect(rows[2]?.querySelector(".cron-last-glyph--error")).not.toBeNull(); expect(rows[2]?.querySelector(".cron-table__last-run")?.getAttribute("aria-label")).toBe( "Error", @@ -148,7 +166,7 @@ describe("cron view list pane", () => { "Trigger configured", ); - (rows[1] as HTMLElement).click(); + getElement(rows[1] as Element, ".cron-table__name", HTMLButtonElement).click(); expect(onSelectJob).toHaveBeenCalledWith(paused); }); @@ -337,9 +355,11 @@ describe("cron view selects", () => { it("shows persisted non-first values in jobs filters and runs sort", () => { const activity = renderView({ listTab: "activity", runsSortDir: "asc" }); - const sort = getElement(activity, "select.cron-run-sort", HTMLSelectElement); - expect(sort.value).toBe("asc"); - expect(sort.querySelector('option[value="asc"]')?.hasAttribute("selected")).toBe(true); + const sort = getElement(activity, ".cron-run-sort", HTMLButtonElement); + expect(sort.textContent).toContain("Oldest first"); + expect( + activity.querySelector('wa-dropdown-item[value="asc"]')?.getAttribute("aria-current"), + ).toBe("true"); const tasks = renderView({ jobsLastStatusFilter: "error" }); const lastStatus = getElement( tasks, diff --git a/ui/src/pages/cron/view.ts b/ui/src/pages/cron/view.ts index 972b76f661ac..1bad08ad1c1a 100644 --- a/ui/src/pages/cron/view.ts +++ b/ui/src/pages/cron/view.ts @@ -436,7 +436,10 @@ export function renderCron(props: CronProps) { function renderAdminRequired(props: CronProps) { return props.canManage ? nothing - : html`
${t("cron.adminRequired")}
`; + : html`
+ + ${t("cron.adminRequired")} +
`; } // ── List view ── @@ -474,17 +477,23 @@ function renderListView(props: CronProps) { !hasAnyJobsFilters && props.canManage; const children = [ - renderSettingsSection({}, renderCronStats(props)), - renderAdminRequired(props), - props.status && !props.status.enabled - ? html` -
- ${t("cron.list.schedulerOff")} ${t("cron.runNotStarted.stopped")} -
- ` - : nothing, - props.error ? html`
${props.error}
` : nothing, - renderToolbar(props, hasAdvancedJobsFilters), + html` +
+
+ ${renderCronStats(props)} ${renderAdminRequired(props)} +
+ ${props.status && !props.status.enabled + ? html` +
+ ${t("cron.list.schedulerOff")} + ${t("cron.runNotStarted.stopped")} +
+ ` + : nothing} + ${props.error ? html`
${props.error}
` : nothing} + ${renderToolbar(props, hasAdvancedJobsFilters)} +
+ `, html`
- ${renderListTabs(props)} +
+ ${renderListTabs(props)} +
+ + ${props.canManage + ? html` + + ` + : nothing} +
+
${props.listTab === "tasks" ? html` - ${renderSegmented({ - value: props.jobsEnabledFilter, - options: ENABLED_TABS.map((tab) => ({ - value: tab.value, - label: t(tab.labelKey), - testId: `cron-tab-${tab.value}`, - })), - ariaLabel: t("cron.tabs.filterLabel"), - onChange: (value) => void props.onJobsFiltersChange({ cronJobsEnabledFilter: value }), - })} - `; } @@ -713,13 +727,13 @@ function renderJobsFilterPopover(props: CronProps, active: boolean) { function renderJobsTable(props: CronProps, hasAnyJobsFilters: boolean) { return html` -
-
+
+
${t("cron.jobs.name")} ${t("cron.jobs.schedule")} ${t("cron.jobs.nextRun")} ${t("cron.jobs.lastRun")} - + ${props.canManage ? html`` : nothing}
${props.jobs.length === 0 ? html` @@ -753,57 +767,52 @@ function renderJobRow(job: CronJob, props: CronProps) { const description = job.description?.trim(); const nextRunAtMs = job.state?.nextRunAtMs; const hasNextRun = typeof nextRunAtMs === "number" && Number.isFinite(nextRunAtMs); - const dotVariant = isCronJobActiveFailure(job) - ? "cron-table__dot--error" - : job.enabled - ? "cron-table__dot--active" - : ""; + const nextRun = isCronJobRunning(job) + ? html`${t("cron.runs.runStatusRunning")}` + : hasNextRun + ? formatRelativeTimestamp(nextRunAtMs) + : t("common.na"); return html`
props.onSelectJob(job)} - @keydown=${(e: KeyboardEvent) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - props.onSelectJob(job); - } - }} > - - - ${job.name} - ${description - ? html` - · ${description} - ` - : nothing} - ${job.trigger ? renderTriggerIndicator() : nothing} - ${job.enabled ? nothing : renderDisabledNote(job)} - - ${formatCronSchedule(job)} - - ${isCronJobRunning(job) - ? html`${t("cron.runs.runStatusRunning")}` - : hasNextRun - ? formatRelativeTimestamp(nextRunAtMs) - : t("common.na")} - - ${renderLastRunCell(job)} - e.stopPropagation()} - @keydown=${(e: Event) => e.stopPropagation()} - > - ${props.canManage - ? html` + + ${renderJobCell("cron-table__schedule", t("cron.jobs.schedule"), formatCronSchedule(job))} + ${renderJobCell("cron-table__next", t("cron.jobs.nextRun"), nextRun)} + ${renderJobCell("cron-table__last", t("cron.jobs.lastRun"), renderLastRunCell(job))} + ${props.canManage + ? html` + e.stopPropagation()}>
`; } +function renderJobCell(className: string, label: string, value: unknown) { + return html` + ${label} + ${value} + `; +} + +function renderJobStateIndicator(job: CronJob) { + const autoDisabled = job.state?.autoDisabled; + const state = isCronJobRunning(job) + ? { + className: "cron-table__state--running", + iconName: "loader" as const, + label: t("cron.runs.runStatusRunning"), + } + : autoDisabled + ? { + className: "cron-table__state--error", + iconName: "lock" as const, + label: disabledNoteLabel(job), + } + : isCronJobActiveFailure(job) + ? { + className: "cron-table__state--error", + iconName: "alertTriangle" as const, + label: t("cron.runs.runStatusError"), + } + : !job.enabled + ? { + className: "cron-table__state--paused", + iconName: "pause" as const, + label: t("cron.list.paused"), + } + : { + className: "cron-table__state--active", + iconName: null, + label: t("cron.detail.active"), + }; + return html`${state.iconName + ? icon(state.iconName) + : html``}`; +} + function renderTriggerIndicator() { const label = t("cron.form.triggerConfigured"); return html`${t("cron.list.paused")}`; } - const label = t( - autoDisabled.reason === "schedule-errors" - ? "cron.list.autoDisabledScheduleErrors" - : "cron.list.autoDisabledRunFailures", - { count: String(autoDisabled.consecutiveErrors) }, - ); + const label = disabledNoteLabel(job); const lastError = job.state?.lastError?.trim(); return html``; } +function disabledNoteLabel(job: CronJob) { + const autoDisabled = job.state?.autoDisabled; + if (!autoDisabled) { + return t("cron.list.paused"); + } + return t( + autoDisabled.reason === "schedule-errors" + ? "cron.list.autoDisabledScheduleErrors" + : "cron.list.autoDisabledRunFailures", + { count: String(autoDisabled.consecutiveErrors) }, + ); +} + function renderLastRunCell(job: CronJob) { const status = resolveCronJobLastRunStatus(job); const lastRunAtMs = job.state?.lastRunAtMs; @@ -963,6 +1029,13 @@ function renderDetailView(props: CronProps, mode: CronPanelMode) { const selectedJob = mode === "job" ? (props.editingJob ?? undefined) : undefined; const hasDetailTabs = mode === "job" && Boolean(selectedJob); const showHistory = mode === "job" && props.detailTab === "history"; + const conditionActivity = selectedJob?.trigger + ? { + checkCount: selectedJob.state?.triggerEvalCount ?? 0, + lastCheckedAtMs: selectedJob.state?.lastTriggerEvalAtMs, + lastFiredAtMs: selectedJob.state?.lastTriggerFireAtMs, + } + : undefined; const children = [ html`
@@ -991,7 +1064,9 @@ function renderDetailView(props: CronProps, mode: CronPanelMode) { ${showHistory ? renderSettingsSection( { title: t("cron.detail.historyTitle") }, - html`
${renderRunsSection(props)}
`, + html`
+ ${renderRunsSection({ ...props, conditionActivity })} +
`, ) : renderEditor(props, mode)}
diff --git a/ui/src/pages/debug/debug-overlay-sections.ts b/ui/src/pages/debug/debug-overlay-sections.ts index 1293f411614a..7c69cc7fe47b 100644 --- a/ui/src/pages/debug/debug-overlay-sections.ts +++ b/ui/src/pages/debug/debug-overlay-sections.ts @@ -1,5 +1,5 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import { html, type TemplateResult } from "lit"; +import { html, nothing, type TemplateResult } from "lit"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ApplicationGateway } from "../../app/gateway.ts"; import { t } from "../../i18n/index.ts"; @@ -13,6 +13,8 @@ import { type CommandLaneDiagnostics, } from "../../lib/gateway-diagnostics.ts"; import { renderCommandLaneRows } from "./lane-table.ts"; +import "./sparkline-tile.ts"; +import type { SparklineSample } from "./sparkline-tile.ts"; type DebugOverlaySectionContext = { client: GatewayBrowserClient; @@ -23,7 +25,7 @@ type TypedDebugOverlaySectionDescriptor = { id: string; titleKey: string; load: (context: DebugOverlaySectionContext, signal: AbortSignal) => Promise; - render: (value: T) => TemplateResult; + render: (value: T, statusHistory: readonly DebugOverlayStatusSample[]) => TemplateResult; }; export type DebugOverlaySectionDescriptor = TypedDebugOverlaySectionDescriptor; @@ -33,24 +35,36 @@ function defineDebugOverlaySection( ): DebugOverlaySectionDescriptor { return { ...descriptor, - render: (value) => { + render: (value, statusHistory) => { // SAFETY: This closure keeps each descriptor's load result paired with its own renderer. - return descriptor.render(value as T); + return descriptor.render(value as T, statusHistory); }, }; } type EventLoopSnapshot = { utilization?: number; + cpuCoreRatio?: number; delayP99Ms?: number; delayMaxMs?: number; + reasons?: string[]; }; -type StatusSectionValue = { +export type DebugOverlayStatusSnapshot = { eventLoop?: EventLoopSnapshot; + processMemory?: { + rssBytes: number; + heapUsedBytes: number; + heapTotalBytes: number; + }; uptimeMs?: number; }; +export type DebugOverlayStatusSample = { + at: number; + status: DebugOverlayStatusSnapshot; +}; + type ActiveSession = { key?: string; sessionId?: string; @@ -76,41 +90,86 @@ function renderLanes(diagnostics: CommandLaneDiagnostics): TemplateResult { `; } -function renderStatus(status: StatusSectionValue): TemplateResult { +function collectSamples( + history: readonly DebugOverlayStatusSample[], + read: (status: DebugOverlayStatusSnapshot) => number | undefined, +): SparklineSample[] { + const samples: SparklineSample[] = []; + for (const entry of history) { + const value = read(entry.status); + if (typeof value === "number" && Number.isFinite(value)) { + samples.push({ value, at: entry.at }); + } + } + return samples; +} + +function formatPercent(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function formatMegabytes(bytes: number): string { + return t("debug.overlay.memoryMb", { value: String(Math.round(bytes / 1_048_576)) }); +} + +function formatDelayMs(value: number): string { + return formatDurationCompact(value) ?? t("common.na"); +} + +function renderStatus( + status: DebugOverlayStatusSnapshot, + history: readonly DebugOverlayStatusSample[], +): TemplateResult { const eventLoop = status.eventLoop; - const utilization = + const reasons = eventLoop?.reasons ?? []; + const cpuDegraded = reasons.includes("cpu") || reasons.includes("event_loop_utilization"); + const delayDegraded = reasons.includes("event_loop_delay"); + const loopSub = typeof eventLoop?.utilization === "number" - ? `${Math.round(eventLoop.utilization * 100)}%` - : t("common.na"); - const delay = - typeof eventLoop?.delayP99Ms === "number" - ? formatDurationCompact(eventLoop.delayP99Ms) - : t("common.na"); - const maxDelay = + ? t("debug.overlay.loopShort", { value: formatPercent(eventLoop.utilization) }) + : ""; + const heapSub = + typeof status.processMemory?.heapUsedBytes === "number" + ? t("debug.overlay.heapShort", { value: formatMegabytes(status.processMemory.heapUsedBytes) }) + : ""; + const maxSub = typeof eventLoop?.delayMaxMs === "number" - ? formatDurationCompact(eventLoop.delayMaxMs) - : t("common.na"); + ? t("debug.overlay.maxShort", { value: formatDelayMs(eventLoop.delayMaxMs) }) + : ""; return html` -
-
-
${t("debug.overlay.utilization")}
-
${utilization}
-
-
-
${t("debug.overlay.delayP99")}
-
${delay}
-
-
-
${t("debug.overlay.delayMax")}
-
${maxDelay}
-
- ${typeof status.uptimeMs === "number" - ? html`
-
${t("debug.overlay.uptime")}
-
${formatDurationHuman(status.uptimeMs)}
-
` - : ""} -
+
+ sample.eventLoop?.cpuCoreRatio)} + .format=${formatPercent} + .floorMax=${1} + > + sample.processMemory?.rssBytes)} + .format=${formatMegabytes} + autorange + > + sample.eventLoop?.delayP99Ms)} + .format=${formatDelayMs} + .floorMax=${20} + > +
+ ${typeof status.uptimeMs === "number" + ? html`` + : nothing} `; } @@ -156,11 +215,16 @@ export const DEBUG_OVERLAY_SECTIONS: readonly DebugOverlaySectionDescriptor[] = id: "status", titleKey: "debug.overlay.status", load: async (context, signal) => { - const value = await context.client.request("status", {}, { signal }); + const value = await context.client.request( + "status", + {}, + { signal }, + ); return { eventLoop: value.eventLoop, + processMemory: value.processMemory, ...(typeof value.uptimeMs === "number" ? { uptimeMs: value.uptimeMs } : {}), - } satisfies StatusSectionValue; + } satisfies DebugOverlayStatusSnapshot; }, render: renderStatus, }), diff --git a/ui/src/pages/debug/debug-overlay.ts b/ui/src/pages/debug/debug-overlay.ts index e116ed6b82bf..f0983126622d 100644 --- a/ui/src/pages/debug/debug-overlay.ts +++ b/ui/src/pages/debug/debug-overlay.ts @@ -9,9 +9,12 @@ import "../../styles/debug.css"; import { DEBUG_OVERLAY_SECTIONS, type DebugOverlaySectionDescriptor, + type DebugOverlayStatusSample, + type DebugOverlayStatusSnapshot, } from "./debug-overlay-sections.ts"; const DEBUG_OVERLAY_POLL_INTERVAL_MS = 2000; +const DEBUG_OVERLAY_HISTORY_LIMIT = 90; type SectionState = | { status: "loading" } @@ -28,6 +31,7 @@ export class DebugOverlay extends OpenClawLightDomElement { private requestController: AbortController | null = null; private requestActive = false; private requestGeneration = 0; + private statusHistory: DebugOverlayStatusSample[] = []; private eventLogSource: ApplicationContext["gateway"] | null = null; private unsubscribeEventLog: (() => void) | null = null; private readonly polling = new PollController( @@ -54,6 +58,7 @@ export class DebugOverlay extends OpenClawLightDomElement { return; } this.open = true; + this.statusHistory = []; document.addEventListener("keydown", this.handleKeydown, true); this.syncEventLogSubscription(); this.sections = new Map( @@ -134,6 +139,14 @@ export class DebugOverlay extends OpenClawLightDomElement { if (!this.open || generation !== this.requestGeneration) { return; } + if (id === "status" && state.status === "ready") { + // SAFETY: The status descriptor owns this section id and always returns a status snapshot. + const snapshot = state.value as DebugOverlayStatusSnapshot; + this.statusHistory = [ + ...this.statusHistory.slice(-(DEBUG_OVERLAY_HISTORY_LIMIT - 1)), + { at: Date.now(), status: snapshot }, + ]; + } const next = new Map(this.sections); next.set(id, state); this.sections = next; @@ -148,7 +161,7 @@ export class DebugOverlay extends OpenClawLightDomElement { ? html`
${t("common.loading")}
` : state.status === "unavailable" ? html`
${t("debug.overlay.unavailable")}
` - : section.render(state.value)} + : section.render(state.value, this.statusHistory)} `; } diff --git a/ui/src/pages/debug/sparkline-tile.ts b/ui/src/pages/debug/sparkline-tile.ts new file mode 100644 index 000000000000..e28b9e968771 --- /dev/null +++ b/ui/src/pages/debug/sparkline-tile.ts @@ -0,0 +1,171 @@ +import { html, nothing, svg } from "lit"; +import { property, state as litState } from "lit/decorators.js"; +import { formatDurationCompact } from "../../lib/format.ts"; +import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; + +export type SparklineSample = { value: number; at: number }; + +// Chart geometry in viewBox units; the svg stretches (preserveAspectRatio="none"), +// so hover/now markers are positioned with percentages in HTML instead. +const CHART_WIDTH = 100; +const CHART_HEIGHT = 40; +const CHART_TOP_PAD = 4; + +// Gradient defs need document-unique ids: the overlay renders one tile per vital +// into the light DOM, so a shared static id would collide across instances. +let gradientCounter = 0; + +function nextGradientId(): string { + gradientCounter += 1; + return `debug-vital-gradient-${gradientCounter}`; +} + +/** Stat tile with an embedded area sparkline and pointer scrubbing. */ +class DebugSparklineTile extends OpenClawLightDomElement { + @property() label = ""; + @property() sub = ""; + @property({ attribute: false }) samples: readonly SparklineSample[] = []; + @property({ attribute: false }) format: (value: number) => string = String; + /** Lower bound for the y-axis top, so quiet metrics keep a calm scale. */ + @property({ attribute: false }) floorMax = 0; + /** Auto-range the baseline near the series minimum instead of zero, so + * large-but-steady metrics (RSS) still show their trend shape. */ + @property({ type: Boolean }) autorange = false; + + @litState() private hoverIndex: number | null = null; + + private readonly gradientId = nextGradientId(); + + private get yRange(): { min: number; span: number } { + let max = this.floorMax; + let min = Number.POSITIVE_INFINITY; + for (const sample of this.samples) { + if (sample.value > max) { + max = sample.value; + } + if (sample.value < min) { + min = sample.value; + } + } + if (!Number.isFinite(min)) { + min = 0; + } + if (!this.autorange) { + return { min: 0, span: max > 0 ? max : 1 }; + } + // Sit the baseline a bit below the observed minimum so the shape stays a + // trend line, not a wall, while never faking a drop to zero. + const spread = Math.max(max - min, max * 0.02, 1e-9); + const base = Math.max(min - spread * 0.5, 0); + return { min: base, span: Math.max(max - base, 1e-9) }; + } + + private toY(value: number): number { + const { min, span } = this.yRange; + const usable = CHART_HEIGHT - CHART_TOP_PAD; + const ratio = Math.min(Math.max((value - min) / span, 0), 1); + return CHART_HEIGHT - ratio * usable; + } + + private readonly handlePointerMove = (event: PointerEvent): void => { + if (this.samples.length < 2) { + return; + } + const target = event.currentTarget; + if (!(target instanceof HTMLElement)) { + return; + } + const ratio = event.offsetX / Math.max(target.clientWidth, 1); + const index = Math.round(ratio * (this.samples.length - 1)); + this.hoverIndex = Math.min(Math.max(index, 0), this.samples.length - 1); + }; + + private readonly handlePointerLeave = (): void => { + this.hoverIndex = null; + }; + + private renderChart() { + const samples = this.samples; + if (samples.length < 2) { + return nothing; + } + const step = CHART_WIDTH / (samples.length - 1); + const points = samples + .map((sample, index) => `${index * step},${this.toY(sample.value)}`) + .join(" "); + const last = samples.at(-1); + if (!last) { + return nothing; + } + const lastY = this.toY(last.value); + const hover = this.hoverIndex !== null ? samples[this.hoverIndex] : undefined; + const hoverLeft = this.hoverIndex !== null ? (this.hoverIndex / (samples.length - 1)) * 100 : 0; + return html` +
+ + ${hover + ? html` +
+
+ ` + : html` +
+ `} +
+ `; + } + + override render() { + const samples = this.samples; + const current = samples.at(-1); + const hover = this.hoverIndex !== null ? samples[this.hoverIndex] : null; + const shown = hover ?? current; + const age = + hover && current && current.at > hover.at + ? formatDurationCompact(current.at - hover.at) + : null; + return html` +
+ ${this.label} + ${this.sub ? html`${this.sub}` : nothing} +
+
+ ${shown ? this.format(shown.value) : "–"} + ${age ? html`−${age}` : nothing} +
+ ${this.renderChart()} + `; + } +} + +if (!customElements.get("openclaw-debug-sparkline")) { + customElements.define("openclaw-debug-sparkline", DebugSparklineTile); +} diff --git a/ui/src/pages/debug/view.test.ts b/ui/src/pages/debug/view.test.ts index bb237a3e757e..d668d6f13015 100644 --- a/ui/src/pages/debug/view.test.ts +++ b/ui/src/pages/debug/view.test.ts @@ -1,10 +1,11 @@ // Control UI tests cover debug behavior. -import { render } from "lit"; +import { render, type LitElement } from "lit"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts"; import { i18n } from "../../i18n/index.ts"; import { createStorageMock } from "../../test-helpers/storage.ts"; +import "./debug-overlay.ts"; import "./debug-page.ts"; import { renderDebug } from "./view.ts"; @@ -34,6 +35,12 @@ type TestDebugPage = HTMLElement & { loadDiagnostics: () => Promise; }; +type TestDebugOverlay = HTMLElement & { + readonly updateComplete: Promise; + context: ApplicationContext; + toggle: () => void; +}; + function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -44,9 +51,9 @@ function deferred() { return { promise, resolve, reject }; } -async function mountDebugPage( +function createDebugApplicationContext( request: (method: string) => Promise, -): Promise { +): ApplicationContext { const client = { request } as unknown as GatewayBrowserClient; const gateway = { snapshot: { phase: "connected", client } as ApplicationGatewaySnapshot, @@ -58,8 +65,14 @@ async function mountDebugPage( state: { selectedId: "main" }, subscribe: () => () => undefined, } as unknown as ApplicationContext["agentSelection"]; + return { agentSelection, basePath: "", gateway } as ApplicationContext; +} + +async function mountDebugPage( + request: (method: string) => Promise, +): Promise { const page = document.createElement("openclaw-debug-page") as TestDebugPage; - page.context = { agentSelection, basePath: "", gateway } as ApplicationContext; + page.context = createDebugApplicationContext(request); document.body.append(page); await vi.waitFor(() => expect(page.debugStatus).not.toBeNull()); return page; @@ -336,3 +349,96 @@ describe("DebugPage", () => { expect(page.debugCallError).toContain("manual request failed"); }); }); + +describe("DebugOverlay", () => { + it("graphs bounded status samples without clamping CPU and resets history on reopen", async () => { + vi.useFakeTimers(); + let sampleCount = 0; + const request = vi.fn(async (method: string) => { + if (method === "status") { + sampleCount += 1; + return { + eventLoop: { + utilization: 0.42, + cpuCoreRatio: 1 + sampleCount / 10, + delayP99Ms: 10 + sampleCount, + delayMaxMs: 87, + }, + processMemory: { + rssBytes: (400 + sampleCount) * 1_048_576, + heapUsedBytes: 100 * 1_048_576, + heapTotalBytes: 200 * 1_048_576, + }, + }; + } + if (method === "sessions.list") { + return { sessions: [] }; + } + return diagnosticResponse(method); + }); + const overlay = document.createElement("openclaw-debug-overlay") as TestDebugOverlay; + overlay.context = createDebugApplicationContext(request); + document.body.append(overlay); + + try { + overlay.toggle(); + await vi.advanceTimersByTimeAsync(0); + await overlay.updateComplete; + + const vitalUpdated = async () => { + await overlay.updateComplete; + for (const tile of overlay.querySelectorAll("openclaw-debug-sparkline")) { + await (tile as LitElement).updateComplete; + } + }; + await vitalUpdated(); + + // One sample: tiles show current values, charts wait for a second point. + expect(overlay.querySelectorAll(".debug-overlay__vital")).toHaveLength(3); + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--cpu"))).toContain( + "loop 42%", + ); + expect(overlay.querySelector(".debug-vital__chart")).toBeNull(); + + await vi.advanceTimersByTimeAsync(2_000); + await vitalUpdated(); + + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--cpu"))).toContain("120%"); + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--memory"))).toContain( + "402 MB", + ); + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--memory"))).toContain( + "heap 100 MB", + ); + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--delay"))).toContain( + "12ms", + ); + expect(normalizedText(overlay.querySelector(".debug-overlay__vital--delay"))).toContain( + "max 87ms", + ); + expect(overlay.querySelectorAll(".debug-vital__chart")).toHaveLength(3); + // Healthy event loop: no tile carries the degraded tint. + expect(overlay.querySelector(".debug-overlay__vital[data-degraded]")).toBeNull(); + + await vi.advanceTimersByTimeAsync(180_000); + await vitalUpdated(); + + const points = overlay + .querySelector(".debug-overlay__vital--cpu polyline") + ?.getAttribute("points") + ?.split(" "); + expect(points).toHaveLength(90); + + overlay.toggle(); + overlay.toggle(); + await vi.advanceTimersByTimeAsync(0); + await vitalUpdated(); + + expect(overlay.querySelectorAll(".debug-overlay__vital")).toHaveLength(3); + expect(overlay.querySelector(".debug-vital__chart")).toBeNull(); + } finally { + overlay.remove(); + vi.useRealTimers(); + } + }); +}); diff --git a/ui/src/pages/gateway-source-replacement.test.ts b/ui/src/pages/gateway-source-replacement.test.ts index 538e468aaac0..462c2bb1489f 100644 --- a/ui/src/pages/gateway-source-replacement.test.ts +++ b/ui/src/pages/gateway-source-replacement.test.ts @@ -7,6 +7,8 @@ import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { ApplicationContext, ApplicationGatewaySnapshot } from "../app/context.ts"; import { clawhubVerdictKey } from "../lib/skills/index.ts"; import { waitForFast } from "../test-helpers/wait-for.ts"; +import type { ModelProvidersData } from "./model-providers/load.ts"; +import type { ModelProvidersRouteData } from "./model-providers/route.ts"; import type { SessionsRouteData } from "./sessions/route.ts"; import type { SkillsRouteData } from "./skills/skills-page.ts"; import { createSkill } from "./skills/view.test-support.ts"; @@ -15,6 +17,7 @@ import type { UsageRouteData } from "./usage/usage-page.ts"; import "./cron/cron-page.ts"; import "./debug/debug-page.ts"; import "./logs/logs-page.ts"; +import "./model-providers/model-providers-page.ts"; import "./sessions/sessions-page.ts"; import "./skills/skills-page.ts"; import "./tasks/tasks-page.ts"; @@ -83,6 +86,7 @@ function contextWithClient( connected?: boolean; agentsList?: unknown; ensureList?: () => Promise; + selectedAgentId?: string | null; } = {}, ): ApplicationContext { const subscribe = () => () => undefined; @@ -97,13 +101,24 @@ function contextWithClient( }, agentIdentity: { get: () => undefined, ensure: vi.fn(async () => undefined), subscribe }, agentSelection: { - state: { selectedId: null, scopeId: null }, + state: { + selectedId: options.selectedAgentId ?? null, + scopeId: options.selectedAgentId ?? null, + }, set: vi.fn(), setScope: vi.fn(), subscribe, }, channels: { subscribe }, - runtimeConfig: { state: { configSnapshot: null }, subscribe }, + runtimeConfig: { + state: { configSnapshot: {}, configLoading: false }, + ensureLoaded: vi.fn(async () => undefined), + subscribe, + }, + overlays: { + snapshot: { updateRunning: false, updateReconciliationPending: false }, + subscribe, + }, sessions: { state: { result: null, loading: false }, list: vi.fn(async () => null), @@ -157,7 +172,7 @@ function createPage(tagName: string, context: ApplicationContext): TestPage { async function replaceContext( page: TestPage, replacementClient: GatewayBrowserClient, - options: { connected?: boolean; agentsList?: unknown } = {}, + options: { connected?: boolean; agentsList?: unknown; selectedAgentId?: string | null } = {}, ): Promise { page.remove(); page.context = contextWithClient(replacementClient, options); @@ -297,7 +312,7 @@ describe("gateway source replacement across reconnect with a reused client", () }, result, costSummary: null, - providerUsage: null, + providerUsage: { ok: true, value: { updatedAt: 1, providers: [] } }, loadedAtMs: Date.now(), error: null, }; @@ -404,7 +419,7 @@ describe("gateway source replacement across reconnect with a reused client", () }, result, costSummary: null, - providerUsage: null, + providerUsage: { ok: true, value: { updatedAt: 1, providers: [] } }, loadedAtMs: Date.now(), error: null, }; @@ -446,6 +461,91 @@ describe("gateway source replacement across reconnect with a reused client", () await waitForFast(() => expect(request).toHaveBeenCalledTimes(12)); await waitForFast(() => expect(page.usageLoading).toBe(false)); }); + + it("discards Model Providers work from a replaced source that reuses its client", async () => { + const staleAuth = deferred(); + let authCalls = 0; + const request = vi.fn(async (method: string) => { + if (method === "models.authStatus") { + authCalls += 1; + return authCalls === 1 ? staleAuth.promise : { ts: 2, providers: [] }; + } + if (method === "models.list") { + return { models: [] }; + } + if (method === "config.get") { + return { config: {}, hash: "hash" }; + } + if (method === "usage.status") { + return { updatedAt: 2, providers: [] }; + } + if (method === "sessions.usage") { + return { aggregates: { byProvider: [] } }; + } + return {}; + }); + const client = { request } as unknown as GatewayBrowserClient; + const agentsList = { defaultId: "main", agents: [{ id: "main" }] }; + const page = createPage( + "openclaw-model-providers-page", + contextWithClient(client, { connected: true, agentsList, selectedAgentId: "main" }), + ) as TestPage & { data: ModelProvidersData | null }; + document.body.append(page); + await waitForFast(() => expect(authCalls).toBe(1)); + + await replaceContext(page, client, { connected: true, agentsList, selectedAgentId: "main" }); + await waitForFast(() => expect(page.data?.authStatus?.ts).toBe(2)); + + staleAuth.resolve({ ts: 1, providers: [] }); + await Promise.resolve(); + await Promise.resolve(); + expect(page.data?.authStatus?.ts).toBe(2); + }); + + it("rejects Model Providers route data from an earlier same-client gateway epoch", async () => { + const request = vi.fn(async (method: string) => { + if (method === "models.authStatus") { + return { ts: 2, providers: [] }; + } + if (method === "models.list") { + return { models: [] }; + } + if (method === "config.get") { + return { config: {}, hash: "fresh" }; + } + if (method === "usage.status") { + return { updatedAt: 2, providers: [] }; + } + if (method === "sessions.usage") { + return { aggregates: { byProvider: [] } }; + } + return {}; + }); + const client = { request } as unknown as GatewayBrowserClient; + const agentsList = { defaultId: "main", agents: [{ id: "main" }] }; + const context = contextWithClient(client, { + connected: true, + agentsList, + selectedAgentId: "main", + }); + const staleData = { authStatus: { ts: 1, providers: [] } } as unknown as ModelProvidersData; + const page = createPage("openclaw-model-providers-page", context) as TestPage & { + routeData: ModelProvidersRouteData; + data: ModelProvidersData | null; + }; + page.routeData = { + gateway: context.gateway, + gatewaySnapshot: { ...context.gateway.snapshot }, + data: staleData, + client, + agentId: "main", + }; + + document.body.append(page); + await waitForFast(() => expect(page.data?.authStatus?.ts).toBe(2)); + expect(page.data).not.toBe(staleData); + }); + it("preserves matching skills route data on the first bind", async () => { const request = vi.fn(); const client = { request } as unknown as GatewayBrowserClient; diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts b/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts new file mode 100644 index 000000000000..92460e2a3160 --- /dev/null +++ b/ui/src/pages/lobsterdex/lobsterdex-page.e2e.test.ts @@ -0,0 +1,196 @@ +import { expect, it } from "vitest"; +import { createControlUiE2eSuite } from "../../e2e/control-ui-e2e-suite.test-support.ts"; +import { installMockGateway } from "../../test-helpers/control-ui-e2e.ts"; + +type ClipboardFaultState = { + asyncWrites: string[]; + execSucceeds: boolean; + legacyWrites: string[]; + mode: "defer" | "missing" | "reject"; + pendingRejects: Array<(reason?: unknown) => void>; +}; + +const suite = createControlUiE2eSuite({ + name: "Control UI Lobsterdex clipboard E2E", + startServerBeforeBrowser: true, + unavailableMessage: (executablePath) => + `Playwright Chromium is not available at ${executablePath}`, +}); + +suite.define(() => { + it("falls back, announces failure, and keeps feedback with the newest copy", async () => { + await suite.withPage( + { + hasTouch: true, + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 844, width: 390 }, + }, + async ({ page }) => { + await page.addInitScript(() => { + const state: ClipboardFaultState = { + asyncWrites: [], + execSucceeds: true, + legacyWrites: [], + mode: "reject", + pendingRejects: [], + }; + Object.defineProperty(window, "lobsterdexClipboardFault", { value: state }); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + get: () => + state.mode === "missing" + ? undefined + : { + writeText(text: string) { + state.asyncWrites.push(text); + if (state.mode === "reject") { + return Promise.reject( + new DOMException("Clipboard access denied", "NotAllowedError"), + ); + } + return new Promise((_resolve, reject) => { + state.pendingRejects.push(reject); + }); + }, + }, + }); + document.execCommand = (command: string) => { + if (command !== "copy") { + return false; + } + state.legacyWrites.push( + document.querySelector("textarea")?.value ?? "", + ); + return state.execSucceeds; + }; + }); + const gateway = await installMockGateway(page); + const response = await page.goto(`${suite.server.baseUrl}settings/lobsterdex`); + expect(response?.status()).toBe(200); + + const pageRoot = page.locator("openclaw-lobsterdex-page"); + const copyButtons = pageRoot.getByRole("button", { name: "Copy link" }); + await expect.poll(() => copyButtons.count()).toBeGreaterThan(1); + const crimson = copyButtons.nth(0); + const blue = copyButtons.nth(1); + const crimsonUrl = `${new URL(suite.server.baseUrl).origin}/settings/lobsterdex#lobsterdex-crimson`; + const blueUrl = `${new URL(suite.server.baseUrl).origin}/settings/lobsterdex#lobsterdex-blue`; + const requestsBeforeCopy = (await gateway.getRequests()).length; + + await crimson.focus(); + await page.keyboard.press("Enter"); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault, + ), + ) + .toMatchObject({ asyncWrites: [crimsonUrl], legacyWrites: [crimsonUrl] }); + + await page.evaluate(() => { + ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault.mode = "missing"; + }); + await blue.tap(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault, + ), + ) + .toMatchObject({ asyncWrites: [crimsonUrl], legacyWrites: [crimsonUrl, blueUrl] }); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.mode = "reject"; + state.execSucceeds = false; + }); + await crimson.tap(); + await expect.poll(() => pageRoot.getByRole("alert").textContent()).toBe("Copy failed"); + await pageRoot.evaluate((element) => { + const parent = element.parentElement; + if (!parent) { + throw new Error("Lobsterdex page has no route host"); + } + element.remove(); + parent.append(element); + }); + await expect.poll(() => pageRoot.getByRole("alert").count()).toBe(0); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.mode = "defer"; + state.execSucceeds = true; + state.asyncWrites = []; + state.legacyWrites = []; + state.pendingRejects = []; + }); + await crimson.focus(); + await page.keyboard.press("Enter"); + await blue.tap(); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.pendingRejects.length, + ), + ) + .toBe(2); + expect(await pageRoot.getByRole("alert").count()).toBe(0); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.pendingRejects[1]?.(new DOMException("Newer write rejected", "NotAllowedError")); + }); + await blue.locator('path[d="M20 6 9 17l-5-5"]').waitFor(); + expect(await crimson.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + await expect + .poll(() => + page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.legacyWrites, + ), + ) + .toEqual([blueUrl]); + + await page.evaluate(() => { + const state = ( + window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState } + ).lobsterdexClipboardFault; + state.pendingRejects[0]?.(new DOMException("Older write rejected", "NotAllowedError")); + }); + await page.evaluate( + () => + new Promise((resolve) => { + window.setTimeout(resolve, 0); + }), + ); + expect(await crimson.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + expect(await blue.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(1); + expect( + await page.evaluate( + () => + (window as typeof window & { lobsterdexClipboardFault: ClipboardFaultState }) + .lobsterdexClipboardFault.legacyWrites, + ), + ).toEqual([blueUrl]); + await expect.poll(() => blue.locator('path[d="M20 6 9 17l-5-5"]').count()).toBe(0); + expect((await gateway.getRequests()).length).toBe(requestsBeforeCopy); + }, + ); + }); +}); diff --git a/ui/src/pages/lobsterdex/lobsterdex-page.ts b/ui/src/pages/lobsterdex/lobsterdex-page.ts index 22798f196289..1ccef5c766c0 100644 --- a/ui/src/pages/lobsterdex/lobsterdex-page.ts +++ b/ui/src/pages/lobsterdex/lobsterdex-page.ts @@ -5,14 +5,18 @@ import { getLobsterdexEntries } from "../../components/lobster-dex.ts"; import type { LobsterPetPaletteId } from "../../components/lobster-pet-contract.ts"; import { LOBSTER_PET_PALETTES } from "../../components/lobster-pet.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; +import { copyToClipboard } from "../../lib/clipboard.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; -import { renderLobsterdex } from "./view.ts"; +import { renderLobsterdex, type LobsterdexCopyFeedback } from "./view.ts"; class LobsterdexPage extends OpenClawLightDomElement { - @state() private copiedPaletteId: LobsterPetPaletteId | null = null; + @state() private copyFeedback: LobsterdexCopyFeedback | null = null; + private copyAttempt = 0; private copyResetTimer: number | null = null; override disconnectedCallback(): void { + this.copyAttempt += 1; + this.copyFeedback = null; if (this.copyResetTimer !== null) { window.clearTimeout(this.copyResetTimer); this.copyResetTimer = null; @@ -54,18 +58,23 @@ class LobsterdexPage extends OpenClawLightDomElement { } private readonly copyLink = async (paletteId: LobsterPetPaletteId): Promise => { - const url = `${location.origin}${location.pathname}#lobsterdex-${paletteId}`; - try { - await navigator.clipboard.writeText(url); - } catch { - return; - } - this.copiedPaletteId = paletteId; + const attempt = ++this.copyAttempt; + this.copyFeedback = null; if (this.copyResetTimer !== null) { window.clearTimeout(this.copyResetTimer); + this.copyResetTimer = null; } + const url = `${location.origin}${location.pathname}#lobsterdex-${paletteId}`; + const copied = await copyToClipboard( + url, + () => this.isConnected && attempt === this.copyAttempt, + ); + if (!this.isConnected || attempt !== this.copyAttempt) { + return; + } + this.copyFeedback = { paletteId, status: copied ? "copied" : "error" }; this.copyResetTimer = window.setTimeout(() => { - this.copiedPaletteId = null; + this.copyFeedback = null; this.copyResetTimer = null; }, 1_500); }; @@ -77,7 +86,7 @@ class LobsterdexPage extends OpenClawLightDomElement { ${renderSettingsWorkspace( renderLobsterdex(getLobsterdexEntries(), { - copiedPaletteId: this.copiedPaletteId, + copyFeedback: this.copyFeedback, onCopyLink: (paletteId) => void this.copyLink(paletteId), }), )} diff --git a/ui/src/pages/lobsterdex/view.ts b/ui/src/pages/lobsterdex/view.ts index 3b5dd4804c36..5208aca972b4 100644 --- a/ui/src/pages/lobsterdex/view.ts +++ b/ui/src/pages/lobsterdex/view.ts @@ -20,8 +20,13 @@ type LobsterdexViewEntry = { type LobsterdexViewEntries = ReadonlyMap; +export type LobsterdexCopyFeedback = { + paletteId: LobsterPetPaletteId; + status: "copied" | "error"; +}; + type LobsterdexViewProps = { - copiedPaletteId?: LobsterPetPaletteId | null; + copyFeedback?: LobsterdexCopyFeedback | null; onCopyLink?: (paletteId: LobsterPetPaletteId) => void; }; @@ -47,6 +52,9 @@ export function renderLobsterdex(entries: LobsterdexViewEntries, props: Lobsterd
${countLabel} + ${props.copyFeedback?.status === "error" + ? html`` + : nothing}
${LOBSTER_PET_PALETTES.map((palette) => { const look = canonicalLobsterLook(palette); @@ -78,7 +86,10 @@ export function renderLobsterdex(entries: LobsterdexViewEntries, props: Lobsterd @click=${() => props.onCopyLink?.(palette.id)} > ${props.copyFeedback?.status === "copied" && + props.copyFeedback.paletteId === palette.id + ? icons.check + : icons.link}
Promise; probeResults: Record; + refresh: (opts: { force: boolean }) => Promise; routeData: ModelProvidersRouteData | undefined; saveDefaultModels: () => Promise; saveKey: (provider: string, configKey: string) => Promise; @@ -174,6 +175,32 @@ function createHarness(initialScopeId: string) { }; } +function publishableGateway(initial: ApplicationGatewaySnapshot) { + let current = initial; + const listeners = new Set<(value: ApplicationGatewaySnapshot) => void>(); + return { + gateway: { + get snapshot() { + return current; + }, + subscribe(listener: (value: ApplicationGatewaySnapshot) => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + publish(next: ApplicationGatewaySnapshot) { + current = next; + for (const listener of listeners) { + listener(next); + } + }, + }; +} + +function requestCount(request: ReturnType, method: string): number { + return request.mock.calls.filter(([candidate]) => candidate === method).length; +} + function appendPage(context: ApplicationContext) { const page = document.createElement( "openclaw-model-providers-page", @@ -189,6 +216,203 @@ afterEach(() => { }); describe("ModelProvidersPage agent scope", () => { + it.each(["direct", "preload"] as const)( + "recovers a failed %s provider usage result on the next page activation", + async (loadSource) => { + const { context, request, snapshot } = createHarness("main"); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const originalRequest = request.getMockImplementation()!; + let providerUnavailable = loadSource === "direct"; + request.mockImplementation(async (method: string) => { + if (method === "usage.status" && providerUnavailable) { + throw new Error("provider usage unreachable"); + } + return originalRequest(method); + }); + const page = document.createElement( + "openclaw-model-providers-page", + ) as ModelProvidersPageTestElement; + page.context = context; + if (loadSource === "preload") { + const routeData = { + gateway: context.gateway, + gatewaySnapshot: snapshot, + data: { + ...EMPTY_MODEL_PROVIDERS_DATA, + config: {}, + providerUsage: { ok: false as const, error: { kind: "request-failed" as const } }, + updatedAt: Date.now(), + }, + client: snapshot.client, + agentId: "main", + }; + page.routeData = routeData; + } + document.body.append(page); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: false })); + const previousCalls = requestCount(request, "usage.status"); + providerUnavailable = false; + + window.dispatchEvent(new Event("focus")); + + await vi.waitFor(() => { + expect(requestCount(request, "usage.status")).toBe(previousCalls + 1); + }); + await waitForFast(() => + expect(page.data?.providerUsage).toEqual({ + ok: true, + value: { updatedAt: 1, providers: [] }, + }), + ); + }, + ); + + it.each(["direct", "preload"] as const)( + "keeps a successful empty %s provider usage result fresh on page activation", + async (loadSource) => { + const { context, request, snapshot } = createHarness("main"); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const page = document.createElement( + "openclaw-model-providers-page", + ) as ModelProvidersPageTestElement; + page.context = context; + if (loadSource === "preload") { + page.routeData = { + gateway: context.gateway, + gatewaySnapshot: snapshot, + data: { + ...EMPTY_MODEL_PROVIDERS_DATA, + config: {}, + providerUsage: { ok: true, value: { updatedAt: 1, providers: [] } }, + updatedAt: Date.now(), + }, + client: snapshot.client, + agentId: "main", + }; + } + document.body.append(page); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + const previousCalls = requestCount(request, "usage.status"); + + window.dispatchEvent(new Event("focus")); + + expect(requestCount(request, "usage.status")).toBe(previousCalls); + expect(page.data?.providerUsage).toEqual({ + ok: true, + value: { updatedAt: 1, providers: [] }, + }); + }, + ); + + it("recovers a failed provider usage result after a same-client reconnect", async () => { + const { context, request, snapshot } = createHarness("main"); + const source = publishableGateway(snapshot); + (context as { gateway: unknown }).gateway = source.gateway; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const originalRequest = request.getMockImplementation()!; + let providerUnavailable = true; + request.mockImplementation(async (method: string) => { + if (method === "usage.status" && providerUnavailable) { + throw new Error("provider usage unreachable"); + } + return originalRequest(method); + }); + const page = appendPage(context); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: false })); + providerUnavailable = false; + + source.publish({ ...snapshot, phase: "reconnecting" }); + source.publish({ ...snapshot, phase: "connected" }); + + await vi.waitFor(() => expect(requestCount(request, "usage.status")).toBe(2)); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + }); + + it("defers failed provider usage recovery while hidden until page activation", async () => { + const { context, request, snapshot } = createHarness("main"); + const source = publishableGateway(snapshot); + (context as { gateway: unknown }).gateway = source.gateway; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const visibility = vi.spyOn(document, "visibilityState", "get").mockReturnValue("hidden"); + const originalRequest = request.getMockImplementation()!; + let providerUnavailable = true; + request.mockImplementation(async (method: string) => { + if (method === "usage.status" && providerUnavailable) { + throw new Error("provider usage unreachable"); + } + return originalRequest(method); + }); + const page = appendPage(context); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: false })); + providerUnavailable = false; + + source.publish({ ...snapshot, phase: "reconnecting" }); + source.publish({ ...snapshot, phase: "connected" }); + expect(requestCount(request, "usage.status")).toBe(1); + + visibility.mockReturnValue("visible"); + document.dispatchEvent(new Event("visibilitychange")); + + await vi.waitFor(() => expect(requestCount(request, "usage.status")).toBe(2)); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + }); + + it("supersedes a hung load on disconnect so reconnect can replace it", async () => { + const { context, request, snapshot, deferNextAuthStatus } = createHarness("main"); + const source = publishableGateway(snapshot); + (context as { gateway: unknown }).gateway = source.gateway; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const page = appendPage(context); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + deferNextAuthStatus(); + void page.refresh({ force: true }); + await vi.waitFor(() => expect(requestCount(request, "models.authStatus")).toBe(2)); + + source.publish({ ...snapshot, phase: "reconnecting" }); + source.publish({ ...snapshot, phase: "connected" }); + + await vi.waitFor(() => expect(requestCount(request, "models.authStatus")).toBe(3)); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + }); + + it("keeps direct data visible while a same-client reconnect replaces it", async () => { + const { context, deferNextAuthStatus, request, snapshot } = createHarness("main"); + const source = publishableGateway(snapshot); + (context as { gateway: unknown }).gateway = source.gateway; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible"); + const page = appendPage(context); + await waitForFast(() => expect(page.data?.providerUsage).toMatchObject({ ok: true })); + const previousData = page.data; + const originalRequest = request.getMockImplementation()!; + request.mockImplementation(async (method: string) => { + if (method === "config.get") { + return { + config: { agents: { defaults: { model: "openai/replacement-model" } } }, + hash: "replacement-hash", + }; + } + return originalRequest(method); + }); + const release = deferNextAuthStatus(); + + source.publish({ ...snapshot, phase: "reconnecting" }); + source.publish({ ...snapshot, phase: "connected" }); + await vi.waitFor(() => expect(requestCount(request, "models.authStatus")).toBe(2)); + expect(page.data).toBe(previousData); + + release(); + await waitForFast(() => + expect(page.data?.config).toEqual({ + agents: { defaults: { model: "openai/replacement-model" } }, + }), + ); + }); + it("switches application ownership from the concrete agent picker", async () => { const { agentSelection, context } = createHarness("main"); const page = appendPage(context); @@ -499,6 +723,8 @@ describe("ModelProvidersPage agent scope", () => { agentSelection.state.selectedId = "writer"; agentSelection.state.scopeId = "writer"; page.routeData = { + gateway: context.gateway, + gatewaySnapshot: snapshot, data: { ...EMPTY_MODEL_PROVIDERS_DATA, config: {}, updatedAt: 1 }, client: snapshot.client, agentId: "writer", @@ -632,7 +858,13 @@ describe("ModelProvidersPage agent scope", () => { "openclaw-model-providers-page", ) as ModelProvidersPageTestElement; page.context = context; - page.routeData = { data: staleData, client: snapshot.client, agentId: "main" }; + page.routeData = { + gateway: context.gateway, + gatewaySnapshot: snapshot, + data: staleData, + client: snapshot.client, + agentId: "main", + }; document.body.append(page); await waitForFast(() => diff --git a/ui/src/pages/model-providers/model-providers-page.ts b/ui/src/pages/model-providers/model-providers-page.ts index 391279da9780..d2226961b629 100644 --- a/ui/src/pages/model-providers/model-providers-page.ts +++ b/ui/src/pages/model-providers/model-providers-page.ts @@ -1,5 +1,5 @@ import { consume } from "@lit/context"; -import { initialState, Task, TaskStatus } from "@lit/task"; +import { initialState, Task } from "@lit/task"; import { asNullableRecord as asConfigRecord } from "@openclaw/normalization-core/record-coerce"; import { html, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; @@ -13,11 +13,12 @@ import { renderDocsLink } from "../../components/settings-ui.ts"; import { renderSettingsWorkspace } from "../../components/settings-workspace.ts"; import { t } from "../../i18n/index.ts"; import { normalizeAgentLabel } from "../../lib/agents/display.ts"; -import { createGatewayConnectionLifecycle } from "../../lib/gateway-connection-lifecycle.ts"; import { isGatewayMethodAdvertised } from "../../lib/gateway-methods.ts"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; +import { GatewayPageController } from "../../lit/gateway-page-controller.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { SubscriptionsController } from "../../lit/subscriptions-controller.ts"; +import { UsageRefreshPolicy } from "../usage/refresh-policy.ts"; import { modelProviderErrorMessage, runModelProviderConfigMutation, @@ -44,57 +45,12 @@ import { buildProviderApiKeyPatch, DEFAULT_MODELS_REPLACE_PATHS, } from "./mutations.ts"; +import { isMissingMethodError, mergeProbeResults } from "./probe-results.ts"; +import type { ModelProvidersRouteData } from "./route.ts"; import { renderModelProviders, type ModelProviderRowMessage } from "./view.ts"; const MODEL_PROVIDERS_DOCS_URL = "https://docs.openclaw.ai/concepts/model-providers"; -export type ModelProvidersRouteData = { - data: ModelProvidersData; - /** Client the loader fetched from; null when it ran disconnected. */ - client: GatewayBrowserClient | null; - /** Concrete agent whose credential store populated the auth snapshot. */ - agentId: string | null; -}; - -function isMissingMethodError(error: unknown): boolean { - return /method (?:not found|not supported)|unknown method/iu.test( - modelProviderErrorMessage(error), - ); -} - -const PROBE_FAILURE_PRIORITY: readonly ModelsProbeResult["status"][] = [ - "auth", - "billing", - "rate_limit", - "timeout", - "format", - "no_model", - "unknown", -]; - -function mergeProbeResults(cardId: string, results: ModelsProbeResult[]): ModelsProbeResult { - if (results.length === 1) { - return results[0]!; - } - const status = results.some((result) => result.status === "ok") - ? "ok" - : (PROBE_FAILURE_PRIORITY.find((candidate) => - results.some((result) => result.status === candidate), - ) ?? "unknown"); - const error = results.find((result) => result.status === status)?.error; - return { - provider: cardId, - status, - ...(error ? { error } : {}), - results: results.flatMap((result) => - result.results.map((target) => ({ - ...target, - label: `${result.provider}: ${target.label}`, - })), - ), - }; -} - export class ModelProvidersPage extends OpenClawLightDomElement { @consume({ context: applicationContext, subscribe: true }) private context!: ApplicationContext; @@ -117,41 +73,61 @@ export class ModelProvidersPage extends OpenClawLightDomElement { /** Client the current data was loaded from; a new client means stale data. */ private dataClient: GatewayBrowserClient | null = null; - private readonly connectionLifecycle = createGatewayConnectionLifecycle({ - client: null, - phase: "stopped", - }); + // Null Task runs supersede stale work without counting as a real load. + private loadClient: GatewayBrowserClient | null = null; + private routeDataObserved = false; // Global config writes survive agent switches; their card state does not. private agentEpoch = 0; private probeEpochs = new Map(); private readonly refreshTask = new Task(this, { autoRun: false, - args: () => - [ - this.context?.gateway.snapshot.phase === "connected" - ? (this.context.gateway.snapshot.client ?? null) - : null, - this.selectedAgentId, - false as boolean, - ] as const, - task: ([client, agentId, force], { signal }) => - client && agentId - ? loadModelProvidersData(client, { - agentId, - ...(force ? { refresh: true } : {}), - signal, - }).then((data) => ({ client, data })) - : initialState, + task: ( + [client, agentId, force]: [GatewayBrowserClient | null, string, boolean], + { signal }, + ) => { + if (!client || !agentId) { + return initialState; + } + this.refreshPolicy.beginLoad(); + return loadModelProvidersData(client, { + agentId, + ...(force ? { refresh: true } : {}), + signal, + }).then((data) => ({ client, data })); + }, onComplete: ({ client, data }) => { - this.data = data; - this.dataClient = client; + this.loadClient = null; + this.adoptLoadedData(client, data); + this.refreshPolicy.flushPending(); + }, + onError: () => { + this.loadClient = null; + this.refreshPolicy.flushPending(); }, }); + private readonly refreshPolicy = new UsageRefreshPolicy({ + isLoading: () => this.loadClient !== null, + reload: () => void this.refresh({ force: false }), + }); + private readonly gateway = new GatewayPageController(this, { + getGateway: () => this.context?.gateway, + onIdentityChange: () => this.resetConnectionState(), + invalidateRequests: () => this.invalidateRequests(), + ensureInitialData: () => this.ensureInitialData(), + onSnapshot: (change) => { + if (change.initial) { + this.resetConnectionState(); + } else if (change.connectionChanged && !change.identityChanged) { + // Keep the last snapshot visible while the canonical reconnect load replaces it. + this.resetConnectionState({ preserveVisibleData: true }); + } + if (change.becameConnected && !change.initial) { + this.refreshPolicy.request("reconnect"); + } + }, + onPageActivation: () => this.refreshPolicy.request("focus"), + }); private readonly subscriptions = new SubscriptionsController(this) - .watch( - () => this.context?.gateway, - (gateway, notify) => gateway.subscribe(notify), - ) .watch( () => this.context?.runtimeConfig, (runtimeConfig, notify) => runtimeConfig.subscribe(notify), @@ -176,31 +152,30 @@ export class ModelProvidersPage extends OpenClawLightDomElement { ); override disconnectedCallback() { - this.connectionLifecycle.transition({ client: null, phase: "stopped" }); - void this.refreshTask.run([null, "", false]); this.subscriptions.clear(); super.disconnectedCallback(); } override willUpdate(changed: PropertyValues) { - if (changed.has("routeData") && this.routeData) { + if (changed.has("routeData") && this.routeData !== undefined) { + this.routeDataObserved = true; const selectedAgentId = this.resolveSelectedAgentId(); this.setSelectedAgent(selectedAgentId); - if ((this.routeData.agentId ?? "") === selectedAgentId) { - this.data = this.routeData.data; - this.dataClient = this.routeData.client; + if ( + (this.routeData.agentId ?? "") === selectedAgentId && + this.gateway.isRouteDataCurrent(this.routeData) + ) { + this.adoptLoadedData(this.routeData.client, this.routeData.data); } else { this.data = null; this.dataClient = null; + this.refreshPolicy.resetPayload(); } + this.ensureInitialData(); } } - override updated() { - const snapshot = this.context.gateway.snapshot; - if (this.connectionLifecycle.transition(snapshot)) { - this.resetConnectionState(snapshot.client, snapshot.phase === "connected"); - } + private ensureInitialData() { if ( !this.context.agents.state.agentsList && !this.context.agents.state.agentsLoading && @@ -208,21 +183,40 @@ export class ModelProvidersPage extends OpenClawLightDomElement { ) { void this.context.agents.ensureList(); } + if (!this.routeDataObserved && this.routeData !== undefined) { + return; + } + const client = this.gateway.client; if ( - snapshot.phase !== "connected" || - !snapshot.client || - this.refreshTask.status === TaskStatus.PENDING + !this.gateway.connected || + !client || + !this.selectedAgentId || + this.loadClient !== null || + (this.data !== null && this.data.updatedAt !== null && client === this.dataClient) ) { return; } - const stale = this.data === null || this.data.updatedAt === null; - if (stale || snapshot.client !== this.dataClient) { - void this.refresh({ force: false }); - } + void this.refresh({ force: false }); } - private resetConnectionState(client: GatewayBrowserClient | null, connected: boolean) { + private adoptLoadedData(client: GatewayBrowserClient | null, data: ModelProvidersData) { + this.data = data; + this.dataClient = client; + this.refreshPolicy.setLastLoadedAtMs(data.providerUsage?.ok ? data.updatedAt : null); + } + + private invalidateRequests() { + this.refreshPolicy.interrupt(); + this.loadClient = null; void this.refreshTask.run([null, this.selectedAgentId, false]); + } + + private resetConnectionState(options: { preserveVisibleData?: boolean } = {}) { + if (!options.preserveVisibleData) { + this.data = null; + this.dataClient = null; + } + this.refreshPolicy.resetPayload(); this.busy = {}; this.messages = {}; this.probeResults = {}; @@ -235,13 +229,10 @@ export class ModelProvidersPage extends OpenClawLightDomElement { this.addProviderId = ""; this.addProviderKey = ""; this.defaultsDraft = null; - if (!connected || client !== this.dataClient) { - this.data = null; - } } private isCurrentClient(client: GatewayBrowserClient, epoch: number): boolean { - return this.connectionLifecycle.isCurrent({ client, epoch }); + return this.gateway.isCurrent({ client, epoch }); } private resolveSelectedAgentId(): string { @@ -267,19 +258,27 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!this.setSelectedAgent(agentId)) { return; } - void this.refreshTask.run([null, agentId, false]); + this.invalidateRequests(); this.data = null; + this.dataClient = null; + this.refreshPolicy.resetPayload(); // probeEpochs stays: per-card counters must remain monotonic across agent // switches, or an in-flight probe from the old agent can reuse an epoch // and clobber a newer probe's state (A->B->A ABA race). this.requestUpdate(); + this.ensureInitialData(); } private refresh(opts: { force: boolean }): Promise { - const client = this.context.gateway.snapshot.client; - if (!client || !this.selectedAgentId) { + if (!this.selectedAgentId) { return Promise.resolve(); } + const client = this.gateway.client; + if (!this.gateway.connected || !client) { + this.refreshPolicy.markLoadDeferred(); + return Promise.resolve(); + } + this.loadClient = client; return this.refreshTask.run([client, this.selectedAgentId, opts.force]); } @@ -351,7 +350,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!client) { return { ok: false }; } - const clientEpoch = this.connectionLifecycle.epoch; + const clientEpoch = this.gateway.epoch; const agentEpoch = this.agentEpoch; return runModelProviderConfigMutation( { @@ -434,7 +433,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!client || !this.canMutate() || this.busy[key] || this.probeUnsupported) { return; } - const clientEpoch = this.connectionLifecycle.epoch; + const clientEpoch = this.gateway.epoch; const agentId = this.selectedAgentId; const agentEpoch = this.agentEpoch; const probeEpoch = (this.probeEpochs.get(cardId) ?? 0) + 1; @@ -488,7 +487,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { if (!client || !this.canMutate() || this.busy[key]) { return; } - const clientEpoch = this.connectionLifecycle.epoch; + const clientEpoch = this.gateway.epoch; const agentId = this.selectedAgentId; const agentEpoch = this.agentEpoch; this.clearProbe(cardId); @@ -625,7 +624,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement { const body = renderModelProviders({ connected: gatewaySnapshot.phase === "connected", loading: gatewaySnapshot.phase === "connected" && this.data === null && !rosterError, - refreshing: this.refreshTask.status === TaskStatus.PENDING, + refreshing: this.loadClient !== null, error: rosterError ?? data.error ?? data.catalogError, providerUsageFailed: data.providerUsage?.ok === false, updatedAt: data.updatedAt, diff --git a/ui/src/pages/model-providers/probe-results.ts b/ui/src/pages/model-providers/probe-results.ts new file mode 100644 index 000000000000..897f0f25c255 --- /dev/null +++ b/ui/src/pages/model-providers/probe-results.ts @@ -0,0 +1,41 @@ +import type { ModelsProbeResult } from "../../api/types.ts"; +import { modelProviderErrorMessage } from "./config-mutation.ts"; + +const PROBE_FAILURE_PRIORITY: readonly ModelsProbeResult["status"][] = [ + "auth", + "billing", + "rate_limit", + "timeout", + "format", + "no_model", + "unknown", +]; + +export function isMissingMethodError(error: unknown): boolean { + return /method (?:not found|not supported)|unknown method/iu.test( + modelProviderErrorMessage(error), + ); +} + +export function mergeProbeResults(cardId: string, results: ModelsProbeResult[]): ModelsProbeResult { + if (results.length === 1) { + return results[0]!; + } + const status = results.some((result) => result.status === "ok") + ? "ok" + : (PROBE_FAILURE_PRIORITY.find((candidate) => + results.some((result) => result.status === candidate), + ) ?? "unknown"); + const error = results.find((result) => result.status === status)?.error; + return { + provider: cardId, + status, + ...(error ? { error } : {}), + results: results.flatMap((result) => + result.results.map((target) => ({ + ...target, + label: `${result.provider}: ${target.label}`, + })), + ), + }; +} diff --git a/ui/src/pages/model-providers/route.ts b/ui/src/pages/model-providers/route.ts index 39ee83b71537..8b26d1676b8d 100644 --- a/ui/src/pages/model-providers/route.ts +++ b/ui/src/pages/model-providers/route.ts @@ -3,12 +3,25 @@ import { html } from "lit"; import { routePageSpec } from "../../app-route-paths.ts"; import type { ApplicationContext } from "../../app/context.ts"; import { normalizeAgentId } from "../../lib/sessions/session-key.ts"; -import type { ModelProvidersRouteData } from "./model-providers-page.ts"; +import type { ModelProvidersData } from "./load.ts"; + +export type ModelProvidersRouteData = { + /** Gateway source that owned the route preload. */ + gateway: ApplicationContext["gateway"]; + /** Exact Gateway snapshot captured before the preload began. */ + gatewaySnapshot: ApplicationContext["gateway"]["snapshot"]; + data: ModelProvidersData; + /** Client the loader fetched from; null when it ran disconnected. */ + client: ApplicationContext["gateway"]["snapshot"]["client"]; + /** Concrete agent whose credential store populated the auth snapshot. */ + agentId: string | null; +}; async function loadModelProvidersRouteData( context: ApplicationContext, ): Promise { - const gatewaySnapshot = context.gateway.snapshot; + const gateway = context.gateway; + const gatewaySnapshot = gateway.snapshot; const { EMPTY_MODEL_PROVIDERS_DATA, loadModelProvidersData } = await import("./load.ts"); const client = gatewaySnapshot.phase === "connected" ? gatewaySnapshot.client : null; if (!context.agentSelection.state.selectedId && client) { @@ -17,9 +30,15 @@ async function loadModelProvidersRouteData( const selectedAgentId = context.agentSelection.state.selectedId; const agentId = selectedAgentId ? normalizeAgentId(selectedAgentId) : null; if (!client || !agentId) { - return { data: EMPTY_MODEL_PROVIDERS_DATA, client: null, agentId }; + return { gateway, gatewaySnapshot, data: EMPTY_MODEL_PROVIDERS_DATA, client: null, agentId }; } - return { data: await loadModelProvidersData(client, { agentId }), client, agentId }; + return { + gateway, + gatewaySnapshot, + data: await loadModelProvidersData(client, { agentId }), + client, + agentId, + }; } export const page = definePage({ diff --git a/ui/src/pages/model-setup/model-setup-page.test.ts b/ui/src/pages/model-setup/model-setup-page.test.ts index c76982ef28ce..24d69f2a68b6 100644 --- a/ui/src/pages/model-setup/model-setup-page.test.ts +++ b/ui/src/pages/model-setup/model-setup-page.test.ts @@ -322,7 +322,7 @@ describe("ModelSetupPage catalog icons", () => { expect(request).toHaveBeenCalledWith( "openclaw.setup.prepare.start", { sessionId: expect.any(String), agentId: "main", authChoice: "llama-cpp" }, - expect.objectContaining({ signal: expect.any(AbortSignal) }), + { timeoutMs: null }, ); expect(page.querySelector("openclaw-modal-dialog")).not.toBeNull(); expect(page.textContent).toContain("Downloading model: 25%"); diff --git a/ui/src/pages/model-setup/wizard-runner.test.ts b/ui/src/pages/model-setup/wizard-runner.test.ts index 910e8e6e6f47..76ac4eafbe68 100644 --- a/ui/src/pages/model-setup/wizard-runner.test.ts +++ b/ui/src/pages/model-setup/wizard-runner.test.ts @@ -39,7 +39,7 @@ describe("ModelSetupWizardRunner", () => { 1, "openclaw.setup.auth.start", { sessionId: expect.any(String), agentId: "research", authChoice: "openai-oauth" }, - expect.objectContaining({ signal: expect.any(AbortSignal) }), + { timeoutMs: null }, ); expect(runner.state).toMatchObject({ phase: "step" }); const answer = runner.answer(undefined, false); @@ -119,7 +119,7 @@ describe("ModelSetupWizardRunner", () => { 1, "openclaw.setup.prepare.start", { sessionId: expect.any(String), authChoice: "llama-cpp" }, - expect.objectContaining({ signal: expect.any(AbortSignal) }), + { timeoutMs: null }, ); expect(runner.state).toMatchObject({ phase: "step", @@ -128,6 +128,295 @@ describe("ModelSetupWizardRunner", () => { }); }); + it.each([ + ["openclaw.setup.auth.start", "cancel"], + ["openclaw.setup.auth.start", "settled cancel"], + ["openclaw.setup.auth.start", "close"], + ["openclaw.setup.prepare.start", "cancel"], + ["openclaw.setup.prepare.start", "settled cancel"], + ["openclaw.setup.prepare.start", "close"], + ] as const)( + "releases a late %s session after %s so setup can restart", + async (method, action) => { + let runningSession: string | null = null; + let firstSessionId = ""; + let resolveFirstStart: () => void = () => { + throw new Error("the first setup request did not start"); + }; + let startCount = 0; + const request = vi.fn( + async ( + requestMethod: string, + params?: { sessionId?: string }, + options?: { signal?: AbortSignal }, + ) => { + if (requestMethod === method) { + const sessionId = params?.sessionId; + if (!sessionId) { + throw new Error("missing setup session"); + } + if (startCount++ === 0) { + firstSessionId = sessionId; + return await new Promise((resolve, reject) => { + options?.signal?.addEventListener( + "abort", + () => reject(new Error("Gateway retired the aborted start request")), + { once: true }, + ); + resolveFirstStart = () => { + runningSession = sessionId; + resolve({ sessionId, done: false, status: "running" }); + }; + }); + } + if (runningSession) { + throw new Error("wizard already running"); + } + return { sessionId, done: true, status: "done" }; + } + if (requestMethod === "wizard.cancel") { + if (runningSession !== params?.sessionId) { + throw new Error("wizard not found"); + } + runningSession = null; + return { status: "cancelled" }; + } + throw new Error(`unexpected request ${requestMethod}`); + }, + ); + const client = { request } as unknown as GatewayBrowserClient; + const runner = new ModelSetupWizardRunner({ + getClient: () => client, + getAgentId: () => null, + onChange: () => undefined, + requestFailedMessage: () => "failed", + cancelledMessage: () => "cancelled", + sessionExpiredMessage: () => "expired", + }); + + const firstStart = runner.start("original", method); + if (action === "cancel") { + await runner.cancel(); + } else if (action === "settled cancel") { + await runner.cancel({ settleActiveRequest: true }); + } else { + runner.close(); + } + resolveFirstStart(); + await firstStart; + + expect(runningSession).toBeNull(); + expect(request).toHaveBeenCalledWith( + "wizard.cancel", + { sessionId: firstSessionId }, + { timeoutMs: 30_000 }, + ); + await expect(runner.start("replacement", method)).resolves.toEqual({ startMethod: method }); + expect(runner.state).toEqual({ phase: "done", authChoice: "replacement" }); + }, + ); + + it.each([ + ["openclaw.setup.auth.start", false], + ["openclaw.setup.prepare.start", false], + ["openclaw.setup.auth.start", true], + ["openclaw.setup.prepare.start", true], + ] as const)( + "retains late %s responses after the local deadline (terminal: %s)", + async (method, terminal) => { + vi.useFakeTimers(); + try { + let runningSession: string | null = null; + let firstSessionId = ""; + let resolveFirstStart: () => void = () => { + throw new Error("the first setup request did not start"); + }; + let startCount = 0; + const request = vi.fn( + async ( + requestMethod: string, + params?: { sessionId?: string }, + options?: { signal?: AbortSignal; timeoutMs?: number | null }, + ) => { + if (requestMethod === method) { + const sessionId = params?.sessionId; + if (!sessionId) { + throw new Error("missing setup session"); + } + if (startCount++ === 0) { + firstSessionId = sessionId; + return await new Promise((resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + if (typeof options?.timeoutMs === "number") { + setTimeout( + () => reject(new Error("Gateway retired the timed-out request")), + options.timeoutMs, + ); + } + resolveFirstStart = () => { + if (!terminal) { + runningSession = sessionId; + } + resolve({ sessionId, done: terminal, status: terminal ? "done" : "running" }); + }; + }); + } + if (runningSession) { + throw new Error("wizard already running"); + } + return { sessionId, done: true, status: "done" }; + } + if (requestMethod === "wizard.cancel") { + if (runningSession !== params?.sessionId) { + throw new Error("wizard not found"); + } + runningSession = null; + return { status: "cancelled" }; + } + throw new Error(`unexpected request ${requestMethod}`); + }, + ); + const client = { request } as unknown as GatewayBrowserClient; + const runner = new ModelSetupWizardRunner({ + getClient: () => client, + getAgentId: () => null, + onChange: () => undefined, + requestFailedMessage: () => "failed", + cancelledMessage: () => "cancelled", + sessionExpiredMessage: () => "expired", + }); + + const timedOutStart = runner.start("original", method); + await vi.advanceTimersByTimeAsync(30_000); + await timedOutStart; + expect(runner.state).toEqual({ + phase: "error", + message: `gateway request timed out after 30000ms: ${method}`, + }); + + resolveFirstStart(); + await vi.runAllTimersAsync(); + expect(runningSession).toBeNull(); + const cancelCalls = request.mock.calls.filter( + ([requestMethod]) => requestMethod === "wizard.cancel", + ); + const lateCancelCalls = cancelCalls.filter( + ([, params]) => params?.sessionId === firstSessionId, + ); + expect(lateCancelCalls).toHaveLength(terminal ? 1 : 2); + + await runner.cancel(); + await expect(runner.start("replacement", method)).resolves.toEqual({ startMethod: method }); + expect(runner.state).toEqual({ phase: "done", authChoice: "replacement" }); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("cleans the original Gateway session without disturbing a replacement connection", async () => { + let originalSessionId = ""; + let resolveOriginalStart: () => void = () => { + throw new Error("the original setup request did not start"); + }; + const originalRequest = vi.fn( + async ( + method: string, + params?: { sessionId?: string }, + options?: { signal?: AbortSignal }, + ) => { + if (method === "openclaw.setup.auth.start") { + originalSessionId = params?.sessionId ?? ""; + return await new Promise((resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + resolveOriginalStart = () => + resolve({ sessionId: originalSessionId, done: false, status: "running" }); + }); + } + return { status: "cancelled" }; + }, + ); + const replacementRequest = vi.fn(async (method: string, params?: { sessionId?: string }) => { + if (method === "openclaw.setup.auth.start") { + return { sessionId: params?.sessionId, done: false, status: "running" }; + } + if (method === "wizard.next") { + return { + done: false, + status: "running", + step: { id: "replacement", type: "text", message: "Replacement setup" }, + }; + } + throw new Error(`unexpected replacement request ${method}`); + }); + const originalClient = { request: originalRequest } as unknown as GatewayBrowserClient; + const replacementClient = { request: replacementRequest } as unknown as GatewayBrowserClient; + let currentClient = originalClient; + const runner = new ModelSetupWizardRunner({ + getClient: () => currentClient, + getAgentId: () => null, + onChange: () => undefined, + requestFailedMessage: () => "failed", + cancelledMessage: () => "cancelled", + sessionExpiredMessage: () => "expired", + }); + + const originalStart = runner.start("original"); + runner.close(); + currentClient = replacementClient; + await runner.start("replacement"); + resolveOriginalStart(); + await originalStart; + + expect(originalRequest).toHaveBeenCalledWith( + "wizard.cancel", + { sessionId: originalSessionId }, + { timeoutMs: 30_000 }, + ); + expect(replacementRequest.mock.calls.some(([method]) => method === "wizard.cancel")).toBe( + false, + ); + expect(runner.state).toMatchObject({ phase: "step", authChoice: "replacement" }); + }); + + it.each(["openclaw.setup.auth.start", "openclaw.setup.prepare.start"] as const)( + "does not cancel a terminal %s result after its wizard closes", + async (method) => { + let resolveStart: () => void = () => { + throw new Error("the setup request did not start"); + }; + const request = vi.fn(async (requestMethod: string) => { + if (requestMethod === method) { + return await new Promise((resolve) => { + resolveStart = () => resolve({ done: true, status: "done" }); + }); + } + throw new Error(`unexpected request ${requestMethod}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const runner = new ModelSetupWizardRunner({ + getClient: () => client, + getAgentId: () => null, + onChange: () => undefined, + requestFailedMessage: () => "failed", + cancelledMessage: () => "cancelled", + sessionExpiredMessage: () => "expired", + }); + + const start = runner.start("original", method); + runner.close(); + resolveStart(); + await start; + + expect(request.mock.calls.map(([requestMethod]) => requestMethod)).toEqual([method]); + expect(runner.state).toEqual({ phase: "idle" }); + }, + ); + it("clears an expired session and abort without cancelling or replaying the answer", async () => { let nextCount = 0; let answerSignal: AbortSignal | undefined; diff --git a/ui/src/pages/model-setup/wizard-runner.ts b/ui/src/pages/model-setup/wizard-runner.ts index 718d3528891f..df2becb2c8e6 100644 --- a/ui/src/pages/model-setup/wizard-runner.ts +++ b/ui/src/pages/model-setup/wizard-runner.ts @@ -57,16 +57,21 @@ export class ModelSetupWizardRunner { this.setState({ phase: "starting", authChoice }); try { const agentId = this.options.getAgentId(); - const started = await client.request( + const request = client.request( startMethod, { sessionId, authChoice, ...(agentId ? { agentId } : {}), }, - { timeoutMs: MODEL_SETUP_AUTH_START_TIMEOUT_MS, signal: abortController.signal }, + { timeoutMs: null }, ); + const started = await this.awaitWizardStart(client, request, sessionId, startMethod); if (generation !== this.generation) { + if (!started.done) { + // Admission can finish after cancellation; release only this generation's original session. + await this.cancelSession(client, sessionId); + } return null; } if (started.done) { @@ -108,15 +113,7 @@ export class ModelSetupWizardRunner { if (!client || !sessionId) { return; } - try { - await client.request( - "wizard.cancel", - { sessionId }, - { timeoutMs: MODEL_SETUP_AUTH_START_TIMEOUT_MS }, - ); - } catch { - // The gateway may have already completed or purged the session. - } + await this.cancelSession(client, sessionId); } close(): void { @@ -133,6 +130,40 @@ export class ModelSetupWizardRunner { this.setState({ phase: "error", message }); } + private async awaitWizardStart( + client: GatewayBrowserClient, + request: Promise, + sessionId: string, + startMethod: ModelSetupWizardStartMethod, + ): Promise { + let timedOut = false; + let timer: ReturnType | undefined; + // Gateway request abort/deadline retirement discards the late session needed for cleanup. + const retainedRequest = request.then(async (result) => { + if (timedOut && !result.done) { + await this.cancelSession(client, sessionId); + } + return result; + }); + try { + return await Promise.race([ + retainedRequest, + new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject( + new Error( + `gateway request timed out after ${MODEL_SETUP_AUTH_START_TIMEOUT_MS}ms: ${startMethod}`, + ), + ); + }, MODEL_SETUP_AUTH_START_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } + } + private async requestNext( authChoice: string, answer: { stepId: string; value?: unknown } | undefined, @@ -202,11 +233,7 @@ export class ModelSetupWizardRunner { this.abortController = null; const sessionExpired = isWizardNotFoundError(error); if (!sessionExpired && client && sessionId) { - void client - .request("wizard.cancel", { sessionId }, { timeoutMs: MODEL_SETUP_AUTH_START_TIMEOUT_MS }) - .catch(() => { - // The failed request may have already completed or purged the session. - }); + void this.cancelSession(client, sessionId); } const message = sessionExpired ? this.options.sessionExpiredMessage() @@ -214,6 +241,18 @@ export class ModelSetupWizardRunner { this.setState({ phase: "error", message }); } + private async cancelSession(client: GatewayBrowserClient, sessionId: string): Promise { + try { + await client.request( + "wizard.cancel", + { sessionId }, + { timeoutMs: MODEL_SETUP_AUTH_START_TIMEOUT_MS }, + ); + } catch { + // The Gateway may already have completed or purged the session. + } + } + private setState(state: ModelSetupWizardState): void { this.currentState = state; this.options.onChange(state); diff --git a/ui/src/pages/new-session/device-placement.test.ts b/ui/src/pages/new-session/device-placement.test.ts index 93eda1d9b59d..e801e571545f 100644 --- a/ui/src/pages/new-session/device-placement.test.ts +++ b/ui/src/pages/new-session/device-placement.test.ts @@ -127,4 +127,56 @@ describe("device placement projection", () => { { deviceId: "unique", subtitle: undefined }, ]); }); + + it.each([ + { + name: "remote execution remains available when every worker slot is occupied", + requirement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + environment: { + workerSlots: { total: 2, available: 0 }, + invocableCommands: ["codex.exec-server.stdio.v1"], + }, + selectable: true, + }, + { + name: "worker turns remain unavailable when every worker slot is occupied", + requirement: { requiredNodeCommands: [], consumesWorkerSlot: true }, + environment: { workerSlots: { total: 2, available: 0 } }, + selectable: false, + reason: /worker slots/i, + }, + { + name: "declaring a command does not grant Gateway invocation authority", + requirement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + environment: { + capabilities: ["codex.exec-server.stdio.v1"], + invocableCommands: [], + }, + selectable: false, + reason: /enable|approv/i, + }, + { + name: "missing command authority fails closed even when worker slots are free", + requirement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + environment: { invocableCommands: ["camera.snap"] }, + selectable: false, + reason: /enable|approv/i, + }, + ])("$name", ({ requirement, environment, selectable, reason }) => { + const [device] = projectDevicePlacements([node(environment)], requirement); + + expect(device?.selectable).toBe(selectable); + if (reason) { + expect(device?.disabledReason).toMatch(reason); + } + }); }); diff --git a/ui/src/pages/new-session/device-placement.ts b/ui/src/pages/new-session/device-placement.ts index 25a37b7f4323..64597c9835db 100644 --- a/ui/src/pages/new-session/device-placement.ts +++ b/ui/src/pages/new-session/device-placement.ts @@ -12,7 +12,20 @@ export type DevicePlacementOption = Readonly<{ disabledReason?: string; }>; -function unavailableReason(environment: DraftEnvironment): string | undefined { +export type DevicePlacementRequirement = Readonly<{ + requiredNodeCommands: readonly string[]; + consumesWorkerSlot: boolean; +}>; + +const DEFAULT_DEVICE_PLACEMENT: DevicePlacementRequirement = { + requiredNodeCommands: [], + consumesWorkerSlot: true, +}; + +function unavailableReason( + environment: DraftEnvironment, + requirement: DevicePlacementRequirement, +): string | undefined { const updateIssue = environment.issues?.find((issue) => issue.code === "update-required"); if (updateIssue) { return t("newSession.nodeUpdateRequired", { @@ -26,6 +39,15 @@ function unavailableReason(environment: DraftEnvironment): string | undefined { if (environment.sessionHost !== true) { return t("newSession.sessionHostingDisabled"); } + const unavailableCommand = requirement.requiredNodeCommands.find( + (command) => !environment.invocableCommands?.includes(command), + ); + if (unavailableCommand) { + return `${t("pluginsPage.enableAction")} ${unavailableCommand}: gateway.nodes.commands.allow.`; + } + if (!requirement.consumesWorkerSlot) { + return undefined; + } if (!environment.workerSlots) { return t("newSession.deviceCapacityUnavailable"); } @@ -35,6 +57,7 @@ function unavailableReason(environment: DraftEnvironment): string | undefined { /** One projection owns device presentation, restore eligibility, and submit eligibility. */ export function projectDevicePlacements( environments: readonly DraftEnvironment[] | null, + requirement: DevicePlacementRequirement = DEFAULT_DEVICE_PLACEMENT, ): DevicePlacementOption[] { const devices = (environments ?? []) .flatMap((environment) => { @@ -45,7 +68,7 @@ export function projectDevicePlacements( if (!deviceId) { return []; } - const disabledReason = unavailableReason(environment); + const disabledReason = unavailableReason(environment, requirement); const facts = environmentMenuFacts(environment, { connected: environment.status === "available", }); @@ -85,6 +108,9 @@ export function projectDevicePlacements( export function findDevicePlacement( environments: readonly DraftEnvironment[] | null, deviceId: string, + requirement?: DevicePlacementRequirement, ): DevicePlacementOption | undefined { - return projectDevicePlacements(environments).find((device) => device.deviceId === deviceId); + return projectDevicePlacements(environments, requirement).find( + (device) => device.deviceId === deviceId, + ); } diff --git a/ui/src/pages/new-session/discovery.test.ts b/ui/src/pages/new-session/discovery.test.ts index f1e31ae926c0..506d9e5672fd 100644 --- a/ui/src/pages/new-session/discovery.test.ts +++ b/ui/src/pages/new-session/discovery.test.ts @@ -101,6 +101,28 @@ describe("readDraftEnvironments", () => { ).toEqual([issue]); }); + it("normalizes bounded invocable commands separately from declared capabilities", () => { + expect( + readDraftEnvironments([ + { + id: "node:runner", + type: "node", + status: "available", + capabilities: ["codex.exec-server.stdio.v1", "camera.snap"], + invocableCommands: [" z.command ", "camera.snap", "camera.snap", "x".repeat(129), ""], + }, + ]), + ).toEqual([ + { + id: "node:runner", + type: "node", + status: "available", + capabilities: ["codex.exec-server.stdio.v1", "camera.snap"], + invocableCommands: ["camera.snap", "z.command"], + }, + ]); + }); + it("keeps the closed environment types while rejecting malformed entries", () => { expect( readDraftEnvironments([ diff --git a/ui/src/pages/new-session/discovery.ts b/ui/src/pages/new-session/discovery.ts index 8778e2e45d84..c41168dc57eb 100644 --- a/ui/src/pages/new-session/discovery.ts +++ b/ui/src/pages/new-session/discovery.ts @@ -1,6 +1,9 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; -import { normalizeArrayBackedTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { + normalizeArrayBackedTrimmedStringList, + normalizeSortedUniqueTrimmedStringList, +} from "@openclaw/normalization-core/string-normalization"; import type { EnvironmentStatus, RuntimeTargetIssue, @@ -52,6 +55,7 @@ export type DraftEnvironment = { lastSeenReason?: string; trust?: "persistent" | "disposable"; capabilities?: string[]; + invocableCommands?: string[]; issues?: RuntimeTargetIssue[]; }; @@ -194,6 +198,7 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { lastSeenReason?: unknown; trust?: unknown; capabilities?: unknown; + invocableCommands?: unknown; issues?: unknown; }; const id = normalizeOptionalString(environment.id); @@ -213,6 +218,11 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { ? environment.trust : undefined; const capabilities = normalizeArrayBackedTrimmedStringList(environment.capabilities); + const invocableCommands = Array.isArray(environment.invocableCommands) + ? normalizeSortedUniqueTrimmedStringList(environment.invocableCommands) + .filter((command) => command.length <= 128) + .slice(0, 128) + : undefined; const lastConnectedAtMs = normalizeTimestamp(environment.lastConnectedAtMs); const lastDisconnectedAtMs = normalizeTimestamp(environment.lastDisconnectedAtMs); const lastSeenAtMs = normalizeTimestamp(environment.lastSeenAtMs); @@ -236,6 +246,7 @@ export function readDraftEnvironments(value: unknown): DraftEnvironment[] { ...(lastSeenReason ? { lastSeenReason } : {}), ...(trust ? { trust } : {}), ...(capabilities ? { capabilities } : {}), + ...(invocableCommands ? { invocableCommands } : {}), ...(issues ? { issues } : {}), }, ]; diff --git a/ui/src/pages/new-session/draft-place-state.ts b/ui/src/pages/new-session/draft-place-state.ts index d1f18f047e70..30175c753779 100644 --- a/ui/src/pages/new-session/draft-place-state.ts +++ b/ui/src/pages/new-session/draft-place-state.ts @@ -165,25 +165,34 @@ export class DraftPlaceState { return this.agents().find((agent) => normalizeAgentId(agent.id) === agentId); } + devicePlacementRequirement() { + return this.modelControl.resolveAgentRuntime({ + agent: this.selectedAgent(), + context: this.read().context, + })?.devicePlacement; + } + devices() { - return projectDevicePlacements(this.gateway.environments); + return projectDevicePlacements(this.gateway.environments, this.devicePlacementRequirement()); + } + + private findDevice(deviceId: string) { + return findDevicePlacement( + this.gateway.environments, + deviceId, + this.devicePlacementRequirement(), + ); } devicePlacementReady(): boolean { - return ( - !this.deviceIdValue || - findDevicePlacement(this.gateway.environments, this.deviceIdValue)?.selectable === true - ); + return !this.deviceIdValue || this.findDevice(this.deviceIdValue)?.selectable === true; } devicePlacementDisabledReason(): string | undefined { if (!this.deviceIdValue) { return undefined; } - return ( - findDevicePlacement(this.gateway.environments, this.deviceIdValue)?.disabledReason ?? - t("newSession.nodeUnavailable") - ); + return this.findDevice(this.deviceIdValue)?.disabledReason ?? t("newSession.nodeUnavailable"); } isAdmin(): boolean { @@ -498,7 +507,7 @@ export class DraftPlaceState { if (snapshot.submitting || snapshot.pendingPlacementSessionKey) { return; } - if (deviceId && findDevicePlacement(this.gateway.environments, deviceId)?.selectable !== true) { + if (deviceId && this.findDevice(deviceId)?.selectable !== true) { return; } if (deviceId === this.deviceIdValue && !this.cloudProfileIdValue) { @@ -604,7 +613,7 @@ export class DraftPlaceState { } if (preferredWhere?.kind === "device" && this.gateway.cloudProfilesReady) { - const device = findDevicePlacement(this.gateway.environments, preferredWhere.id); + const device = this.findDevice(preferredWhere.id); this.deviceIdValue = device?.selectable === true ? preferredWhere.id : ""; this.cloudProfileIdValue = ""; this.repositoryState.forceWorktree(Boolean(this.deviceIdValue)); diff --git a/ui/src/pages/new-session/draft-submission-flow.test.ts b/ui/src/pages/new-session/draft-submission-flow.test.ts index f175840ba6b3..9bd22844a1bf 100644 --- a/ui/src/pages/new-session/draft-submission-flow.test.ts +++ b/ui/src/pages/new-session/draft-submission-flow.test.ts @@ -320,7 +320,7 @@ describe("DraftSubmissionFlow submit gates", () => { workspaceGit: false, model: { primary: "openai/gpt-5.6-sol" }, agentRuntime: { - id: "codex", + id: "cloud-only", cloudPlacementSupported: true, devicePlacementSupported: false, source: "model", @@ -351,10 +351,12 @@ describe("DraftSubmissionFlow submit gates", () => { expect(fixture.flow.submitBlock()).toEqual({ gate: "device-runtime", - reason: "Needs the embedded runtime", + reason: "This runtime does not support paired devices", }); expect(fixture.flow.canSubmit()).toBe(false); - expect(fixture.flow.submitDisabledReason()).toBe("Needs the embedded runtime"); + expect(fixture.flow.submitDisabledReason()).toBe( + "This runtime does not support paired devices", + ); expect(fixture.request).not.toHaveBeenCalledWith("node.list", expect.anything()); }); }); diff --git a/ui/src/pages/new-session/model-control.test.ts b/ui/src/pages/new-session/model-control.test.ts index 23ea29ec4977..f4d4f2a19f49 100644 --- a/ui/src/pages/new-session/model-control.test.ts +++ b/ui/src/pages/new-session/model-control.test.ts @@ -64,6 +64,41 @@ describe("new-session model runtime", () => { expect(control.cloudRuntimeUnsupportedReason(profile)).toBe(expected); }); + it.each([ + { + name: "allows opted-in remote execution", + runtimeId: "codex", + devicePlacement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + }, + { + name: "allows embedded execution", + runtimeId: "openclaw", + devicePlacement: { requiredNodeCommands: [], consumesWorkerSlot: true }, + }, + { name: "rejects a cloud-only runtime", runtimeId: "cloud-only" }, + { + name: "rejects a stale support flag without an owner requirement", + runtimeId: "stale", + devicePlacementSupported: true, + }, + ])("$name on paired devices", ({ runtimeId, devicePlacement, devicePlacementSupported }) => { + const control = new NewSessionModelControl(() => undefined); + vi.spyOn(control, "resolveAgentRuntime").mockReturnValue({ + id: runtimeId, + cloudPlacementSupported: true, + devicePlacementSupported: devicePlacementSupported ?? Boolean(devicePlacement), + ...(devicePlacement ? { devicePlacement } : {}), + source: "model", + }); + + expect(control.devicePlacementUnsupportedReason()).toBe( + devicePlacement ? undefined : "This runtime does not support paired devices", + ); + }); + it("keeps CLI agents hidden and undiscovered while the Labs gate is off", async () => { const { context, request } = contextWith([ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", provider: "openai" }, diff --git a/ui/src/pages/new-session/model-control.ts b/ui/src/pages/new-session/model-control.ts index 20a5135fc6ba..adad921c2a04 100644 --- a/ui/src/pages/new-session/model-control.ts +++ b/ui/src/pages/new-session/model-control.ts @@ -26,7 +26,6 @@ import type { NewSessionPreference } from "./preferences.ts"; type NewSessionMetadataClient = NonNullable; type GatewayAgentRuntime = NonNullable & { cloudPlacementSupported?: boolean; - devicePlacementSupported?: boolean; }; type NewSessionMetadataStatus = ChatModelCatalogState["status"]; type NewSessionMetadataState = { @@ -514,10 +513,11 @@ export class NewSessionModelControl { } devicePlacementUnsupportedReason(): string | undefined { - return this.resolveAgentRuntime({ + const runtime = this.resolveAgentRuntime({ agent: this.pendingAgent, context: this.pendingContext, - })?.devicePlacementSupported === false + }); + return runtime && !runtime.devicePlacement ? t("newSession.deviceRuntimeUnsupported") : undefined; } diff --git a/ui/src/pages/new-session/new-session-page.ts b/ui/src/pages/new-session/new-session-page.ts index 9057dac1bcb4..4430dbc24093 100644 --- a/ui/src/pages/new-session/new-session-page.ts +++ b/ui/src/pages/new-session/new-session-page.ts @@ -375,6 +375,7 @@ export class NewSessionPage extends OpenClawLightDomElement { cloudProfileId: this.place.cloudProfileId, machineClass: this.place.machineClass, deviceId: this.place.deviceId, + devicePlacement: this.place.devicePlacementRequirement(), deviceDisabledReason: this.place.modelControl.devicePlacementUnsupportedReason(), }); const projectState = resolveProjectChip({ diff --git a/ui/src/pages/new-session/where-chip.test.ts b/ui/src/pages/new-session/where-chip.test.ts index 7b24be943771..5281ea0db45c 100644 --- a/ui/src/pages/new-session/where-chip.test.ts +++ b/ui/src/pages/new-session/where-chip.test.ts @@ -118,7 +118,7 @@ describe("Where chip", () => { cloudProfiles: [], cloudProfileId: "", deviceId: "", - deviceDisabledReason: "Needs the embedded runtime", + deviceDisabledReason: "This runtime does not support paired devices", }); const container = document.createElement("div"); render( @@ -146,7 +146,90 @@ describe("Where chip", () => { const device = container.querySelector('[data-value="device:macbook"]'); expect(device?.disabled).toBe(true); - expect(device?.textContent).toContain("Needs the embedded runtime"); - expect(device?.title).toBe("Needs the embedded runtime"); + expect(device?.textContent).toContain("This runtime does not support paired devices"); + expect(device?.title).toBe("This runtime does not support paired devices"); }); + + it.each([ + { + name: "allows enabled remote execution without a free worker slot", + devicePlacement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + workerSlots: { total: 1, available: 0 }, + invocableCommands: ["codex.exec-server.stdio.v1"], + disabled: false, + }, + { + name: "keeps worker execution capacity-gated", + devicePlacement: { requiredNodeCommands: [], consumesWorkerSlot: true }, + workerSlots: { total: 1, available: 0 }, + invocableCommands: [], + disabled: true, + reason: /worker slots/i, + }, + { + name: "disables a declared remote command that the Gateway has not enabled", + devicePlacement: { + requiredNodeCommands: ["codex.exec-server.stdio.v1"], + consumesWorkerSlot: false, + }, + workerSlots: { total: 1, available: 1 }, + invocableCommands: [], + disabled: true, + reason: /enable|approv/i, + }, + ])( + "$name in the New Session picker", + ({ devicePlacement, workerSlots, invocableCommands, disabled, reason }) => { + const state = resolveWhereChip({ + environments: [ + { + id: "node:runner", + type: "node", + label: "Build runner", + status: "available", + sessionHost: true, + workerSlots, + capabilities: ["codex.exec-server.stdio.v1"], + invocableCommands, + }, + ], + cloudProfiles: [], + cloudProfileId: "", + deviceId: "", + devicePlacement, + }); + const container = document.createElement("div"); + render( + renderWhereChip({ + state, + gatewayName: "", + cloudProfileId: "", + deviceId: "", + worktreeAvailable: true, + submitting: false, + pendingPlacement: false, + popoverOpen: true, + popoverHiding: false, + isAdmin: true, + onGuardTransition: vi.fn(), + onPopoverShow: vi.fn(), + onPopoverHide: vi.fn(), + onPopoverAfterHide: vi.fn(), + onSelectDevice: vi.fn(), + onSelectCloudProfile: vi.fn(), + onConnectMachine: vi.fn(), + }), + container, + ); + + const device = container.querySelector('[data-value="device:runner"]'); + expect(device?.disabled).toBe(disabled); + if (reason) { + expect(device?.title).toMatch(reason); + } + }, + ); }); diff --git a/ui/src/pages/new-session/where-chip.ts b/ui/src/pages/new-session/where-chip.ts index f31640e04d98..ca4bea50eb73 100644 --- a/ui/src/pages/new-session/where-chip.ts +++ b/ui/src/pages/new-session/where-chip.ts @@ -7,7 +7,11 @@ import { renderConnectMachineMenuItem, renderSessionMenuItem, } from "./cloud-target.ts"; -import { projectDevicePlacements, type DevicePlacementOption } from "./device-placement.ts"; +import { + projectDevicePlacements, + type DevicePlacementOption, + type DevicePlacementRequirement, +} from "./device-placement.ts"; import type { DraftCloudProfile, DraftEnvironment, DraftMachineOption } from "./discovery.ts"; type WhereChipState = Readonly<{ @@ -26,9 +30,10 @@ export function resolveWhereChip(params: { cloudProfileId: string; machineClass?: string; deviceId: string; + devicePlacement?: DevicePlacementRequirement; deviceDisabledReason?: string; }): WhereChipState { - const devices = projectDevicePlacements(params.environments); + const devices = projectDevicePlacements(params.environments, params.devicePlacement); const device = devices.find((candidate) => candidate.deviceId === params.deviceId); const profile = params.cloudProfiles.find((candidate) => candidate.id === params.cloudProfileId); if (params.cloudProfileId) { diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 5ddb3bbff3d7..cba49169a8d0 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -760,6 +760,57 @@ describe("PluginsPage", () => { expect(calls).toContainEqual(["plugins.list", {}]); }); + it("does not let an older uninstall republish its page notice after a newer row action", async () => { + const uninstallResult = deferred(); + const enabledPlugin = createPlugin({ enabled: true, state: "enabled" }); + const removable = createPlugin({ + id: "community-thing", + name: "Community Thing", + origin: "global", + removable: true, + featured: false, + }); + const { client, request } = createClient(async (method) => { + if (method === "plugins.uninstall") { + return uninstallResult.promise; + } + if (method === "plugins.setEnabled") { + return { ok: true, plugin: enabledPlugin, restartRequired: false }; + } + if (method === "plugins.list") { + return createResult(enabledPlugin); + } + throw new Error(`Unexpected method ${method}`); + }); + const harness = createGateway(client); + const { page } = await mountPage( + createContext(harness.gateway), + createPluginsRouteData(harness.gateway, { + plugins: [createPlugin(), removable], + diagnostics: [], + mutationAllowed: true, + }), + ); + + const uninstall = page.uninstall("community-thing", "plugin:community-thing"); + await waitForFast(() => + expect(request).toHaveBeenCalledWith("plugins.uninstall", { pluginId: "community-thing" }), + ); + await page.updateEnabled("workboard", true); + + uninstallResult.resolve({ + ok: true, + pluginId: "community-thing", + restartRequired: true, + removed: ["config entry", "install record", "directory"], + }); + await uninstall; + await page.updateComplete; + + expect(page.querySelector(".plugins-page-notice")).toBeNull(); + expect(page.messages["plugin:workboard"]?.text).toContain("Enabled Workboard"); + }); + it("adds an MCP server through the shared config seam", async () => { const { client } = createClient(async (method) => { if (method === "plugins.list") { diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 68b55349eb55..7c6326274b31 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -717,6 +717,7 @@ class PluginsPage extends OpenClawLightDomElement { refreshError: string | null, client: GatewayBrowserClient, isCurrent: () => boolean, + isLatest: () => boolean, ) => Promise, onError: (error: unknown) => void = (error) => { this.setMessage(rowKey, { @@ -730,10 +731,12 @@ class PluginsPage extends OpenClawLightDomElement { if (!scope || !this.canMutate() || this.busy[rowKey]) { return; } + this.pageNotice = null; const mutationToken = ++this.mutationToken; this.mutationTokens.set(rowKey, mutationToken); const isCurrent = () => this.gateway.isCurrent(scope) && this.mutationTokens.get(rowKey) === mutationToken; + const isLatest = () => isCurrent() && this.mutationToken === mutationToken; this.setBusy(rowKey, true); if (!options.preserveMessageWhilePending) { this.setMessage(rowKey, null); @@ -747,7 +750,7 @@ class PluginsPage extends OpenClawLightDomElement { if (!isCurrent()) { return; } - await onSuccess(mutation.value, mutation.refreshError, scope.client, isCurrent); + await onSuccess(mutation.value, mutation.refreshError, scope.client, isCurrent, isLatest); } catch (error) { if (isCurrent()) { onError(error); @@ -840,19 +843,21 @@ class PluginsPage extends OpenClawLightDomElement { await this.runPluginMutation( rowKey, (client) => uninstallPlugin(client, pluginId), - async (result, refreshError, client) => { + async (result, refreshError, client, _isCurrent, isLatest) => { this.setPendingRemoval(rowKey, false); // Removal hides its row, so keep the restart reminder on the page. - this.pageNotice = { - kind: "success", - text: [ - t("pluginsPage.removedRestart", { name: result.pluginId }), - ...(result.warnings ?? []).map((warning) => formatUiExternalText(warning)), - refreshError ? t("pluginsPage.configRefreshFailed", { error: refreshError }) : null, - ] - .filter(Boolean) - .join("\n"), - }; + if (isLatest()) { + this.pageNotice = { + kind: "success", + text: [ + t("pluginsPage.removedRestart", { name: result.pluginId }), + ...(result.warnings ?? []).map((warning) => formatUiExternalText(warning)), + refreshError ? t("pluginsPage.configRefreshFailed", { error: refreshError }) : null, + ] + .filter(Boolean) + .join("\n"), + }; + } await this.refreshCatalogAfterMutation(client); }, ); diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index def3964a4f89..cec933712a9f 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -676,6 +676,42 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { "Removed calendar-plus", ); + await gateway.setMethodResponse("plugins.list", uninstalledInventory); + await page.getByRole("tab", { name: /^Discover/u }).click(); + const searchCountBeforeReinstall = (await gateway.getRequests("plugins.search")).length; + await page.getByRole("searchbox", { name: "Search plugins" }).fill("calendar"); + await waitForNextRequest(gateway, "plugins.search", searchCountBeforeReinstall); + const reinstallRow = page.locator( + '[data-package-name="calendar-plus"][data-plugin-status="not-installed"]', + ); + await reinstallRow.waitFor({ state: "visible" }); + await gateway.setMethodResponse("plugins.install", installResult); + await gateway.setMethodResponse("plugins.list", finalInventory); + const installCountBeforeReinstall = (await gateway.getRequests("plugins.install")).length; + await reinstallRow + .getByRole("button", { name: "Install Calendar Plus", exact: true }) + .click(); + const reinstallRequest = await waitForNextRequest( + gateway, + "plugins.install", + installCountBeforeReinstall, + ); + expect(requestParams(reinstallRequest)).toEqual({ + source: "clawhub", + packageName: "calendar-plus", + }); + const reinstalledRow = page.locator( + '[data-package-name="calendar-plus"][data-plugin-status="enabled"]', + ); + await reinstalledRow.waitFor({ state: "attached" }); + await captureScreenshot(page, "11-reinstalled-feedback-desktop.png"); + expect(await page.locator(".plugins-page-notice").count()).toBe(0); + expect(await reinstalledRow.getByRole("status").textContent()).toContain( + "Installed Calendar Plus", + ); + + await page.getByRole("tab", { name: /^Installed/u }).click(); + await page.getByRole("searchbox", { name: "Search plugins" }).fill(""); await page.setViewportSize(mobileViewport); await expect .poll(() => diff --git a/ui/src/pages/route-provenance.test.ts b/ui/src/pages/route-provenance.test.ts index 2ea824847a14..d8e0564034d5 100644 --- a/ui/src/pages/route-provenance.test.ts +++ b/ui/src/pages/route-provenance.test.ts @@ -6,8 +6,10 @@ import type { ApplicationContext, ApplicationGatewaySnapshot } from "../app/cont import { page as agentsPage, type AgentsRouteData } from "./agents/route.ts"; import type { DevicesRouteData } from "./devices/devices-page.ts"; import { page as devicesPage } from "./devices/route.ts"; -import type { ModelProvidersRouteData } from "./model-providers/model-providers-page.ts"; -import { page as modelProvidersPage } from "./model-providers/route.ts"; +import { + page as modelProvidersPage, + type ModelProvidersRouteData, +} from "./model-providers/route.ts"; import type { PluginsRouteData } from "./plugins/plugins-page.ts"; import { page as pluginsPage } from "./plugins/route.ts"; import { page as sessionsPage, type SessionsRouteData } from "./sessions/route.ts"; @@ -148,7 +150,8 @@ describe("route preload gateway provenance", () => { const replacementClient = { request: replacementRequest, } as unknown as GatewayBrowserClient; - const mutable = mutableGateway(snapshot(originalClient, true)); + const originalSnapshot = snapshot(originalClient, true); + const mutable = mutableGateway(originalSnapshot); const request = loadRoute(modelProvidersPage, { gateway: mutable.gateway, agents: { @@ -166,6 +169,8 @@ describe("route preload gateway provenance", () => { mutable.replaceSnapshot(snapshot(replacementClient, true)); const data = await request; + expect(data.gateway).toBe(mutable.gateway); + expect(data.gatewaySnapshot).toBe(originalSnapshot); expect(data.client).toBe(originalClient); expect(originalRequest).toHaveBeenCalled(); expect(replacementRequest).not.toHaveBeenCalled(); diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index c75a5152922d..6d900556ad74 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -1510,6 +1510,8 @@ class SessionsPage extends OpenClawLightDomElement { override render() { const context = this.context; + const personGroupingAvailable = + context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities === true; if (!context) { return html``; } @@ -1559,7 +1561,10 @@ class SessionsPage extends OpenClawLightDomElement { ), sortColumn: this.sortColumn, sortDir: this.sortDir, - groupBy: this.groupBy, + // Same reconnect resilience as the sidebar: the stored Person + // preference survives a temporarily hidden identity capability. + groupBy: personGroupingAvailable || this.groupBy !== "person" ? this.groupBy : "none", + personGroupingAvailable, knownCategories: this.knownCategories(), page: this.page, pageSize: this.pageSize, diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts index 26285eef43a8..f70ceae99223 100644 --- a/ui/src/pages/sessions/view.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -49,6 +49,7 @@ function buildProps(result: SessionsListResult): SessionsProps { sortColumn: "updated", sortDir: "desc", groupBy: "none", + personGroupingAvailable: true, knownCategories: [], page: 0, pageSize: 10, @@ -482,6 +483,57 @@ describe("sessions view", () => { expect(container.querySelectorAll(".session-data-row")).toHaveLength(1); }); + it("offers person grouping and labels owner sections from their durable profile", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps( + buildMultiResult([ + { + key: "agent:main:ada", + kind: "direct", + updatedAt: 2, + owner: { actor: { type: "human", id: "profile-ada", label: "Ada Lovelace" } }, + }, + { key: "agent:main:ownerless", kind: "direct", updatedAt: 1 }, + ]), + ), + groupBy: "person", + }), + container, + ); + await Promise.resolve(); + + expect( + container + .querySelector('.session-groupby__select option[value="person"]') + ?.textContent?.trim(), + ).toBe("Person"); + expect( + [...container.querySelectorAll(".session-group-row__label")].map((label) => + label.textContent?.trim(), + ), + ).toEqual(["Ada Lovelace", "Ungrouped"]); + }); + + it("hides the person grouping option without the identity capability", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps(buildResult({ key: "agent:main:a", kind: "direct", updatedAt: 1 })), + personGroupingAvailable: false, + }), + container, + ); + await Promise.resolve(); + + const modes = [ + ...container.querySelectorAll(".session-groupby__select option"), + ].map((option) => option.value); + expect(modes).not.toContain("person"); + expect(modes).toContain("category"); + }); + it("selects and names the current page size on first render", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index a4cd25169c9b..3fe4a8b60c76 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -90,6 +90,8 @@ export type SessionsProps = { sortColumn: "key" | "kind" | "updated" | "tokens"; sortDir: "asc" | "desc"; groupBy: SessionsGroupBy; + /** Multi-identity gateways only; hides the Person mode elsewhere. */ + personGroupingAvailable: boolean; knownCategories: string[]; page: number; pageSize: number; @@ -780,6 +782,7 @@ function sessionsTableColumnCount(props: SessionsProps): number { const SESSION_GROUP_MODE_LABELS = { none: "sessionsView.groupByNone", category: "sessionsView.groupByCategory", + person: "sessionsView.groupByPerson", channel: "sessionsView.groupByChannel", kind: "sessionsView.groupByKind", agent: "sessionsView.groupByAgent", @@ -790,7 +793,8 @@ function groupModeLabel(mode: SessionsGroupBy): string { return t(SESSION_GROUP_MODE_LABELS[mode] ?? SESSION_GROUP_MODE_LABELS.none); } -function sessionGroupLabel(id: string, props: SessionsProps): string { +function sessionGroupLabel(group: SessionRowGroup, props: SessionsProps): string { + const { id } = group; if (props.groupBy === "date") { const labels: Record = { today: "sessionsView.dateToday", @@ -811,6 +815,9 @@ function sessionGroupLabel(id: string, props: SessionsProps): string { return emoji ? `${emoji} ${name}` : name; } } + if (props.groupBy === "person") { + return group.rows[0]?.owner?.actor.label?.trim() || id; + } return id; } @@ -856,7 +863,7 @@ function categoryDropHandlers(props: SessionsProps, category: string | null) { } function renderGroupHeaderRow(group: SessionRowGroup, props: SessionsProps) { - const label = sessionGroupLabel(group.id, props); + const label = sessionGroupLabel(group, props); const count = group.rows.length === 1 ? t("sessionsView.groupRowCountOne", { count: "1" }) @@ -1215,7 +1222,9 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) { @change=${(e: Event) => props.onGroupByChange((e.target as HTMLSelectElement).value as SessionsGroupBy)} > - ${SESSION_GROUP_MODES.map( + ${SESSION_GROUP_MODES.filter( + (mode) => mode !== "person" || props.personGroupingAvailable, + ).map( (mode) => html`