mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
Merge remote-tracking branch 'origin/main' into fix/inline-slash-skills
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+86
-22
@@ -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,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -152,14 +152,15 @@ valid host-bound evidence; it never means allowed. `unsupported` is reserved
|
||||
for a named path with no authoritative Phase 0 integration. A plugin-provided
|
||||
sender or structurally copied resolver result cannot upgrade either state.
|
||||
|
||||
A terminal approval receipt shows `allowed` or `denied`, its stable reason
|
||||
code, enforcement state, authoritative source boundary, policy and grant
|
||||
references, context fields used, and remediation. Expired and cancelled
|
||||
A terminal approval display shows `allowed` or `denied`, its stable reason
|
||||
code, enforcement state, verified producer class, policy and grant counts,
|
||||
context fields used, and remediation. Expired and cancelled
|
||||
approvals are denied non-actions with distinct reason codes. `no-route` is an
|
||||
enforced denial only when the approval owner recorded that terminal state. A
|
||||
corrupt approval is `unknown`. The text view labels `operator_approvals` as an
|
||||
authoritative owner-native SQLite record retained for 30 days; JSON preserves
|
||||
the same source owner and record reference without lossy reformatting.
|
||||
corrupt approval is `unknown`. The text view labels a verified
|
||||
operator-approval producer as an authoritative owner-native SQLite record
|
||||
retained for 30 days. Neither text nor JSON exposes the raw source owner, record
|
||||
reference, policy reference, or grant reference.
|
||||
`enforced` requires the approval's immutable owner-local binding to match the
|
||||
selected context, execution, and run exactly. A missing, malformed, or
|
||||
mismatched binding reports `operator_approval_execution_link_missing`,
|
||||
@@ -192,8 +193,9 @@ their exact tuple was recorded and the gate changed the outcome.
|
||||
Portable actions and early suppressions that have no durable delivery record
|
||||
use the generic decision-fact owner instead of duplicating delivery state.
|
||||
|
||||
JSON output is the Gateway result without lossy reformatting. An exact result contains one
|
||||
bounded V1 context (maximum 16 KiB), up to 100 decision receipts, coverage and
|
||||
JSON output is the Gateway's safe-only result without lossy reformatting. An
|
||||
exact result contains one bounded V1 context (maximum 16 KiB), up to 100
|
||||
`decisionDisplays`, coverage and
|
||||
missing-evidence codes, and an optional `nextDecisionCursor`. An ambiguous run
|
||||
result instead contains at most 50 execution candidates and an optional
|
||||
`nextExecutionCursor`. Sensitive domain,
|
||||
@@ -303,18 +305,25 @@ openclaw gateway call audit.run.inspect \
|
||||
--params '{"executionId":"5da4c4c3-e1c9-4c95-a17d-6e5c10fd45cf","decisionLimit":50}'
|
||||
```
|
||||
|
||||
Its result is `{ "schemaVersion": 1, "run": ..., "identity": ..., "decisions":
|
||||
..., "coverage": ..., "nextDecisionCursor"?: ..., "nextExecutionCursor"?: ... }`.
|
||||
Its result is `{ "schemaVersion": 1, "run": ..., "identity": ...,
|
||||
"decisionDisplays": ..., "coverage": ..., "nextDecisionCursor"?: ...,
|
||||
"nextExecutionCursor"?: ... }`. The required `decisionDisplays` array is the
|
||||
only receipt presentation field. Raw owner receipts and a `decisions` key never
|
||||
cross the Gateway boundary.
|
||||
The closed request accepts exactly one of `executionId` or `runId`.
|
||||
`decisionLimit` is 1–100 and `decisionCursor` is optional. Run discovery also
|
||||
accepts `executionLimit` from 1–50 and an optional `executionCursor`. A run
|
||||
with multiple retained executions returns the typed `ambiguous` identity state
|
||||
and no identity context or decisions until the caller selects an execution id.
|
||||
and no identity context; its required `decisionDisplays` array is empty until
|
||||
the caller selects an execution id.
|
||||
For one selected context, receipt paging starts with admission, then reads
|
||||
owner-native terminal approvals, merges outbound progress and terminal records,
|
||||
and finally reads generic facts for boundaries without a native durable record.
|
||||
The merge is deterministic across restart and rejects a cursor whose exact
|
||||
owner row has expired.
|
||||
owner row has expired. Approval and message selectors use the opaque
|
||||
`approval-decision:` and `message-decision:` namespaces minted from the same
|
||||
owner-query snapshot; raw receipt, resolution, and event identifiers never
|
||||
become selectors.
|
||||
Approval and delivery inspection never write generic duplicates. Generic fact
|
||||
writes and projections also require the full context, execution, and run tuple
|
||||
to match the immutable execution context.
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user