Merge remote-tracking branch 'origin/main' into fix/inline-slash-skills

This commit is contained in:
Jesse Merhi
2026-08-22 09:02:54 +10:00
926 changed files with 44461 additions and 9744 deletions
@@ -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 <landed-branch>`; 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 <verified-sha>`.
- 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 <pr> --repo openclaw/openclaw --body-file <comment.md> --attach <proof.mp4>`.
- `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.
+27 -9
View File
@@ -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-<run-id>` 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
+51 -6
View File
@@ -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 <full-release-run-id> --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 <full-release-run-id>
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-<run-id>-<attempt>` 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-<run-id>` artifact is the identity
source for every collector attempt.
## Failure Triage
1. Confirm parent SHA and child run IDs.
@@ -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 <public-json> [--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
File diff suppressed because it is too large Load Diff
@@ -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");
+76 -13
View File
@@ -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 }}
+6 -1
View File
@@ -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"
@@ -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
+4 -1
View File
@@ -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=<f>&content_type=<mime>&repository_id=<id>" -X POST -H "Authorization: Bearer $(gh auth token)" -H "Accept: application/json" --data-binary @<f>`; 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 <pr> --repo openclaw/openclaw --body-file <comment.md> --attach <proof.mp4>`; repeat `--attach <file>` 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=<f>&content_type=<mime>&repository_id=<id>" -X POST -H "Authorization: Bearer $(gh auth token)" -H "Accept: application/json" --data-binary @<f>`; 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 <pr> <head-sha>` — 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`.
+1
View File
@@ -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.
+86 -22
View File
@@ -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"
}
]
},
+8
View File
@@ -8,6 +8,14 @@
<!-- OpenClaw uses date-based version codes (yyyyMMddNN), which are high but still below the Android max. -->
<issue id="HighAppVersionCode" severity="ignore" />
<!-- Native locale refresh removes translated rows after source strings are removed. -->
<issue id="ExtraTranslation">
<ignore regexp="previous_item|next_item" />
</issue>
<issue id="UnusedResources">
<ignore regexp="previous_item|next_item" />
</issue>
<!-- Target SDK follows the current release train; bump only after platform compatibility testing. -->
<issue id="OldTargetApi" severity="ignore" />
</lint>
@@ -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
@@ -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
@@ -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"),
;
@@ -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",
@@ -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<Intent>,
) {
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,
@@ -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<WearSessionSummary> = emptyList(),
val sessionSearchQuery: String? = null,
val sessionSearchResults: List<WearSessionSummary> = emptyList(),
val sessionSearchHasMore: Boolean = false,
val sessionSearchSupported: Boolean = false,
val models: List<WearModelSummary> = emptyList(),
val modelSearchQuery: String? = null,
val modelSearchResults: List<WearModelSummary> = emptyList(),
val modelControlsSupported: Boolean = false,
val modelSearchSupported: Boolean = false,
val messages: List<WearChatMessage> = 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,
@@ -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<WearProxyCapability>,
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<WearProxyCapability> = 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(),
)
}
@@ -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<WearContextPicker?>(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
@@ -34,6 +34,13 @@ internal data class WearUiState(
val proxyCapabilities: Set<WearProxyCapability> = emptySet(),
val sessions: List<WearSession> = emptyList(),
val selectedSession: WearSession? = null,
val phoneActiveSessionKey: String? = null,
val sessionSearchQuery: String? = null,
val sessionSearchResults: List<WearSession> = emptyList(),
val sessionSearchHasMore: Boolean = false,
val sessionSearchNextOffset: Int? = null,
val modelSearchQuery: String? = null,
val modelSearchResults: List<WearModel> = emptyList(),
val messages: List<WearChatMessage> = 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) {
@@ -4,8 +4,6 @@
<string name="chat">Chat</string>
<string name="session">Session</string>
<string name="model">Model</string>
<string name="previous_item">Previous %1$s</string>
<string name="next_item">Next %1$s</string>
<string name="controls">Controls</string>
<string name="talk">Talk</string>
<string name="real_time_audio_failed">Watch audio failed</string>
@@ -43,6 +41,17 @@
<string name="start_conversation">Start a conversation</string>
<string name="start_conversation_detail">Talk or type on your watch. The paired phone sends the message through its authenticated OpenClaw session.</string>
<string name="current_session">Current session</string>
<string name="context_label_value" tools:ignore="MissingTranslation">%1$s: %2$s</string>
<string name="close" tools:ignore="MissingTranslation">Close</string>
<string name="search" tools:ignore="MissingTranslation">Search</string>
<string name="search_sessions" tools:ignore="MissingTranslation">Search sessions</string>
<string name="search_models" tools:ignore="MissingTranslation">Search models</string>
<string name="load_more" tools:ignore="MissingTranslation">Load more</string>
<string name="active_on_phone" tools:ignore="MissingTranslation">Active on phone</string>
<string name="open_on_watch" tools:ignore="MissingTranslation">Open on watch</string>
<string name="no_matches" tools:ignore="MissingTranslation">No matches</string>
<string name="search_sessions_title" tools:ignore="MissingTranslation">Find a session</string>
<string name="search_models_title" tools:ignore="MissingTranslation">Find a model</string>
<string name="appearance">Appearance</string>
<string name="theme_dark">Dark</string>
<string name="theme_light">Light</string>
@@ -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
@@ -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 =
@@ -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<Void, Never>?
/// 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)")
}
}
}
@@ -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 {
+69 -38
View File
@@ -27,19 +27,24 @@ final class CronJobsStore {
private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>?
private var pollTask: Task<Void, Never>?
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
}
}
@@ -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 {
@@ -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")
}
@@ -108,7 +108,6 @@ enum DebugActions {
}
case .unconfigured:
await GatewayConnection.shared.shutdown()
await ControlChannel.shared.disconnect()
}
}
@@ -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
@@ -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(
+1 -1
View File
@@ -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) {
@@ -62,6 +62,9 @@ final class CodexAppServerThreadClient: @unchecked Sendable {
var requestData: Data?
var continuation: CheckedContinuation<Data, Error>?
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)
}
}
@@ -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
}
@@ -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
@@ -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<Data>.makeStream()
@@ -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))
}
}
@@ -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()
}
}
@@ -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 {
@@ -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<T>(
_ 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)
@@ -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(
@@ -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))
}
@@ -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()
@@ -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) {
@@ -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()
}
}
@@ -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"
+4 -7
View File
@@ -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
+2
View File
@@ -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.
@@ -1 +1 @@
73cfbac3e2ef8a75d561165d9798c805e0a8c726c1bcd1c814c7cae777194b2b sqlite-session-transcript-schema-baseline.sql
fecbb8adccfa0be0b452f646d3bee8d5a17faeb2c2d8a2300fb75ef173c709cd sqlite-session-transcript-schema-baseline.sql
-5
View File
@@ -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.
+21 -12
View File
@@ -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 1100 and `decisionCursor` is optional. Run discovery also
accepts `executionLimit` from 150 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.
+1 -1
View File
@@ -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)
</Accordion>
</AccordionGroup>
+7 -4
View File
@@ -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
+14 -5
View File
@@ -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
+61 -9
View File
@@ -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":"<paired-device-id>"}'
```
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.
+12 -8
View File
@@ -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
+7 -5
View File
@@ -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
+1 -1
View File
@@ -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 |
+12 -4
View File
@@ -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 },
);
}
```
+78 -3
View File
@@ -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,
+33 -15
View File
@@ -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:
+140 -4
View File
@@ -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 <requestId>`. 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":"<paired-device-id>"}'
```
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
+31 -1
View File
@@ -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 })`.
+5 -3
View File
@@ -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.
</Step>
@@ -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 |
+12 -6
View File
@@ -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
+6
View File
@@ -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.
+11
View File
@@ -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
+6
View File
@@ -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
+52 -7
View File
@@ -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-<run-id>` 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`<br />**Child workflow:** `OpenClaw Release Checks`<br />**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.<br />**Rerun:** classify the failed surface and select one concrete release-check group. |
| Package Telegram | **Job:** `Run package Telegram E2E`<br />**Child workflow:** `NPM Telegram Beta E2E`<br />**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.<br />**Rerun:** `rerun_group=npm-telegram` with `release_package_spec` or `npm_telegram_package_spec`. |
| Product performance | **Job:** `Run product performance evidence`<br />**Child workflow:** `OpenClaw Performance`<br />**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.<br />**Rerun:** `rerun_group=performance`. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**Proves:** re-checks recorded child run conclusions and appends slowest-job tables from child workflows.<br />**Rerun:** rerun only this job after rerunning a failed child to green. |
| Release decision | **Job:** `Release Decision`<br />**Child workflow:** none<br />**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.<br />**Rerun:** fix or rerun only the blocking surface. |
| Diagnostic drain | **Job:** `Diagnostic Drain`<br />**Child workflow:** none<br />**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.<br />**Rerun:** recover collection only for `orchestration_error`; product failures do not invalidate the drain. |
| Execution plan | **Job:** `Seal release execution plan`<br />**Child workflow:** none<br />**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.<br />**Rerun:** restore the existing plan only; a missing plan is an orchestration error. |
| Umbrella verifier | **Job:** `Verify full validation`<br />**Child workflow:** none<br />**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.<br />**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
+1 -1
View File
@@ -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 <git-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
+5 -1
View File
@@ -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:<jobId>` 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
+9 -3
View File
@@ -331,7 +331,7 @@ select it to open the owning Approvals page.
</Accordion>
<Accordion title="Debug, logs, update">
- 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=<percent-encoded-run-id>` 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=<percent-encoded-run-id>` 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=<opaque-display-selector>` and, for a later page, `decision=<opaque-cursor>` 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=<percent-encoded-execution-id>` and query that exact execution.
+8 -5
View File
@@ -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
@@ -105,6 +105,7 @@ describe("acpx doctor state migration", () => {
});
afterEach(async () => {
resetPluginStateStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -65,6 +65,7 @@ describe("active-memory doctor state migration", () => {
afterEach(async () => {
vi.useRealTimers();
resetPluginStateStoreForTests();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -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 });
}
});
@@ -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<typeof createTestDeps>): Record<str
}
describe("createMantleAnthropicStreamFn", () => {
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", () => {
@@ -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<typeof Anthropic>[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(
@@ -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<typeof streamSimpleBedrock>[2] = {}) {
return streamSimpleBedrock(
model as never,
{ messages: [{ role: "user", content: "Hello", timestamp: 0 }] } as never,
options,
);
}
function expectDestroyedClient(
send: ReturnType<typeof vi.spyOn>,
destroy: ReturnType<typeof vi.spyOn>,
) {
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<void>((resolve) => {
markStreamBlocked = resolve;
});
let releaseStream!: () => void;
const streamReleased = new Promise<void>((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<IteratorResult<never>>(() => {})),
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);
});
});
@@ -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<typeof vi.spyOn>,
destroy: ReturnType<typeof vi.spyOn>,
) {
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<void>((resolve) => {
markStreamBlocked = resolve;
});
let releaseStream!: () => void;
const streamReleased = new Promise<void>((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) =>
+10 -4
View File
@@ -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<string, string> = {};
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(
@@ -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);
@@ -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;
@@ -16,6 +16,10 @@ const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"];
const RETIRED_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1";
const backgroundCleanups = new Set<() => Promise<void>>();
function waitForBackgroundState<T>(assertion: () => T | Promise<T>): Promise<T> {
return vi.waitFor(assertion, { interval: 1 });
}
export async function cleanupBackgroundHarnesses(): Promise<void> {
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);
});
},
@@ -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;
@@ -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();
}
@@ -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}/`);
@@ -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;
@@ -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<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
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,
});
}),
);
};
@@ -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", () => ({
@@ -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", () => ({
@@ -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<string, unknown>) => Promise<unknown>;
@@ -252,7 +250,7 @@ export async function setDeviceViaPlaywright(opts: {
if (!name) {
throw new Error("device name is required");
}
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
const descriptor = (getPlaywrightCore().devices as Record<string, unknown>)[name] as
| PlaywrightDeviceDescriptor
| undefined;
if (!descriptor) {
+34 -27
View File
@@ -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<void> {
// 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<string, unknown>;
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", () => {
+63 -3
View File
@@ -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",
+24 -7
View File
@@ -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 },
});
+29 -2
View File
@@ -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 },
},
);
+6
View File
@@ -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,
@@ -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",

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