mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
96 Commits
sqlite-str
...
v2026.4.27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc2ba0931 | ||
|
|
8a6cf36aec | ||
|
|
15901513a3 | ||
|
|
b2a4d3d99a | ||
|
|
50826a70c7 | ||
|
|
2d52abc011 | ||
|
|
c8749c4f8f | ||
|
|
6076794339 | ||
|
|
375f45054d | ||
|
|
728cbd7068 | ||
|
|
1a2a707297 | ||
|
|
7fde52d1e5 | ||
|
|
5370add03b | ||
|
|
56cb9506c0 | ||
|
|
2046212eaa | ||
|
|
b0a4d59401 | ||
|
|
18fa754699 | ||
|
|
04101e42eb | ||
|
|
6339b66ae1 | ||
|
|
d7fc1c0ad5 | ||
|
|
f6913c8269 | ||
|
|
b8dd69a100 | ||
|
|
3f1ac9dc10 | ||
|
|
68ae346547 | ||
|
|
d9a3f18db6 | ||
|
|
525a187d96 | ||
|
|
47b1f19add | ||
|
|
d93ff21fea | ||
|
|
d8d6e0e5b6 | ||
|
|
c18d636030 | ||
|
|
9f7d44ecc0 | ||
|
|
bad9f9de3a | ||
|
|
becd348278 | ||
|
|
d2fc7b39fc | ||
|
|
9aa980bf8e | ||
|
|
17d6fa87c8 | ||
|
|
5402f7166c | ||
|
|
49c42b9e92 | ||
|
|
09a13fd55a | ||
|
|
8d02646129 | ||
|
|
af43f5dabc | ||
|
|
e246a0e87f | ||
|
|
b771d99b6b | ||
|
|
9bc264cbed | ||
|
|
2c0797dfb9 | ||
|
|
bbefe55d74 | ||
|
|
2dbafe9d47 | ||
|
|
4046183e95 | ||
|
|
4629752801 | ||
|
|
e20b7334c6 | ||
|
|
b2eb387681 | ||
|
|
be9f00735f | ||
|
|
8fe647be7d | ||
|
|
ba4f62fad3 | ||
|
|
3d809b01a8 | ||
|
|
74530bc0cd | ||
|
|
d429bf4a08 | ||
|
|
1f945cf95f | ||
|
|
7b74b1b17e | ||
|
|
02e6866df2 | ||
|
|
7eae05e55e | ||
|
|
a38a33512d | ||
|
|
3e4b50a3e9 | ||
|
|
27599d319e | ||
|
|
bd11678122 | ||
|
|
c34ba97262 | ||
|
|
b8d15f8219 | ||
|
|
03195583fe | ||
|
|
18f65c2d58 | ||
|
|
78853d9adc | ||
|
|
f51008a8f6 | ||
|
|
ab17e06057 | ||
|
|
3978d44fa8 | ||
|
|
46eaa5171d | ||
|
|
dcc8190933 | ||
|
|
93ecd917ec | ||
|
|
55cdac2ab7 | ||
|
|
2ea5f19ec0 | ||
|
|
579e40269b | ||
|
|
462abf326a | ||
|
|
3a3859b484 | ||
|
|
3afc597287 | ||
|
|
b7a30fc201 | ||
|
|
3fc8277eb2 | ||
|
|
13327dbaef | ||
|
|
e09ddbd8b6 | ||
|
|
f55220d6b9 | ||
|
|
2e8f91c36e | ||
|
|
727bff8133 | ||
|
|
3ead5926b7 | ||
|
|
7e22aa0a0e | ||
|
|
f5cd467d93 | ||
|
|
7e42e2c087 | ||
|
|
99e69a232b | ||
|
|
90c3d4ae1d | ||
|
|
df76659019 |
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -349,7 +349,7 @@ jobs:
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
@@ -372,4 +372,4 @@ jobs:
|
||||
env:
|
||||
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
|
||||
OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s
|
||||
run: timeout 240s pnpm test:docker:bundled-channel-deps:fast
|
||||
run: timeout 480s pnpm test:docker:bundled-channel-deps:fast
|
||||
|
||||
@@ -333,6 +333,9 @@ jobs:
|
||||
cache: pnpm
|
||||
cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }}
|
||||
|
||||
- name: Ensure pnpm cache path exists
|
||||
run: mkdir -p "$(pnpm store path --silent)"
|
||||
|
||||
- name: Build candidate artifact once
|
||||
if: inputs.candidate_artifact_name == ''
|
||||
env:
|
||||
@@ -343,12 +346,19 @@ jobs:
|
||||
--source-dir source \
|
||||
--output-dir "${OUTPUT_DIR}"
|
||||
|
||||
- name: Download provided candidate artifact
|
||||
if: inputs.candidate_artifact_name != ''
|
||||
- name: Download current-run candidate artifact
|
||||
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id == ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.candidate_artifact_name }}
|
||||
run-id: ${{ inputs.candidate_artifact_run_id || github.run_id }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
|
||||
|
||||
- name: Download previous-run candidate artifact
|
||||
if: inputs.candidate_artifact_name != '' && inputs.candidate_artifact_run_id != ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.candidate_artifact_name }}
|
||||
run-id: ${{ inputs.candidate_artifact_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: ${{ runner.temp }}/openclaw-cross-os-release-checks/prepare/package
|
||||
|
||||
|
||||
@@ -1875,18 +1875,20 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.5" >> "$GITHUB_ENV"
|
||||
# The CLI backend Docker lane should exercise the same staged
|
||||
# Codex auth path Peter uses locally so MCP cron creation and
|
||||
# multimodal probes stay covered in CI. Replace the staged
|
||||
# config.toml with a minimal CI-safe config so the repo stays
|
||||
# trusted for MCP/tool use without inheriting maintainer-local
|
||||
# provider/profile overrides that do not exist inside CI.
|
||||
# Keep the release-blocking CI lane on Codex API-key auth. The
|
||||
# staged auth-file path remains supported for local maintainer
|
||||
# reruns, but it can hang on stale subscription/session state in
|
||||
# an otherwise healthy release run.
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
# Replace the staged config.toml with a minimal CI-safe config so
|
||||
# the repo stays trusted for MCP/tool use without inheriting
|
||||
# maintainer-local provider/profile overrides that do not exist
|
||||
# inside CI.
|
||||
# Codex's workspace-write sandbox relies on user namespaces that
|
||||
# this Docker lane does not provide, so run Codex unsandboxed
|
||||
# inside the already-isolated container to keep MCP cron/tool
|
||||
# execution representative instead of failing on nested sandbox
|
||||
# setup.
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
|
||||
13
.github/workflows/openclaw-release-checks.yml
vendored
13
.github/workflows/openclaw-release-checks.yml
vendored
@@ -63,6 +63,7 @@ env:
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.33.0"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
|
||||
OPENCLAW_QA_LIVE_OPENAI_MODEL: openai/gpt-5.4
|
||||
|
||||
jobs:
|
||||
resolve_target:
|
||||
@@ -314,7 +315,6 @@ jobs:
|
||||
provider: ${{ needs.resolve_target.outputs.provider }}
|
||||
mode: ${{ needs.resolve_target.outputs.mode }}
|
||||
candidate_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
candidate_artifact_run_id: ${{ github.run_id }}
|
||||
candidate_file_name: openclaw-current.tgz
|
||||
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
|
||||
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
|
||||
@@ -343,7 +343,6 @@ jobs:
|
||||
include_live_suites: true
|
||||
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
|
||||
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
package_artifact_run_id: ${{ github.run_id }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -404,7 +403,6 @@ jobs:
|
||||
with:
|
||||
workflow_ref: ${{ github.ref_name }}
|
||||
source: artifact
|
||||
artifact_run_id: ${{ github.run_id }}
|
||||
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
|
||||
package_sha256: ${{ needs.prepare_release_package.outputs.package_sha256 }}
|
||||
suite_profile: custom
|
||||
@@ -649,6 +647,7 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS: "90000"
|
||||
OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS: "3000"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -660,8 +659,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
|
||||
--profile fast \
|
||||
--fast
|
||||
)
|
||||
@@ -751,8 +750,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_QA_LIVE_OPENAI_MODEL}" \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci
|
||||
|
||||
15
.github/workflows/package-acceptance.yml
vendored
15
.github/workflows/package-acceptance.yml
vendored
@@ -281,8 +281,15 @@ jobs:
|
||||
install-bun: ${{ inputs.source == 'ref' && 'true' || 'false' }}
|
||||
install-deps: "false"
|
||||
|
||||
- name: Download package artifact input
|
||||
if: inputs.source == 'artifact'
|
||||
- name: Download current-run package artifact input
|
||||
if: inputs.source == 'artifact' && inputs.artifact_run_id == ''
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ inputs.artifact_name }}
|
||||
path: .artifacts/package-candidate-input
|
||||
|
||||
- name: Download previous-run package artifact input
|
||||
if: inputs.source == 'artifact' && inputs.artifact_run_id != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
|
||||
@@ -290,10 +297,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ARTIFACT_RUN_ID// }" ]]; then
|
||||
echo "artifact_run_id is required when source=artifact." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ARTIFACT_NAME// }" ]]; then
|
||||
echo "artifact_name is required when source=artifact." >&2
|
||||
exit 1
|
||||
|
||||
@@ -4,10 +4,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changes
|
||||
|
||||
- Channels: add Yuanbao channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
### Changes
|
||||
@@ -29,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin SDK/models: move BytePlus and Volcano Engine standard and plan-provider catalogs into plugin manifest `modelCatalog` rows and remove the now-unused Volcengine-family shared catalog SDK subpath. Thanks @shakkernerd.
|
||||
- CLI/models: move Fireworks and Together AI fixed provider catalogs into plugin manifest `modelCatalog` rows so provider-filtered listing can use manifest-backed static rows. Thanks @shakkernerd.
|
||||
- Channels/Yuanbao: register the Tencent Yuanbao external channel plugin (`openclaw-plugin-yuanbao`) in the official channel catalog, contract suites, and community plugin docs, with a new `docs/channels/yuanbao.md` quick-start guide for WebSocket bot DMs and group chats. (#72756) Thanks @loongfay.
|
||||
- Channels/Yuanbao: add a channel docs entrance so the Tencent Yuanbao bot appears in the channel listing and sidebar navigation. (#73443) Thanks @loongfay.
|
||||
- Channels/QQBot: add full group chat support (history tracking, @-mention gating, activation modes, per-group config, FIFO message queue with deliver debounce), C2C `stream_messages` streaming with a `StreamingController` lifecycle manager, unified `sendMedia` with chunked upload for large files, and refactor the engine into pipeline stages, focused outbound submodules, builtin slash-command modules, and explicit DI ports via `createEngineAdapters()`. (#70624) Thanks @cxyhhhhh.
|
||||
- Plugins/startup: migrate bundled plugin manifests to explicit `activation.onStartup` declarations so Gateway startup imports only the bundled plugins that intentionally register startup-time runtime surfaces. Thanks @shakkernerd.
|
||||
- Plugins/startup: add an opt-in future-mode gate for disabling deprecated implicit startup sidecar loading while preserving explicit startup and narrower activation triggers. Thanks @shakkernerd.
|
||||
@@ -62,17 +59,20 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/auth: clear reused stale device tokens and stop reconnecting on device-token mismatch in the Control UI and Node gateway clients, avoiding rate-limit loops after scope-upgrade or token-rotation handoffs. Fixes #71609. Thanks @ricksayhi.
|
||||
- Gateway/approvals: treat duplicate same-decision approval resolves as idempotent during the resolved-entry grace window, including consumed `allow-once` approvals, while returning an explicit already-resolved error for conflicting repeats. Fixes #59162; refs #58479 and #65486. Thanks @wikithoughts, @sajazuniga7-coder, and @mjmai20682068-create.
|
||||
- Channels/Telegram: honor `approvals.exec/plugin.targets[].accountId` when routing native approvals across multi-bot Telegram accounts while preserving unscoped Telegram targets for any account. Fixes #69916. Thanks @joerod26.
|
||||
- Telegram/gateway: bound outbound Bot API calls and cache bundled plugin alias lookup so slow Telegram sends or WSL2 filesystem scans no longer wedge gateway replies. (#74210) Thanks @obviyus.
|
||||
- Agents/exec: omit the internal session-resume fallback preface from successful async exec completion messages sent directly back to chat. Fixes #67181. Thanks @raistlin88.
|
||||
- Agents/media: register detached `video_generate` and `music_generate` tool run contexts until terminal status, so Discord-backed provider jobs stay live in `/tasks` instead of becoming `lost` when the parent chat run context disappears. Thanks @vincentkoc.
|
||||
- Agents/media: prefer OpenAI image and video providers when the default model uses the OpenAI Codex auth alias, so auto media generation no longer falls through to Fal before GPT Image or Sora. Thanks @vincentkoc.
|
||||
- Tasks/media: infer agent ownership for session-scoped task records so `/tasks` agent-local fallback includes session-backed `video_generate` and other async media jobs even when the current chat session has no linked rows. Thanks @vincentkoc.
|
||||
- Agents/media: keep long-running `video_generate` and `music_generate` tasks fresh while provider jobs are still pending, so task maintenance does not mark active Discord media renders lost before completion. Thanks @vincentkoc.
|
||||
- CLI/status: treat scope-limited gateway probes as reachable-but-degraded in shared status scans, so `openclaw status --all` no longer reports a live gateway as unreachable after `missing scope: operator.read`. Fixes #49180; supersedes #47981. Thanks @openjay.
|
||||
- CLI/update: skip tracked plugins disabled in config during post-update plugin sync before npm, ClawHub, or marketplace update checks, preserving their install records without failing the update. Fixes #73880. Thanks @islandpreneur007.
|
||||
- Slack/Socket Mode: use a 15s Slack SDK pong timeout by default and add `channels.slack.socketMode.clientPingTimeout`, `serverPingTimeout`, and `pingPongLoggingEnabled` overrides so stale-websocket handling no longer depends on app-event health heuristics. Fixes #14248; refs #58519, #64009, and #63488. Thanks @shivasymbl and @freerk.
|
||||
- Slack/media: bound private file and forwarded attachment downloads with idle and total timeouts while preserving placeholder fallback, so stalled Slack `file_share` media no longer wedges inbound message handling. Fixes #61850. Thanks @bassboy2k.
|
||||
- Plugins/inspector: keep bundled plugin runtime capture quiet and config-tolerant for Codex, memory-lancedb, Feishu, Mattermost, QQBot, and Tlon so plugin-inspector JSON checks can validate the full bundled set. Thanks @vincentkoc.
|
||||
- Slack/auto-reply: keep fully consumed text reset triggers such as `new session` out of `BodyForAgent` after directive cleanup, so configured Slack reset phrases do not leak into the fresh model turn. Fixes #73137. Thanks @neeravmakwana.
|
||||
- Plugins/runtime deps: prune stale retained bundled runtime deps and keep doctor/secret channel contract scans on lightweight artifacts, so disabled bundled channels stop preserving old dependency trees or importing heavy plugin surfaces. Thanks @SymbolStar and @vincentkoc.
|
||||
- Plugins/runtime deps: cache unchanged bundled runtime mirror dist-file materialization decisions and close file-lock handles on owner-write failures, reducing repeated startup chunk scans and avoiding FileHandle-GC recovery stalls. Refs #73532. Thanks @oadiazp and @bstanbury.
|
||||
- Auto-reply: bound the post-run pending tool-result delivery drain with a progress-aware idle timeout, so a never-settling tool-result task no longer leaves the session active forever while slow healthy deliveries can keep draining. Fixes #53889; supersedes #64733 and #73434. Thanks @zijunl and @wujiaming88.
|
||||
- Gateway/startup: start chat channels without waiting for primary model prewarm, keeping model warmup bounded in the background so Slack and other channels come online promptly when provider discovery is slow. Supersedes #73420. Thanks @dorukardahan.
|
||||
- Gateway/install: carry env-backed config SecretRefs such as `channels.discord.token` into generated service environments when they are present only in the installing shell, while keeping gateway auth SecretRefs non-persisted. Fixes #67817; supersedes #73426. Thanks @wdimaculangan and @ztexydt-cqh.
|
||||
@@ -307,6 +307,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW.
|
||||
- Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI.
|
||||
- Agents/sessions_spawn: resolve configured bare model aliases for spawn model overrides using the target agent runtime default provider, carrying forward the alias-specific #69029 review fixes from #59681 without the unrelated active-session pruning path. Fixes #59681. Thanks @HowdyDooToYou.
|
||||
- Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev.
|
||||
|
||||
@@ -63,6 +63,7 @@ COPY openclaw.mjs ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
|
||||
|
||||
COPY --from=ext-deps /out/ ./${OPENCLAW_BUNDLED_PLUGIN_DIR}/
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
9a688c953f0108f85f58c173e79c28363d846a592130abec04cafbcabbb22dcc plugin-sdk-api-baseline.json
|
||||
010252e56202abde0816787588239c41b4bfb710b930a5454848a5ae76ad6dae plugin-sdk-api-baseline.jsonl
|
||||
eb01888d55022db197e445659ebd24233486e1c0b6d6799b9adb5a9fe8a754b2 plugin-sdk-api-baseline.json
|
||||
98c1132dd42ae9c21dec9b9f8bb3c9717961553151e8f15909aaa95ac6af9812 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -52,9 +52,15 @@ present.
|
||||
|
||||
- OpenClaw creates collections from your workspace memory files and any
|
||||
configured `memory.qmd.paths`, then runs `qmd update` on boot and
|
||||
periodically (default every 5 minutes). Semantic modes also run `qmd embed`.
|
||||
periodically (default every 5 minutes). These refreshes run through QMD
|
||||
subprocesses, not an in-process filesystem crawl. Semantic modes also run
|
||||
`qmd embed`.
|
||||
- The default workspace collection tracks `MEMORY.md` plus the `memory/`
|
||||
tree. Lowercase `memory.md` is not indexed as a root memory file.
|
||||
- QMD's own scanner ignores hidden paths and common dependency/build
|
||||
directories such as `.git`, `.cache`, `node_modules`, `vendor`, `dist`, and
|
||||
`build`. Boot refreshes use a one-shot QMD subprocess path instead of
|
||||
creating the full long-lived in-process watcher during gateway startup.
|
||||
- Boot refresh runs in the background so chat startup is not blocked.
|
||||
- Searches use the configured `searchMode` (default: `search`; also supports
|
||||
`vsearch` and `query`). `search` is BM25-only, so OpenClaw skips semantic
|
||||
|
||||
@@ -99,6 +99,7 @@ Pass `--scenario <id>` (repeatable) to run a hand-picked set; combine with `--pr
|
||||
| Variable | Default | Effect |
|
||||
| --------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OPENCLAW_QA_MATRIX_TIMEOUT_MS` | `1800000` (30 min) | Hard upper bound on the entire run. |
|
||||
| `OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS` | `45000` | Bound for the initial canary reply. Release CI raises this on shared runners so a slow first gateway turn does not fail before scenario coverage starts. |
|
||||
| `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS` | `8000` | Quiet window for negative no-reply assertions. Clamped to `≤` the run timeout. |
|
||||
| `OPENCLAW_QA_MATRIX_CLEANUP_TIMEOUT_MS` | `90000` | Bound for Docker teardown. Failure surfaces include the recovery `docker compose ... down --remove-orphans` command. |
|
||||
| `OPENCLAW_QA_MATRIX_TUWUNEL_IMAGE` | `ghcr.io/matrix-construct/tuwunel:v1.5.1` | Override the homeserver image when validating against a different Tuwunel version. |
|
||||
|
||||
@@ -137,6 +137,7 @@ The Gateway writes a rolling log file (printed on startup as
|
||||
`gateway log file: ...`). Look for `bonjour:` lines, especially:
|
||||
|
||||
- `bonjour: advertise failed ...`
|
||||
- `bonjour: suppressing ciao cancellation ...`
|
||||
- `bonjour: ... name conflict resolved` / `hostname conflict resolved`
|
||||
- `bonjour: watchdog detected non-announced service ...`
|
||||
- `bonjour: disabling advertiser after ... failed restarts ...`
|
||||
|
||||
@@ -497,7 +497,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
|
||||
| ------------------------- | --------- | ------- | ------------------------------------- |
|
||||
| `update.interval` | `string` | `5m` | Refresh interval |
|
||||
| `update.debounceMs` | `number` | `15000` | Debounce file changes |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh on startup |
|
||||
| `update.onBoot` | `boolean` | `true` | Refresh on startup in a QMD subprocess |
|
||||
| `update.waitForBootSync` | `boolean` | `false` | Block startup until refresh completes |
|
||||
| `update.embedInterval` | `string` | -- | Separate embed cadence |
|
||||
| `update.commandTimeoutMs` | `number` | -- | Timeout for QMD commands |
|
||||
@@ -545,6 +545,8 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
|
||||
|
||||
### Full QMD example
|
||||
|
||||
```json5
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("does not install process-level ciao handlers by default", async () => {
|
||||
it("installs only the scoped ciao unhandled-rejection listener by default", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -331,7 +331,7 @@ describe("gateway bonjour advertiser", () => {
|
||||
{ logger },
|
||||
);
|
||||
|
||||
expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
|
||||
expect(processOn).toHaveBeenCalledWith("unhandledRejection", expect.any(Function));
|
||||
expect(processOn).not.toHaveBeenCalledWith("uncaughtException", expect.any(Function));
|
||||
|
||||
await started.stop();
|
||||
@@ -393,11 +393,11 @@ describe("gateway bonjour advertiser", () => {
|
||||
expect(exceptionHandler).toBeTypeOf("function");
|
||||
|
||||
expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true);
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ignoring unhandled ciao rejection"),
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("suppressing ciao cancellation"),
|
||||
);
|
||||
|
||||
logger.debug.mockClear();
|
||||
logger.warn.mockClear();
|
||||
expect(
|
||||
handler?.(new Error("Reached illegal state! IPV4 address change from defined to undefined!")),
|
||||
).toBe(true);
|
||||
@@ -423,6 +423,37 @@ describe("gateway bonjour advertiser", () => {
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("recovers when ciao cancellation escapes the advertiser", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
|
||||
const destroy = vi.fn().mockResolvedValue(undefined);
|
||||
const advertise = vi.fn().mockResolvedValue(undefined);
|
||||
mockCiaoService({ advertise, destroy });
|
||||
|
||||
const started = await startAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
});
|
||||
|
||||
const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as
|
||||
| ((reason: unknown) => boolean)
|
||||
| undefined;
|
||||
expect(handler?.(new Error("CIAO ANNOUNCEMENT CANCELLED"))).toBe(true);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(createService).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("suppressing ciao cancellation"),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("restarting advertiser"));
|
||||
expect(destroy).toHaveBeenCalledTimes(1);
|
||||
expect(advertise).toHaveBeenCalledTimes(2);
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
it("logs advertise failures and retries via watchdog", async () => {
|
||||
enableAdvertiserUnitMode();
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -69,6 +69,7 @@ type ServiceStateTracker = {
|
||||
type ConsoleLogFn = (...args: unknown[]) => void;
|
||||
type UncaughtExceptionHandler = (error: unknown) => boolean;
|
||||
type UnhandledRejectionHandler = (reason: unknown) => boolean;
|
||||
type ProcessUnhandledRejectionListener = (reason: unknown, promise: Promise<unknown>) => void;
|
||||
type ExecBridge = (command: string, options?: unknown, callback?: unknown) => ChildProcess;
|
||||
type ExecOptionsRecord = Record<string, unknown> & { windowsHide?: boolean };
|
||||
|
||||
@@ -324,6 +325,25 @@ function installCiaoWindowsExecHidePatch(): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
function installCiaoUnhandledRejectionListener(handler: UnhandledRejectionHandler): () => void {
|
||||
const hadOtherListeners = process.listenerCount("unhandledRejection") > 0;
|
||||
const listener: ProcessUnhandledRejectionListener = (reason) => {
|
||||
if (handler(reason)) {
|
||||
return;
|
||||
}
|
||||
if (hadOtherListeners) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
throw reason instanceof Error ? reason : new Error(String(reason));
|
||||
});
|
||||
};
|
||||
process.on("unhandledRejection", listener);
|
||||
return () => {
|
||||
process.off("unhandledRejection", listener);
|
||||
};
|
||||
}
|
||||
|
||||
export async function startGatewayBonjourAdvertiser(
|
||||
opts: GatewayBonjourAdvertiseOpts,
|
||||
deps: BonjourAdvertiserDeps = {},
|
||||
@@ -341,6 +361,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
let restoreConsoleLog: () => void = () => {};
|
||||
let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined;
|
||||
let cleanupUnhandledRejection: (() => void) | undefined;
|
||||
let cleanupDirectUnhandledRejection: (() => void) | undefined;
|
||||
let cleanupUncaughtException: (() => void) | undefined;
|
||||
let processHandlersCleaned = false;
|
||||
|
||||
@@ -349,6 +370,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
return;
|
||||
}
|
||||
processHandlersCleaned = true;
|
||||
cleanupDirectUnhandledRejection?.();
|
||||
cleanupUncaughtException?.();
|
||||
cleanupUnhandledRejection?.();
|
||||
}
|
||||
@@ -363,7 +385,8 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
|
||||
if (classification.kind === "cancellation") {
|
||||
logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`);
|
||||
logger.warn(`bonjour: suppressing ciao cancellation: ${classification.formatted}`);
|
||||
requestCiaoRecovery?.(classification);
|
||||
} else if (classification.kind === "interface-enumeration-failure") {
|
||||
// Restricted sandboxes can refuse os.networkInterfaces(); mDNS cannot
|
||||
// function without it, so surface a single warning and skip recovery.
|
||||
@@ -379,6 +402,7 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
return true;
|
||||
};
|
||||
cleanupDirectUnhandledRejection = installCiaoUnhandledRejectionListener(handleCiaoProcessError);
|
||||
cleanupUnhandledRejection = deps.registerUnhandledRejectionHandler?.(handleCiaoProcessError);
|
||||
cleanupUncaughtException = deps.registerUncaughtExceptionHandler?.(handleCiaoProcessError);
|
||||
|
||||
@@ -490,6 +514,28 @@ export async function startGatewayBonjourAdvertiser(
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdvertiseFailure(
|
||||
label: string,
|
||||
svc: BonjourService,
|
||||
err: unknown,
|
||||
action: "failed" | "threw",
|
||||
) {
|
||||
const classification = classifyCiaoProcessError(err);
|
||||
if (classification) {
|
||||
logger.warn(
|
||||
`bonjour: advertise ${action} with ciao ${classification.kind} (${serviceSummary(
|
||||
label,
|
||||
svc,
|
||||
)}): ${classification.formatted}`,
|
||||
);
|
||||
requestCiaoRecovery?.(classification);
|
||||
return;
|
||||
}
|
||||
logger.warn(
|
||||
`bonjour: advertise ${action} (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function startAdvertising(services: Array<{ label: string; svc: BonjourService }>) {
|
||||
for (const { label, svc } of services) {
|
||||
try {
|
||||
@@ -499,14 +545,10 @@ export async function startGatewayBonjourAdvertiser(
|
||||
logger.info(`bonjour: advertised ${serviceSummary(label, svc)}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn(
|
||||
`bonjour: advertise failed (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
|
||||
);
|
||||
handleAdvertiseFailure(label, svc, err, "failed");
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`bonjour: advertise threw (${serviceSummary(label, svc)}): ${formatBonjourError(err)}`,
|
||||
);
|
||||
handleAdvertiseFailure(label, svc, err, "threw");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,8 +565,6 @@ export async function startGatewayBonjourAdvertiser(
|
||||
let consecutiveRestarts = 0;
|
||||
let cycle: BonjourCycle | null = createCycle();
|
||||
const stateTracker = new Map<string, ServiceStateTracker>();
|
||||
attachConflictListeners(cycle.services);
|
||||
startAdvertising(cycle.services);
|
||||
|
||||
const updateStateTrackers = (services: Array<{ label: string; svc: BonjourService }>) => {
|
||||
const now = Date.now();
|
||||
@@ -578,6 +618,8 @@ export async function startGatewayBonjourAdvertiser(
|
||||
requestCiaoRecovery = (classification) => {
|
||||
void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`);
|
||||
};
|
||||
attachConflictListeners(cycle.services);
|
||||
startAdvertising(cycle.services);
|
||||
|
||||
const lastRepairAttempt = new Map<string, number>();
|
||||
const watchdog = setInterval(() => {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"playwright-core": "1.59.1",
|
||||
"typebox": "1.1.33",
|
||||
"undici": "8.1.0",
|
||||
"ws": "^8.20.0"
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import {
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
|
||||
let createPageViaPlaywright: typeof import("./pw-session.js").createPageViaPlaywright;
|
||||
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
|
||||
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
|
||||
|
||||
type BrowserMockBundle = {
|
||||
browser: import("playwright-core").Browser;
|
||||
@@ -121,9 +116,18 @@ function makeMutatingDisconnectBrowser(): BrowserMockBundle & {
|
||||
return { browser, browserClose, newPage };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
} = await import("./pw-session.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connectOverCdpSpy.mockReset();
|
||||
getChromeWebSocketUrlSpy.mockReset();
|
||||
connectOverCdpMock.mockReset();
|
||||
getChromeWebSocketUrlMock.mockReset();
|
||||
await closePlaywrightBrowserConnection().catch(() => {});
|
||||
});
|
||||
|
||||
@@ -133,7 +137,7 @@ describe("pw-session connection scoping", () => {
|
||||
const browserB = makeBrowser("B", "https://b.example");
|
||||
let resolveA: ((value: import("playwright-core").Browser) => void) | undefined;
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText === "http://127.0.0.1:9222") {
|
||||
return await new Promise<import("playwright-core").Browser>((resolve) => {
|
||||
@@ -145,21 +149,21 @@ describe("pw-session connection scoping", () => {
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
const pendingA = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
await Promise.resolve();
|
||||
const pendingB = listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
|
||||
expect(connectOverCdpMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://127.0.0.1:9222",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(connectOverCdpSpy).toHaveBeenNthCalledWith(
|
||||
expect(connectOverCdpMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://127.0.0.1:9333",
|
||||
expect.any(Object),
|
||||
@@ -175,7 +179,7 @@ describe("pw-session connection scoping", () => {
|
||||
const browserA = makeBrowser("A", "https://a.example");
|
||||
const browserB = makeBrowser("B", "https://b.example");
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText === "http://127.0.0.1:9222") {
|
||||
return browserA.browser;
|
||||
@@ -185,7 +189,7 @@ describe("pw-session connection scoping", () => {
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
@@ -202,7 +206,7 @@ describe("pw-session connection scoping", () => {
|
||||
const browserB = makeBrowser("B", "https://b.example");
|
||||
let callsForA = 0;
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText === "http://127.0.0.1:9222") {
|
||||
callsForA += 1;
|
||||
@@ -213,7 +217,7 @@ describe("pw-session connection scoping", () => {
|
||||
}
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
@@ -226,7 +230,7 @@ describe("pw-session connection scoping", () => {
|
||||
expect(staleA.browserClose).toHaveBeenCalledTimes(1);
|
||||
expect(refreshedA.browserClose).not.toHaveBeenCalled();
|
||||
expect(browserB.browserClose).not.toHaveBeenCalled();
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("reconnects listPagesViaPlaywright once after a cached transport disconnect", async () => {
|
||||
@@ -234,7 +238,7 @@ describe("pw-session connection scoping", () => {
|
||||
const refreshed = makeBrowser("A", "https://a.example/recovered");
|
||||
let connectCalls = 0;
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText !== "http://127.0.0.1:9222") {
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
@@ -242,12 +246,12 @@ describe("pw-session connection scoping", () => {
|
||||
connectCalls += 1;
|
||||
return connectCalls === 1 ? stale.browser : refreshed.browser;
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
const pages = await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
|
||||
expect(pages.map((page) => page.targetId)).toEqual(["A"]);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
|
||||
await vi.waitFor(() => expect(stale.browserClose).toHaveBeenCalledTimes(1));
|
||||
expect(refreshed.browserClose).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -257,7 +261,7 @@ describe("pw-session connection scoping", () => {
|
||||
const refreshed = makeBrowser("A", "https://a.example/recovered");
|
||||
let connectCalls = 0;
|
||||
|
||||
connectOverCdpSpy.mockImplementation((async (...args: unknown[]) => {
|
||||
connectOverCdpMock.mockImplementation((async (...args: unknown[]) => {
|
||||
const endpointText = String(args[0]);
|
||||
if (endpointText !== "http://127.0.0.1:9222") {
|
||||
throw new Error(`unexpected endpoint: ${endpointText}`);
|
||||
@@ -265,7 +269,7 @@ describe("pw-session connection scoping", () => {
|
||||
connectCalls += 1;
|
||||
return connectCalls === 1 ? stale.browser : refreshed.browser;
|
||||
}) as never);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
@@ -275,6 +279,6 @@ describe("pw-session connection scoping", () => {
|
||||
).rejects.toThrow(/browser has been closed/);
|
||||
|
||||
expect(stale.newPage).toHaveBeenCalledTimes(1);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(1);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import "../test-support/browser-security.mock.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { BrowserTabNotFoundError } from "./errors.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import * as navigationGuardModule from "./navigation-guard.js";
|
||||
import {
|
||||
BlockedBrowserTargetError,
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
gotoPageWithNavigationGuard,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
let BlockedBrowserTargetError: typeof import("./pw-session.js").BlockedBrowserTargetError;
|
||||
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
|
||||
let createPageViaPlaywright: typeof import("./pw-session.js").createPageViaPlaywright;
|
||||
let forceDisconnectPlaywrightForTarget: typeof import("./pw-session.js").forceDisconnectPlaywrightForTarget;
|
||||
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
|
||||
let gotoPageWithNavigationGuard: typeof import("./pw-session.js").gotoPageWithNavigationGuard;
|
||||
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
"ALL_PROXY",
|
||||
@@ -101,8 +96,8 @@ function installBrowserMocks() {
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
connectOverCdpSpy.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
connectOverCdpMock.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
const getBrowserDisconnectedHandler = () =>
|
||||
browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as
|
||||
@@ -186,10 +181,22 @@ beforeEach(() => {
|
||||
}
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
BlockedBrowserTargetError,
|
||||
closePlaywrightBrowserConnection,
|
||||
createPageViaPlaywright,
|
||||
forceDisconnectPlaywrightForTarget,
|
||||
getPageForTargetId,
|
||||
gotoPageWithNavigationGuard,
|
||||
listPagesViaPlaywright,
|
||||
} = await import("./pw-session.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
connectOverCdpSpy.mockClear();
|
||||
getChromeWebSocketUrlSpy.mockClear();
|
||||
connectOverCdpMock.mockClear();
|
||||
getChromeWebSocketUrlMock.mockClear();
|
||||
await closePlaywrightBrowserConnection().catch(() => {});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import {
|
||||
closePlaywrightBrowserConnection,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
} from "./pw-session.js";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
|
||||
|
||||
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
|
||||
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
|
||||
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
|
||||
let getPageForTargetId: typeof import("./pw-session.js").getPageForTargetId;
|
||||
let listPagesViaPlaywright: typeof import("./pw-session.js").listPagesViaPlaywright;
|
||||
|
||||
type MockPageSpec = {
|
||||
targetId?: string;
|
||||
@@ -61,9 +56,14 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
return { browser, browserClose, pages: pageObjects };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ closePlaywrightBrowserConnection, getPageForTargetId, listPagesViaPlaywright } =
|
||||
await import("./pw-session.js"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
connectOverCdpSpy.mockReset();
|
||||
getChromeWebSocketUrlSpy.mockReset();
|
||||
connectOverCdpMock.mockReset();
|
||||
getChromeWebSocketUrlMock.mockReset();
|
||||
await closePlaywrightBrowserConnection().catch(() => {});
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ function createExtensionFallbackBrowserHarness(options?: {
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
connectOverCdpSpy.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
connectOverCdpMock.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
return { browserClose, newCDPSession, pages };
|
||||
}
|
||||
|
||||
@@ -179,15 +179,15 @@ describe("pw-session getPageForTargetId", () => {
|
||||
const stale = makeBrowser([]);
|
||||
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
|
||||
|
||||
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
connectOverCdpMock.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
|
||||
const resolved = await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9222" });
|
||||
|
||||
expect(resolved).toBe(fresh.pages[0]);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -201,8 +201,8 @@ describe("pw-session getPageForTargetId", () => {
|
||||
{ targetId: "TARGET_B", url: "https://beta.example" },
|
||||
]);
|
||||
|
||||
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
connectOverCdpMock.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
|
||||
|
||||
@@ -212,7 +212,7 @@ describe("pw-session getPageForTargetId", () => {
|
||||
});
|
||||
|
||||
expect(resolved).toBe(fresh.pages[1]);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -220,27 +220,27 @@ describe("pw-session getPageForTargetId", () => {
|
||||
const stale = makeBrowser([]);
|
||||
const stillBroken = makeBrowser([]);
|
||||
|
||||
connectOverCdpSpy
|
||||
connectOverCdpMock
|
||||
.mockResolvedValueOnce(stale.browser)
|
||||
.mockResolvedValueOnce(stillBroken.browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
|
||||
|
||||
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9444" })).rejects.toThrow(
|
||||
"No pages available in the connected browser.",
|
||||
);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(2);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(2);
|
||||
expect(stale.browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
|
||||
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
connectOverCdpMock.mockRejectedValue(new Error("connectOverCDP exploded"));
|
||||
getChromeWebSocketUrlMock.mockResolvedValue(null);
|
||||
|
||||
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
|
||||
"connectOverCDP exploded",
|
||||
);
|
||||
expect(connectOverCdpSpy).toHaveBeenCalledTimes(3);
|
||||
expect(connectOverCdpMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +277,54 @@ describe("Google speech provider", () => {
|
||||
expect(result.audioBuffer.subarray(44)).toEqual(pcm);
|
||||
});
|
||||
|
||||
it("retries once when Gemini TTS fetch aborts", async () => {
|
||||
const pcm = Buffer.from([7, 0, 8, 0]);
|
||||
const abortError = new Error("This operation was aborted");
|
||||
abortError.name = "AbortError";
|
||||
const requestSequence = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(abortError)
|
||||
.mockResolvedValueOnce({
|
||||
response: googleTtsResponse(pcm),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
postJsonRequestMock.mockImplementation(requestSequence);
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
const result = await provider.synthesize({
|
||||
text: "Retry aborted fetch.",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
|
||||
expect(requestSequence).toHaveBeenCalledTimes(2);
|
||||
expect(result.audioBuffer.subarray(44)).toEqual(pcm);
|
||||
});
|
||||
|
||||
it("does not retry non-transient Gemini TTS request failures", async () => {
|
||||
const requestSequence = vi.fn().mockRejectedValueOnce(new Error("invalid request"));
|
||||
postJsonRequestMock.mockImplementation(requestSequence);
|
||||
const provider = buildGoogleSpeechProvider();
|
||||
|
||||
await expect(
|
||||
provider.synthesize({
|
||||
text: "Do not retry this.",
|
||||
cfg: {},
|
||||
providerConfig: {
|
||||
apiKey: "google-test-key",
|
||||
},
|
||||
target: "audio-file",
|
||||
timeoutMs: 5_000,
|
||||
}),
|
||||
).rejects.toThrow("invalid request");
|
||||
|
||||
expect(requestSequence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to GEMINI_API_KEY and configured Google API base URL", async () => {
|
||||
vi.stubEnv("GEMINI_API_KEY", "env-google-key");
|
||||
const requestMock = installGoogleTtsRequestMock();
|
||||
|
||||
@@ -107,6 +107,25 @@ class GoogleTtsRetryableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function isGoogleTtsRetryableError(err: unknown): boolean {
|
||||
if (err instanceof GoogleTtsRetryableError) {
|
||||
return true;
|
||||
}
|
||||
if (!(err instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
if (err.name === "AbortError") {
|
||||
return true;
|
||||
}
|
||||
const message = err.message.toLowerCase();
|
||||
return (
|
||||
message.includes("aborted") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("fetch failed") ||
|
||||
message.includes("network")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeGoogleTtsModel(model: unknown): string {
|
||||
const trimmed = normalizeOptionalString(model);
|
||||
if (!trimmed) {
|
||||
@@ -509,7 +528,7 @@ async function synthesizeGoogleTtsPcm(params: {
|
||||
return await synthesizeGoogleTtsPcmOnce(params);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (!(err instanceof GoogleTtsRetryableError) || attempt > 0) {
|
||||
if (!isGoogleTtsRetryableError(err) || attempt > 0) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -432,6 +432,18 @@ describe("QmdMemoryManager", () => {
|
||||
};
|
||||
const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update");
|
||||
expect(initialUpdateCalls).toHaveLength(0);
|
||||
const [, watchOptions] = watchMock.mock.calls[0] as unknown as [
|
||||
string[],
|
||||
{ ignored?: (watchPath: string) => boolean },
|
||||
];
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "node_modules", "pkg", "note.md"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, ".cache", "qmd", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "vendor", "pkg", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "dist", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "build", "note.md"))).toBe(true);
|
||||
expect(watchOptions.ignored?.(path.join(workspaceDir, "notes.md"))).toBe(false);
|
||||
|
||||
watcher.emit("change", path.join(workspaceDir, "notes.md"));
|
||||
expect(manager.status().dirty).toBe(true);
|
||||
|
||||
@@ -79,7 +79,11 @@ const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail");
|
||||
const QMD_UPDATE_QUEUE_KEY = Symbol.for("openclaw.qmdUpdateQueueState");
|
||||
const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
|
||||
".git",
|
||||
".cache",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"dist",
|
||||
"build",
|
||||
".pnpm-store",
|
||||
".venv",
|
||||
"venv",
|
||||
@@ -402,6 +406,7 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
|
||||
private async initialize(mode: QmdManagerMode): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
this.bootstrapCollections();
|
||||
if (mode === "status") {
|
||||
return;
|
||||
@@ -424,10 +429,16 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
|
||||
await this.ensureCollections();
|
||||
if (mode === "cli") {
|
||||
log.info(
|
||||
`qmd manager initialized for agent "${this.agentId}" mode=cli collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.ensureWatcher();
|
||||
log.info(
|
||||
`qmd manager initialized for agent "${this.agentId}" mode=full collections=${this.qmd.collections.length} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
|
||||
if (this.qmd.update.onBoot) {
|
||||
const bootRun = this.runUpdate("boot", true);
|
||||
@@ -1459,6 +1470,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
return;
|
||||
}
|
||||
const run = async () => {
|
||||
const startTime = Date.now();
|
||||
log.debug(
|
||||
`qmd sync started for agent "${this.agentId}" reason=${reason} force=${force === true}`,
|
||||
);
|
||||
await this.withQmdUpdateQueue(async () => {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -1492,6 +1507,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
}
|
||||
this.lastUpdateAt = Date.now();
|
||||
this.docPathCache.clear();
|
||||
log.info(
|
||||
`qmd sync completed for agent "${this.agentId}" reason=${reason} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
};
|
||||
this.pendingUpdate = run().finally(() => {
|
||||
this.pendingUpdate = null;
|
||||
@@ -1513,7 +1531,10 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
if (watchPaths.size === 0) {
|
||||
return;
|
||||
}
|
||||
this.watcher = chokidar.watch(Array.from(watchPaths), {
|
||||
const watchPathList = Array.from(watchPaths);
|
||||
const startTime = Date.now();
|
||||
log.info(`qmd watcher starting for agent "${this.agentId}" paths=${watchPathList.length}`);
|
||||
this.watcher = chokidar.watch(watchPathList, {
|
||||
ignoreInitial: true,
|
||||
ignored: (watchPath) => shouldIgnoreMemoryWatchPath(watchPath),
|
||||
awaitWriteFinish: {
|
||||
@@ -1528,6 +1549,11 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
this.watcher.on("add", markDirty);
|
||||
this.watcher.on("change", markDirty);
|
||||
this.watcher.on("unlink", markDirty);
|
||||
this.watcher.once("ready", () => {
|
||||
log.info(
|
||||
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private resolveCollectionWatchPath(collection: ManagedCollection): string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createKimiWebSearchProvider } from "./src/kimi-web-search-provider.js";
|
||||
const KIMI_SEARCH_KEY =
|
||||
process.env.KIMI_API_KEY?.trim() || process.env.MOONSHOT_API_KEY?.trim() || "";
|
||||
const describeLive = isLiveTestEnabled() && KIMI_SEARCH_KEY.length > 0 ? describe : describe.skip;
|
||||
const KIMI_LIVE_SEARCH_TIMEOUT_SECONDS = 60;
|
||||
|
||||
function isTransientKimiSearchError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
@@ -22,7 +23,11 @@ describeLive("moonshot plugin live", () => {
|
||||
const provider = createKimiWebSearchProvider();
|
||||
const tool = provider.createTool?.({
|
||||
config: {},
|
||||
searchConfig: { kimi: { apiKey: KIMI_SEARCH_KEY }, cacheTtlMinutes: 0, timeoutSeconds: 90 },
|
||||
searchConfig: {
|
||||
kimi: { apiKey: KIMI_SEARCH_KEY },
|
||||
cacheTtlMinutes: 0,
|
||||
timeoutSeconds: KIMI_LIVE_SEARCH_TIMEOUT_SECONDS,
|
||||
},
|
||||
} as never);
|
||||
|
||||
let result: { provider?: string; content?: unknown; citations?: unknown } | undefined;
|
||||
@@ -47,5 +52,5 @@ describeLive("moonshot plugin live", () => {
|
||||
expect(typeof result?.content).toBe("string");
|
||||
expect((result?.content as string).length).toBeGreaterThan(20);
|
||||
expect(Array.isArray(result?.citations)).toBe(true);
|
||||
}, 120_000);
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { runRealtimeSttLiveTest } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import {
|
||||
isOverloadedErrorMessage,
|
||||
isServerErrorMessage,
|
||||
isTimeoutErrorMessage,
|
||||
} from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
@@ -99,6 +104,21 @@ function createReferencePng(): Buffer {
|
||||
return encodePngRgba(buf, width, height);
|
||||
}
|
||||
|
||||
function formatLiveOpenAIError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function resolveLiveOpenAISkipReason(error: unknown): string | null {
|
||||
const message = formatLiveOpenAIError(error);
|
||||
if (isTimeoutErrorMessage(message) || /timed out|operation was aborted/i.test(message)) {
|
||||
return "provider timeout";
|
||||
}
|
||||
if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) {
|
||||
return "provider outage";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function createLiveConfig(): OpenClawConfig {
|
||||
const cfg = getRuntimeConfig();
|
||||
return {
|
||||
@@ -151,6 +171,10 @@ async function createTempAgentDir(): Promise<string> {
|
||||
return await fs.mkdtemp(path.join(os.tmpdir(), "openai-plugin-live-"));
|
||||
}
|
||||
|
||||
async function removeTempAgentDir(agentDir: string): Promise<void> {
|
||||
await fs.rm(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
function normalizeTranscriptForMatch(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
@@ -387,7 +411,7 @@ describeLive("openai plugin live", () => {
|
||||
expect(generated.images[0]?.mimeType).toBe("image/png");
|
||||
expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await removeTempAgentDir(agentDir);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
@@ -424,7 +448,7 @@ describeLive("openai plugin live", () => {
|
||||
expect(edited.images[0]?.mimeType).toBe("image/png");
|
||||
expect(edited.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await removeTempAgentDir(agentDir);
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
@@ -436,22 +460,36 @@ describeLive("openai plugin live", () => {
|
||||
const agentDir = await createTempAgentDir();
|
||||
|
||||
try {
|
||||
const description = await mediaProvider.describeImage?.({
|
||||
buffer: createReferencePng(),
|
||||
fileName: "reference.png",
|
||||
mime: "image/png",
|
||||
prompt: "Reply with one lowercase word for the dominant center color.",
|
||||
timeoutMs: 120_000,
|
||||
agentDir,
|
||||
cfg,
|
||||
authStore: EMPTY_AUTH_STORE,
|
||||
model: LIVE_VISION_MODEL,
|
||||
provider: "openai",
|
||||
});
|
||||
let description:
|
||||
| Awaited<ReturnType<NonNullable<typeof mediaProvider.describeImage>>>
|
||||
| undefined;
|
||||
try {
|
||||
description = await mediaProvider.describeImage?.({
|
||||
buffer: createReferencePng(),
|
||||
fileName: "reference.png",
|
||||
mime: "image/png",
|
||||
prompt: "Reply with one lowercase word for the dominant center color.",
|
||||
timeoutMs: 45_000,
|
||||
agentDir,
|
||||
cfg,
|
||||
authStore: EMPTY_AUTH_STORE,
|
||||
model: LIVE_VISION_MODEL,
|
||||
provider: "openai",
|
||||
});
|
||||
} catch (err) {
|
||||
const skipReason = resolveLiveOpenAISkipReason(err);
|
||||
if (skipReason) {
|
||||
console.warn(
|
||||
`[live:openai] image description skipped: ${skipReason}: ${formatLiveOpenAIError(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
expect((description?.text ?? "").toLowerCase()).toContain("orange");
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await removeTempAgentDir(agentDir);
|
||||
}
|
||||
}, 180_000);
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
9
extensions/qa-channel/setup-entry.test.ts
Normal file
9
extensions/qa-channel/setup-entry.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("qa-channel setup entry", () => {
|
||||
it("exposes the bundled setup-entry contract", () => {
|
||||
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
|
||||
expect(setupEntry.loadSetupPlugin().id).toBe("qa-channel");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
||||
import { qaChannelPlugin } from "./src/channel.js";
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineSetupPluginEntry(qaChannelPlugin);
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "qaChannelPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./api.js",
|
||||
exportName: "setQaChannelRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
@@ -56,6 +57,30 @@ function createMockQaRuntime(params?: {
|
||||
sessionUpdatedAt.set(sessionKey, Date.now());
|
||||
},
|
||||
},
|
||||
text: {
|
||||
hasControlCommand() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes() {
|
||||
return [/\b@?openclaw\b/i];
|
||||
},
|
||||
matchesMentionPatterns(text: string, regexes: RegExp[]) {
|
||||
return regexes.some((regex) => regex.test(text));
|
||||
},
|
||||
resolveInboundMentionDecision,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions() {
|
||||
return {};
|
||||
@@ -197,6 +222,78 @@ describe("qa-channel plugin", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("marks mentioned threaded group traffic before dispatch", { timeout: 20_000 }, async () => {
|
||||
let dispatchedCtx: Record<string, unknown> | null = null;
|
||||
const harness = await startQaChannelTestHarness({
|
||||
allowFrom: ["*"],
|
||||
runtime: createMockQaRuntime({
|
||||
onDispatch: (ctx) => {
|
||||
dispatchedCtx = ctx;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.addInboundMessage({
|
||||
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "@openclaw thread memory check",
|
||||
threadId: "thread-1",
|
||||
threadTitle: "Thread 1",
|
||||
});
|
||||
|
||||
const outbound = await harness.state.waitFor({
|
||||
kind: "message-text",
|
||||
textIncludes: "qa-echo: @openclaw thread memory check",
|
||||
direction: "outbound",
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
expect("threadId" in outbound && outbound.threadId).toBe("thread-1");
|
||||
expect(dispatchedCtx).toMatchObject({
|
||||
ChatType: "group",
|
||||
WasMentioned: true,
|
||||
MessageThreadId: "thread-1",
|
||||
});
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops unmentioned group traffic when mention is required", { timeout: 20_000 }, async () => {
|
||||
let didDispatch = false;
|
||||
const harness = await startQaChannelTestHarness({
|
||||
allowFrom: ["*"],
|
||||
runtime: createMockQaRuntime({
|
||||
onDispatch: () => {
|
||||
didDispatch = true;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
harness.state.addInboundMessage({
|
||||
conversation: { id: "qa-room", kind: "channel", title: "QA Room" },
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "thread memory check",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.state.waitFor({
|
||||
kind: "message-text",
|
||||
textIncludes: "qa-echo:",
|
||||
direction: "outbound",
|
||||
timeoutMs: 750,
|
||||
}),
|
||||
).rejects.toThrow(/wait timeout/i);
|
||||
expect(didDispatch).toBe(false);
|
||||
} finally {
|
||||
await harness.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("stages inbound image attachments into agent media payload", { timeout: 20_000 }, async () => {
|
||||
let dispatchedCtx: Record<string, unknown> | null = null;
|
||||
const harness = await startQaChannelTestHarness({
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isHttpMediaUrl } from "./inbound.js";
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { setQaChannelRuntime } from "../api.js";
|
||||
import { handleQaInbound, isHttpMediaUrl } from "./inbound.js";
|
||||
|
||||
const dispatchInboundReplyWithBaseMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/inbound-reply-dispatch", () => ({
|
||||
dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
dispatchInboundReplyWithBaseMock.mockReset();
|
||||
});
|
||||
|
||||
describe("isHttpMediaUrl", () => {
|
||||
it("accepts only http and https urls", () => {
|
||||
@@ -10,3 +22,45 @@ describe("isHttpMediaUrl", () => {
|
||||
expect(isHttpMediaUrl("data:text/plain;base64,SGVsbG8=")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleQaInbound", () => {
|
||||
it("marks group messages that match configured mention patterns", async () => {
|
||||
const runtime = createPluginRuntimeMock();
|
||||
vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]);
|
||||
setQaChannelRuntime(runtime);
|
||||
|
||||
await handleQaInbound({
|
||||
channelId: "qa-channel",
|
||||
channelLabel: "QA Channel",
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
baseUrl: "http://127.0.0.1:43123",
|
||||
botUserId: "openclaw",
|
||||
botDisplayName: "OpenClaw QA",
|
||||
pollTimeoutMs: 250,
|
||||
config: {},
|
||||
},
|
||||
config: {},
|
||||
message: {
|
||||
id: "msg-1",
|
||||
accountId: "default",
|
||||
direction: "inbound",
|
||||
conversation: {
|
||||
kind: "channel",
|
||||
id: "qa-room",
|
||||
title: "QA Room",
|
||||
},
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
text: "@openclaw ping",
|
||||
timestamp: 1_777_000_000_000,
|
||||
reactions: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(dispatchInboundReplyWithBaseMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchInboundReplyWithBaseMock.mock.calls[0]?.[0].ctxPayload.WasMentioned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,48 @@ export async function handleQaInbound(params: {
|
||||
id: target,
|
||||
},
|
||||
});
|
||||
const isGroup = inbound.conversation.kind !== "direct";
|
||||
const mentionRegexes = isGroup
|
||||
? runtime.channel.mentions.buildMentionRegexes(params.config as OpenClawConfig, route.agentId)
|
||||
: [];
|
||||
const wasMentioned =
|
||||
isGroup && mentionRegexes.length > 0
|
||||
? runtime.channel.mentions.matchesMentionPatterns(inbound.text, mentionRegexes)
|
||||
: false;
|
||||
const allowTextCommands = runtime.channel.commands.shouldHandleTextCommands({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
surface: params.channelId,
|
||||
});
|
||||
const hasControlCommand = runtime.channel.text.hasControlCommand(
|
||||
inbound.text,
|
||||
params.config as OpenClawConfig,
|
||||
);
|
||||
const commandAuthorized = true;
|
||||
const requireMention = isGroup
|
||||
? runtime.channel.groups.resolveRequireMention({
|
||||
cfg: params.config as OpenClawConfig,
|
||||
channel: params.channelId,
|
||||
groupId: inbound.conversation.id,
|
||||
accountId: params.account.accountId,
|
||||
})
|
||||
: false;
|
||||
const mentionDecision = runtime.channel.mentions.resolveInboundMentionDecision({
|
||||
facts: {
|
||||
canDetectMention: mentionRegexes.length > 0,
|
||||
wasMentioned,
|
||||
hasAnyMention: wasMentioned,
|
||||
},
|
||||
policy: {
|
||||
isGroup,
|
||||
requireMention,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
commandAuthorized,
|
||||
},
|
||||
});
|
||||
if (isGroup && mentionDecision.shouldSkip) {
|
||||
return;
|
||||
}
|
||||
const storePath = runtime.channel.session.resolveStorePath(params.config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
@@ -110,7 +152,7 @@ export async function handleQaInbound(params: {
|
||||
To: target,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId ?? params.account.accountId,
|
||||
ChatType: inbound.conversation.kind === "direct" ? "direct" : "group",
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel:
|
||||
inbound.threadTitle ||
|
||||
inbound.conversation.title ||
|
||||
@@ -135,7 +177,8 @@ export async function handleQaInbound(params: {
|
||||
Timestamp: inbound.timestamp,
|
||||
OriginatingChannel: params.channelId,
|
||||
OriginatingTo: target,
|
||||
CommandAuthorized: true,
|
||||
WasMentioned: isGroup ? mentionDecision.effectiveWasMentioned : undefined,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
...mediaPayload,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
type SessionRecord = {
|
||||
@@ -48,6 +49,30 @@ export function createQaRunnerRuntime(): PluginRuntime {
|
||||
});
|
||||
},
|
||||
},
|
||||
text: {
|
||||
hasControlCommand() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes() {
|
||||
return [/\b@?openclaw\b/i];
|
||||
},
|
||||
matchesMentionPatterns(text: string, regexes: RegExp[]) {
|
||||
return regexes.some((regex) => regex.test(text));
|
||||
},
|
||||
resolveInboundMentionDecision,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions() {
|
||||
return {};
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("discord live qa runtime", () => {
|
||||
|
||||
expect(next.plugins?.allow).toContain("discord");
|
||||
expect(next.plugins?.entries?.discord).toEqual({ enabled: true });
|
||||
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
expect(next.channels?.discord).toEqual({
|
||||
enabled: true,
|
||||
defaultAccount: "sut",
|
||||
|
||||
@@ -287,6 +287,13 @@ function buildDiscordQaConfig(
|
||||
allow: pluginAllow,
|
||||
entries: pluginEntries,
|
||||
},
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
groupChat: {
|
||||
...baseCfg.messages?.groupChat,
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
discord: {
|
||||
|
||||
@@ -94,6 +94,62 @@ describe("startQaLiveLaneGateway", () => {
|
||||
expect(mockStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables memory search for transport-only live lanes", async () => {
|
||||
await startQaLiveLaneGateway({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
transport: createStubTransport(),
|
||||
transportBaseUrl: "http://127.0.0.1:43123",
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: "mock-openai/gpt-5.5",
|
||||
alternateModel: "mock-openai/gpt-5.5-alt",
|
||||
controlUiEnabled: false,
|
||||
});
|
||||
|
||||
const [{ mutateConfig }] = startQaGatewayChild.mock.calls[0] ?? [];
|
||||
expect(typeof mutateConfig).toBe("function");
|
||||
const cfg = mutateConfig?.({
|
||||
plugins: {
|
||||
allow: ["acpx", "memory-core", "qa-channel"],
|
||||
entries: {
|
||||
acpx: { enabled: true },
|
||||
"memory-core": { enabled: true },
|
||||
"qa-channel": { enabled: true },
|
||||
},
|
||||
slots: {
|
||||
memory: "memory-core",
|
||||
contextEngine: "qmd",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
sync: {
|
||||
onSearch: true,
|
||||
onSessionStart: true,
|
||||
watch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(cfg?.plugins?.allow).toEqual(["acpx", "qa-channel"]);
|
||||
expect(cfg?.plugins?.entries).not.toHaveProperty("memory-core");
|
||||
expect(cfg?.plugins?.slots).toMatchObject({
|
||||
memory: "none",
|
||||
contextEngine: "qmd",
|
||||
});
|
||||
expect(cfg?.agents?.defaults?.memorySearch).toMatchObject({
|
||||
enabled: false,
|
||||
sync: {
|
||||
onSearch: false,
|
||||
onSessionStart: false,
|
||||
watch: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards gateway stop options to the child harness", async () => {
|
||||
const harness = await startQaLiveLaneGateway({
|
||||
repoRoot: "/tmp/openclaw-repo",
|
||||
|
||||
@@ -34,6 +34,52 @@ async function stopQaLiveLaneResources(
|
||||
}
|
||||
}
|
||||
|
||||
function omitMemoryCoreEntry<T extends Record<string, unknown> | undefined>(entries: T): T {
|
||||
if (!entries || !Object.prototype.hasOwnProperty.call(entries, "memory-core")) {
|
||||
return entries;
|
||||
}
|
||||
const { "memory-core": _memoryCore, ...rest } = entries;
|
||||
return rest as T;
|
||||
}
|
||||
|
||||
function prepareLiveTransportGatewayConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const defaults = cfg.agents?.defaults ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
plugins: cfg.plugins
|
||||
? {
|
||||
...cfg.plugins,
|
||||
allow: cfg.plugins.allow?.filter((pluginId) => pluginId !== "memory-core"),
|
||||
entries: omitMemoryCoreEntry(cfg.plugins.entries),
|
||||
slots: {
|
||||
...cfg.plugins.slots,
|
||||
memory: "none",
|
||||
},
|
||||
}
|
||||
: {
|
||||
slots: {
|
||||
memory: "none",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...defaults,
|
||||
memorySearch: {
|
||||
...defaults.memorySearch,
|
||||
enabled: false,
|
||||
sync: {
|
||||
...defaults.memorySearch?.sync,
|
||||
onSearch: false,
|
||||
onSessionStart: false,
|
||||
watch: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startQaLiveLaneGateway(params: {
|
||||
repoRoot: string;
|
||||
command?: QaGatewayChildCommand;
|
||||
@@ -70,7 +116,8 @@ export async function startQaLiveLaneGateway(params: {
|
||||
thinkingDefault: params.thinkingDefault,
|
||||
claudeCliAuthMode: params.claudeCliAuthMode,
|
||||
controlUiEnabled: params.controlUiEnabled,
|
||||
mutateConfig: params.mutateConfig,
|
||||
mutateConfig: (cfg) =>
|
||||
prepareLiveTransportGatewayConfig(params.mutateConfig ? params.mutateConfig(cfg) : cfg),
|
||||
});
|
||||
return {
|
||||
gateway,
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
|
||||
describe("telegram live qa runtime", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
@@ -100,6 +101,46 @@ describe("telegram live qa runtime", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes the Telegram canary timeout env", () => {
|
||||
expect(
|
||||
__testing.resolveTelegramQaCanaryTimeoutMs({
|
||||
OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "12345",
|
||||
}),
|
||||
).toBe(12345);
|
||||
expect(
|
||||
__testing.resolveTelegramQaCanaryTimeoutMs({
|
||||
OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "nope",
|
||||
}),
|
||||
).toBe(60_000);
|
||||
});
|
||||
|
||||
it("waits for Telegram polling connectivity before treating the account as ready", async () => {
|
||||
vi.useFakeTimers();
|
||||
const gateway = {
|
||||
call: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [{ accountId: "sut", connected: false, running: true }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
channelAccounts: {
|
||||
telegram: [{ accountId: "sut", connected: true, running: true }],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const ready = __testing.waitForTelegramChannelRunning(gateway as never, "sut", {
|
||||
pollIntervalMs: 10,
|
||||
timeoutMs: 1_000,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await expect(ready).resolves.toBeUndefined();
|
||||
expect(gateway.call).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("sanitizes and truncates Telegram live progress details", () => {
|
||||
expect(__testing.sanitizeTelegramQaProgressValue("scenario\nid\tvalue")).toBe(
|
||||
"scenario id value",
|
||||
@@ -165,6 +206,7 @@ describe("telegram live qa runtime", () => {
|
||||
expect(next.agents?.defaults?.skipBootstrap).toBe(true);
|
||||
expect(next.plugins?.allow).toContain("telegram");
|
||||
expect(next.plugins?.entries?.telegram).toEqual({ enabled: true });
|
||||
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
expect(next.channels?.telegram).toEqual({
|
||||
enabled: true,
|
||||
defaultAccount: "sut",
|
||||
|
||||
@@ -302,6 +302,7 @@ const TELEGRAM_QA_ENV_KEYS = [
|
||||
"OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN",
|
||||
"OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN",
|
||||
] as const;
|
||||
const DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS = 60_000;
|
||||
const TELEGRAM_QA_CAPTURE_CONTENT_ENV = "OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT";
|
||||
const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA";
|
||||
const QA_SUITE_PROGRESS_ENV = "OPENCLAW_QA_SUITE_PROGRESS";
|
||||
@@ -342,6 +343,26 @@ function parseTelegramQaProgressBooleanEnv(value: string | undefined): boolean |
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parsePositiveTelegramQaEnvMs(env: NodeJS.ProcessEnv, name: string, fallback: number) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function resolveTelegramQaCanaryTimeoutMs(env: NodeJS.ProcessEnv = process.env) {
|
||||
return parsePositiveTelegramQaEnvMs(
|
||||
env,
|
||||
"OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS",
|
||||
DEFAULT_TELEGRAM_QA_CANARY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldLogTelegramQaLiveProgress(env: NodeJS.ProcessEnv = process.env) {
|
||||
const override = parseTelegramQaProgressBooleanEnv(env[QA_SUITE_PROGRESS_ENV]);
|
||||
if (override !== undefined) {
|
||||
@@ -486,6 +507,13 @@ function buildTelegramQaConfig(
|
||||
allow: pluginAllow,
|
||||
entries: pluginEntries,
|
||||
},
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
groupChat: {
|
||||
...baseCfg.messages?.groupChat,
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
telegram: {
|
||||
@@ -633,9 +661,15 @@ async function waitForObservedMessage(params: {
|
||||
async function waitForTelegramChannelRunning(
|
||||
gateway: Awaited<ReturnType<typeof startQaGatewayChild>>,
|
||||
accountId: string,
|
||||
opts: {
|
||||
pollIntervalMs?: number;
|
||||
timeoutMs?: number;
|
||||
} = {},
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < 45_000) {
|
||||
const timeoutMs = opts.timeoutMs ?? 90_000;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 500;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const payload = (await gateway.call(
|
||||
"channels.status",
|
||||
@@ -644,20 +678,25 @@ async function waitForTelegramChannelRunning(
|
||||
)) as {
|
||||
channelAccounts?: Record<
|
||||
string,
|
||||
Array<{ accountId?: string; running?: boolean; restartPending?: boolean }>
|
||||
Array<{
|
||||
accountId?: string;
|
||||
connected?: boolean;
|
||||
running?: boolean;
|
||||
restartPending?: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
const accounts = payload.channelAccounts?.telegram ?? [];
|
||||
const match = accounts.find((entry) => entry.accountId === accountId);
|
||||
if (match?.running && match.restartPending !== true) {
|
||||
if (match?.running && match.connected === true && match.restartPending !== true) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
throw new Error(`telegram account "${accountId}" did not become ready`);
|
||||
throw new Error(`telegram account "${accountId}" did not become ready and connected`);
|
||||
}
|
||||
|
||||
function renderTelegramQaMarkdown(params: {
|
||||
@@ -831,6 +870,7 @@ async function runCanary(params: {
|
||||
params.groupId,
|
||||
`/help@${params.sutUsername}`,
|
||||
);
|
||||
const canaryTimeoutMs = resolveTelegramQaCanaryTimeoutMs();
|
||||
const requestStartedAt = new Date(requestStartedAtMs).toISOString();
|
||||
let firstUnthreadedReply:
|
||||
| Pick<TelegramObservedMessage, "messageId" | "replyToMessageId" | "text">
|
||||
@@ -840,7 +880,7 @@ async function runCanary(params: {
|
||||
sutObserved = await waitForObservedMessage({
|
||||
token: params.driverToken,
|
||||
initialOffset: offset,
|
||||
timeoutMs: 30_000,
|
||||
timeoutMs: canaryTimeoutMs,
|
||||
observedMessages: params.observedMessages,
|
||||
observationScenarioId: "telegram-canary",
|
||||
observationScenarioTitle: "Telegram canary",
|
||||
@@ -881,7 +921,7 @@ async function runCanary(params: {
|
||||
}
|
||||
throw new TelegramQaCanaryError(
|
||||
"sut_reply_timeout",
|
||||
"SUT bot did not send any group reply after the canary command within 30s.",
|
||||
`SUT bot did not send any group reply after the canary command within ${Math.round(canaryTimeoutMs / 1000)}s.`,
|
||||
{
|
||||
groupId: params.groupId,
|
||||
sutBotId: params.sutBotId,
|
||||
@@ -1439,10 +1479,12 @@ export const __testing = {
|
||||
matchesTelegramScenarioReply,
|
||||
normalizeTelegramObservedMessage,
|
||||
parseTelegramQaProgressBooleanEnv,
|
||||
resolveTelegramQaCanaryTimeoutMs,
|
||||
parseTelegramQaCredentialPayload,
|
||||
resolveTelegramQaRuntimeEnv,
|
||||
sanitizeTelegramQaProgressValue,
|
||||
shouldLogTelegramQaLiveProgress,
|
||||
waitForTelegramChannelRunning,
|
||||
formatTelegramQaProgressDetails,
|
||||
renderTelegramQaMarkdown,
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("qa channel transport", () => {
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\b@?openclaw\\b"],
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ export function createQaChannelGatewayConfig(params: {
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\b@?openclaw\\b"],
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ function createQaChannelTransportParams(baseUrl = "http://127.0.0.1:43124") {
|
||||
messages: {
|
||||
groupChat: {
|
||||
mentionPatterns: ["\\b@?openclaw\\b"],
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
} satisfies QaTransportGatewayConfig,
|
||||
@@ -77,7 +78,10 @@ describe("buildQaGatewayConfig", () => {
|
||||
baseUrl: "http://127.0.0.1:43124",
|
||||
pollTimeoutMs: 250,
|
||||
});
|
||||
expect(cfg.messages?.groupChat?.mentionPatterns).toEqual(["\\b@?openclaw\\b"]);
|
||||
expect(cfg.messages?.groupChat).toMatchObject({
|
||||
mentionPatterns: ["\\b@?openclaw\\b"],
|
||||
visibleReplies: "automatic",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps provider-qualified openai and anthropic refs through the mock provider lane", () => {
|
||||
|
||||
@@ -106,6 +106,22 @@ describe("matrix live qa runtime", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes the Matrix QA canary timeout env", () => {
|
||||
const previous = process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
|
||||
try {
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "12345";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(12345);
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = "nope";
|
||||
expect(liveTesting.resolveMatrixQaCanaryTimeoutMs()).toBe(90_000);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("injects a temporary Matrix account into the QA gateway config", () => {
|
||||
const baseCfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
@@ -148,6 +164,7 @@ describe("matrix live qa runtime", () => {
|
||||
|
||||
expect(next.plugins?.allow).toContain("matrix");
|
||||
expect(next.plugins?.entries?.matrix).toEqual({ enabled: true });
|
||||
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
expect(next.channels?.matrix).toEqual({
|
||||
enabled: true,
|
||||
defaultAccount: "sut",
|
||||
|
||||
@@ -50,6 +50,7 @@ type MatrixQaGatewayChild = {
|
||||
};
|
||||
|
||||
const DEFAULT_MATRIX_QA_RUN_TIMEOUT_MS = 30 * 60_000;
|
||||
const DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS = 90_000;
|
||||
const DEFAULT_MATRIX_QA_CLEANUP_TIMEOUT_MS = 90_000;
|
||||
|
||||
type MatrixQaLiveLaneGatewayHarness = {
|
||||
@@ -192,6 +193,13 @@ function createMatrixQaRunDeadline() {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatrixQaCanaryTimeoutMs() {
|
||||
return parsePositiveMatrixQaEnvMs(
|
||||
"OPENCLAW_QA_MATRIX_CANARY_TIMEOUT_MS",
|
||||
DEFAULT_MATRIX_QA_CANARY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function remainingMatrixQaRunMs(deadline: { deadlineMs: number }) {
|
||||
return Math.max(1, deadline.deadlineMs - Date.now());
|
||||
}
|
||||
@@ -720,7 +728,7 @@ export async function runMatrixQaLive(params: {
|
||||
syncState,
|
||||
syncStreams,
|
||||
sutUserId: provisioning.sut.userId,
|
||||
timeoutMs: 45_000,
|
||||
timeoutMs: resolveMatrixQaCanaryTimeoutMs(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -935,9 +943,8 @@ export async function runMatrixQaLive(params: {
|
||||
} finally {
|
||||
if (gatewayHarness) {
|
||||
try {
|
||||
const shouldPreserveGatewayDebugArtifacts = scenarioResults.some(
|
||||
(scenario) => scenario?.status === "fail",
|
||||
);
|
||||
const shouldPreserveGatewayDebugArtifacts =
|
||||
scenarioResults.some((scenario) => scenario?.status === "fail") || canaryFailed;
|
||||
preservedGatewayDebugDirPath = shouldPreserveGatewayDebugArtifacts
|
||||
? path.join(outputDir, "gateway-debug")
|
||||
: undefined;
|
||||
@@ -1128,6 +1135,7 @@ export const __testing = {
|
||||
findMatrixQaScenarios,
|
||||
isMatrixAccountReady,
|
||||
patchMatrixQaGatewayConfig,
|
||||
resolveMatrixQaCanaryTimeoutMs,
|
||||
resolveMatrixQaModels,
|
||||
shouldWriteMatrixQaProgress,
|
||||
summarizeMatrixQaConfigSnapshot,
|
||||
|
||||
@@ -76,6 +76,7 @@ describe("matrix qa config", () => {
|
||||
replyToMode: "off",
|
||||
threadReplies: "inbound",
|
||||
});
|
||||
expect(next.messages?.groupChat?.visibleReplies).toBe("automatic");
|
||||
});
|
||||
|
||||
it("applies room-keyed Matrix QA config overrides", () => {
|
||||
|
||||
@@ -640,6 +640,13 @@ export function buildMatrixQaConfig(
|
||||
matrix: { enabled: true },
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
...baseCfg.messages,
|
||||
groupChat: {
|
||||
...baseCfg.messages?.groupChat,
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
...baseCfg.channels,
|
||||
matrix: {
|
||||
|
||||
@@ -12,8 +12,15 @@ describe("resolveTelegramRequestTimeoutMs", () => {
|
||||
expect(resolveTelegramRequestTimeoutMs("getupdates")).toBe(45_000);
|
||||
});
|
||||
|
||||
it("bounds outbound delivery methods", () => {
|
||||
expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBe(20_000);
|
||||
expect(resolveTelegramRequestTimeoutMs("sendchataction")).toBe(10_000);
|
||||
expect(resolveTelegramRequestTimeoutMs("editmessagetext")).toBe(15_000);
|
||||
expect(resolveTelegramRequestTimeoutMs("sendphoto")).toBe(30_000);
|
||||
});
|
||||
|
||||
it("does not assign hard timeouts to unrelated Telegram methods", () => {
|
||||
expect(resolveTelegramRequestTimeoutMs("sendmessage")).toBeUndefined();
|
||||
expect(resolveTelegramRequestTimeoutMs("answercallbackquery")).toBeUndefined();
|
||||
expect(resolveTelegramRequestTimeoutMs(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,24 @@ const TELEGRAM_REQUEST_TIMEOUTS_MS = {
|
||||
// Bound startup/control-plane calls so the gateway cannot report Telegram as
|
||||
// healthy while provider startup is still hung on Bot API setup.
|
||||
deletewebhook: 15_000,
|
||||
deletemessage: 15_000,
|
||||
editforumtopic: 15_000,
|
||||
editmessagetext: 15_000,
|
||||
getchat: 15_000,
|
||||
getfile: 30_000,
|
||||
getme: 15_000,
|
||||
getupdates: 45_000,
|
||||
pinchatmessage: 15_000,
|
||||
sendanimation: 30_000,
|
||||
sendaudio: 30_000,
|
||||
sendchataction: 10_000,
|
||||
senddocument: 30_000,
|
||||
sendmessage: 20_000,
|
||||
sendmessagedraft: 20_000,
|
||||
sendphoto: 30_000,
|
||||
sendvideo: 30_000,
|
||||
sendvoice: 30_000,
|
||||
setmessagereaction: 10_000,
|
||||
setwebhook: 15_000,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { runRealtimeSttLiveTest } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import {
|
||||
expectOpenClawLiveTranscriptMarker,
|
||||
runRealtimeSttLiveTest,
|
||||
} from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
@@ -68,10 +71,6 @@ const registerXaiPlugin = () =>
|
||||
name: "xAI Provider",
|
||||
});
|
||||
|
||||
function normalizeTranscriptForMatch(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
describeLive("xai plugin live", () => {
|
||||
it("synthesizes TTS through the registered speech provider", async () => {
|
||||
const { speechProviders } = await registerXaiPlugin();
|
||||
@@ -146,9 +145,8 @@ describeLive("xai plugin live", () => {
|
||||
});
|
||||
|
||||
const normalized = transcript?.text.toLowerCase() ?? "";
|
||||
const compact = normalizeTranscriptForMatch(normalized);
|
||||
expect(transcript?.model).toBe(XAI_DEFAULT_STT_MODEL);
|
||||
expect(compact).toContain("openclaw");
|
||||
expectOpenClawLiveTranscriptMarker(normalized);
|
||||
expect(normalized).toContain("speech");
|
||||
expect(normalized).toContain("text");
|
||||
expect(normalized).toContain("integration");
|
||||
@@ -222,8 +220,7 @@ describeLive("xai plugin live", () => {
|
||||
});
|
||||
|
||||
const normalized = transcripts.join(" ").toLowerCase();
|
||||
const compact = normalizeTranscriptForMatch(normalized);
|
||||
expect(compact).toContain("openclaw");
|
||||
expectOpenClawLiveTranscriptMarker(normalized);
|
||||
expect(normalized).toContain("transcription");
|
||||
expect(partials.length + transcripts.length).toBeGreaterThan(0);
|
||||
}, 180_000);
|
||||
|
||||
@@ -559,6 +559,7 @@ describe("handleZaloWebhookRequest", () => {
|
||||
...DEFAULT_ACCOUNT,
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,13 @@ import { expect, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "../types.js";
|
||||
|
||||
function resolveLifecycleAllowFrom(params: {
|
||||
dmPolicy: "open" | "pairing";
|
||||
allowFrom?: string[];
|
||||
}): string[] | undefined {
|
||||
return params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
|
||||
}
|
||||
|
||||
export function createLifecycleConfig(params: {
|
||||
accountId: string;
|
||||
dmPolicy: "open" | "pairing";
|
||||
@@ -12,6 +19,7 @@ export function createLifecycleConfig(params: {
|
||||
}): OpenClawConfig {
|
||||
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
|
||||
const webhookSecret = params.webhookSecret ?? "supersecret";
|
||||
const allowFrom = resolveLifecycleAllowFrom(params);
|
||||
return {
|
||||
channels: {
|
||||
zalo: {
|
||||
@@ -22,7 +30,7 @@ export function createLifecycleConfig(params: {
|
||||
webhookUrl,
|
||||
webhookSecret, // pragma: allowlist secret
|
||||
dmPolicy: params.dmPolicy,
|
||||
...(params.allowFrom ? { allowFrom: params.allowFrom } : {}),
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,6 +47,7 @@ export function createLifecycleAccount(params: {
|
||||
}): ResolvedZaloAccount {
|
||||
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
|
||||
const webhookSecret = params.webhookSecret ?? "supersecret";
|
||||
const allowFrom = resolveLifecycleAllowFrom(params);
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled: true,
|
||||
@@ -48,7 +57,7 @@ export function createLifecycleAccount(params: {
|
||||
webhookUrl,
|
||||
webhookSecret, // pragma: allowlist secret
|
||||
dmPolicy: params.dmPolicy,
|
||||
...(params.allowFrom ? { allowFrom: params.allowFrom } : {}),
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
} as ResolvedZaloAccount;
|
||||
}
|
||||
|
||||
77
openclaw.mjs
77
openclaw.mjs
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { access } from "node:fs/promises";
|
||||
import module from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MIN_NODE_MAJOR = 22;
|
||||
@@ -46,6 +48,41 @@ const isSourceCheckoutLauncher = () =>
|
||||
const isNodeCompileCacheDisabled = () => process.env.NODE_DISABLE_COMPILE_CACHE !== undefined;
|
||||
const isNodeCompileCacheRequested = () =>
|
||||
process.env.NODE_COMPILE_CACHE !== undefined && !isNodeCompileCacheDisabled();
|
||||
const sanitizeCompileCachePathSegment = (value) => {
|
||||
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return normalized.length > 0 ? normalized : "unknown";
|
||||
};
|
||||
const readPackageVersion = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
|
||||
if (typeof parsed?.version === "string" && parsed.version.trim().length > 0) {
|
||||
return parsed.version;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to an install-metadata-only cache key.
|
||||
}
|
||||
return "unknown";
|
||||
};
|
||||
const resolvePackagedCompileCacheDirectory = () => {
|
||||
const packageJsonUrl = new URL("./package.json", import.meta.url);
|
||||
const version = sanitizeCompileCachePathSegment(readPackageVersion());
|
||||
let installMarker = "no-package-json";
|
||||
try {
|
||||
const stat = statSync(packageJsonUrl);
|
||||
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
|
||||
} catch {
|
||||
// Package archives should always have package.json, but keep startup best-effort.
|
||||
}
|
||||
const baseDirectory = isNodeCompileCacheRequested()
|
||||
? process.env.NODE_COMPILE_CACHE
|
||||
: path.join(os.tmpdir(), "node-compile-cache");
|
||||
return path.join(
|
||||
baseDirectory,
|
||||
"openclaw",
|
||||
version,
|
||||
sanitizeCompileCachePathSegment(installMarker),
|
||||
);
|
||||
};
|
||||
|
||||
const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
if (!isSourceCheckoutLauncher()) {
|
||||
@@ -79,10 +116,46 @@ const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
|
||||
respawnWithoutCompileCacheIfNeeded();
|
||||
|
||||
const respawnWithPackagedCompileCacheIfNeeded = () => {
|
||||
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
|
||||
return false;
|
||||
}
|
||||
const currentDirectory = module.getCompileCacheDir?.();
|
||||
if (!currentDirectory) {
|
||||
return false;
|
||||
}
|
||||
const desiredDirectory = resolvePackagedCompileCacheDirectory();
|
||||
if (path.resolve(currentDirectory) === path.resolve(desiredDirectory)) {
|
||||
return false;
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_COMPILE_CACHE: desiredDirectory,
|
||||
OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1",
|
||||
};
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env,
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
process.exit(result.status ?? 1);
|
||||
};
|
||||
|
||||
respawnWithPackagedCompileCacheIfNeeded();
|
||||
|
||||
// https://nodejs.org/api/module.html#module-compile-cache
|
||||
if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) {
|
||||
try {
|
||||
module.enableCompileCache();
|
||||
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"skills/",
|
||||
"scripts/npm-runner.mjs",
|
||||
"scripts/preinstall-package-manager-warning.mjs",
|
||||
"scripts/lib/package-dist-imports.mjs",
|
||||
"scripts/postinstall-bundled-plugins.mjs",
|
||||
"scripts/windows-cmd-helpers.mjs"
|
||||
],
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -362,6 +362,9 @@ importers:
|
||||
ws:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
|
||||
@@ -4,13 +4,26 @@
|
||||
// prebuilt package artifact with dist inventory, not a source checkout.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { LOCAL_BUILD_METADATA_DIST_PATHS } from "./lib/local-build-metadata-paths.mjs";
|
||||
import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs";
|
||||
|
||||
let extractDir = "";
|
||||
|
||||
function usage() {
|
||||
return "Usage: node scripts/check-openclaw-package-tarball.mjs <openclaw.tgz>";
|
||||
}
|
||||
|
||||
function cleanupExtractDir() {
|
||||
if (extractDir) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
extractDir = "";
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
cleanupExtractDir();
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -31,6 +44,20 @@ if (list.status !== 0) {
|
||||
fail(`tar -tf failed for ${tarball}: ${list.stderr || list.status}`);
|
||||
}
|
||||
|
||||
extractDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-tarball-"));
|
||||
try {
|
||||
const extract = spawnSync("tar", ["-xf", tarball, "-C", extractDir], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (extract.status !== 0) {
|
||||
fail(`tar -xf failed for ${tarball}: ${extract.stderr || extract.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
cleanupExtractDir();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const entries = list.stdout
|
||||
.split(/\r?\n/u)
|
||||
.map((entry) => entry.trim())
|
||||
@@ -39,9 +66,22 @@ const normalized = entries.map((entry) => entry.replace(/^package\//u, ""));
|
||||
const entrySet = new Set(normalized);
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const unsafeEntries = normalized.filter(
|
||||
(entry) => entry.startsWith("/") || entry.split("/").includes(".."),
|
||||
);
|
||||
const DIST_JS_IMPORT_SPECIFIER_PATTERN =
|
||||
/\b(?:import|export)\s+(?:(?:[^'"()]*?\s+from\s+)|)["'](?<staticSpecifier>[^"']+)["']|\bimport\s*\(\s*["'](?<dynamicSpecifier>[^"']+)["']\s*\)/gu;
|
||||
const DIST_IMPORT_REFERENCE_ENTRYPOINTS = [
|
||||
"dist/entry.js",
|
||||
"dist/cli/run-main.js",
|
||||
"dist/index.js",
|
||||
"dist/index.mjs",
|
||||
];
|
||||
const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 };
|
||||
const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 };
|
||||
const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS);
|
||||
const REQUIRED_PACKAGE_ENTRIES = ["dist/control-ui/index.html", "dist/postinstall-inventory.json"];
|
||||
const REQUIRED_PACKAGE_PREFIXES = ["dist/control-ui/assets/"];
|
||||
|
||||
const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [
|
||||
"dist/extensions/qa-channel/",
|
||||
@@ -104,23 +144,85 @@ function isLegacyLocalBuildMetadataCompatVersion(version) {
|
||||
}
|
||||
|
||||
function readTarEntry(entryPath) {
|
||||
const candidates = [entryPath, `package/${entryPath}`];
|
||||
const candidates = [
|
||||
path.join(extractDir, entryPath),
|
||||
path.join(extractDir, "package", entryPath),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const result = spawnSync("tar", ["-xOf", tarball, candidate], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
return result.stdout;
|
||||
if (fs.existsSync(candidate)) {
|
||||
return fs.readFileSync(candidate, "utf8");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
for (const entry of normalized) {
|
||||
if (entry.startsWith("/") || entry.split("/").includes("..")) {
|
||||
errors.push(`unsafe tar entry: ${entry}`);
|
||||
function isRelativeModuleSpecifier(value) {
|
||||
return value.startsWith("./") || value.startsWith("../");
|
||||
}
|
||||
|
||||
function normalizeModuleSpecifierTarget(value) {
|
||||
return value.split(/[?#]/u, 1)[0] ?? value;
|
||||
}
|
||||
|
||||
function normalizeTarPath(value) {
|
||||
return value.replace(/\\/gu, "/");
|
||||
}
|
||||
|
||||
function resolveTarImportTarget(importer, specifier) {
|
||||
const normalizedSpecifier = normalizeModuleSpecifierTarget(specifier);
|
||||
const base = normalizeTarPath(
|
||||
new URL(normalizedSpecifier, `file:///${importer}`).pathname.replace(/^\//u, ""),
|
||||
);
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}.js`,
|
||||
`${base}.mjs`,
|
||||
`${base}.cjs`,
|
||||
`${base}/index.js`,
|
||||
`${base}/index.mjs`,
|
||||
`${base}/index.cjs`,
|
||||
];
|
||||
return candidates.find((candidate) => entrySet.has(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function collectTarImportReferenceErrors() {
|
||||
if (unsafeEntries.length > 0) {
|
||||
return [];
|
||||
}
|
||||
const extractRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-check-"));
|
||||
const importErrors = [];
|
||||
try {
|
||||
const extract = spawnSync("tar", ["-xzf", tarball, "-C", extractRoot], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (extract.status !== 0) {
|
||||
return [
|
||||
`tar extraction failed for import reference check: ${extract.stderr || extract.status}`,
|
||||
];
|
||||
}
|
||||
|
||||
for (const entry of DIST_IMPORT_REFERENCE_ENTRYPOINTS.filter((entry) => entrySet.has(entry))) {
|
||||
const source = fs.readFileSync(path.join(extractRoot, "package", entry), "utf8");
|
||||
for (const match of source.matchAll(DIST_JS_IMPORT_SPECIFIER_PATTERN)) {
|
||||
const specifier = match.groups?.staticSpecifier ?? match.groups?.dynamicSpecifier ?? "";
|
||||
if (!isRelativeModuleSpecifier(specifier)) {
|
||||
continue;
|
||||
}
|
||||
if (resolveTarImportTarget(entry, specifier)) {
|
||||
continue;
|
||||
}
|
||||
importErrors.push(`missing packaged dist import target ${specifier} from ${entry}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(extractRoot, { recursive: true, force: true });
|
||||
}
|
||||
return importErrors.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
for (const entry of unsafeEntries) {
|
||||
errors.push(`unsafe tar entry: ${entry}`);
|
||||
}
|
||||
|
||||
if (!entrySet.has("package.json")) {
|
||||
@@ -147,8 +249,15 @@ for (const forbiddenEntry of FORBIDDEN_LOCAL_BUILD_METADATA_FILES) {
|
||||
errors.push(`forbidden local build metadata tar entry ${forbiddenEntry}`);
|
||||
}
|
||||
}
|
||||
if (!entrySet.has("dist/postinstall-inventory.json")) {
|
||||
errors.push("missing dist/postinstall-inventory.json");
|
||||
for (const requiredEntry of REQUIRED_PACKAGE_ENTRIES) {
|
||||
if (!entrySet.has(requiredEntry)) {
|
||||
errors.push(`missing required package tar entry ${requiredEntry}`);
|
||||
}
|
||||
}
|
||||
for (const requiredPrefix of REQUIRED_PACKAGE_PREFIXES) {
|
||||
if (!normalized.some((entry) => entry.startsWith(requiredPrefix))) {
|
||||
errors.push(`missing required package tar entries under ${requiredPrefix}`);
|
||||
}
|
||||
}
|
||||
if (entrySet.has("dist/postinstall-inventory.json")) {
|
||||
try {
|
||||
@@ -158,6 +267,8 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
|
||||
if (!Array.isArray(inventory) || inventory.some((entry) => typeof entry !== "string")) {
|
||||
errors.push("invalid dist/postinstall-inventory.json");
|
||||
} else {
|
||||
const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/"));
|
||||
const normalizedInventorySet = new Set(normalizedInventory);
|
||||
for (const inventoryEntry of inventory) {
|
||||
const normalizedEntry = inventoryEntry.replace(/\\/gu, "/");
|
||||
if (!entrySet.has(normalizedEntry)) {
|
||||
@@ -173,6 +284,16 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
|
||||
errors.push(`inventory references missing tar entry ${normalizedEntry}`);
|
||||
}
|
||||
}
|
||||
const expandedInventory = expandPackageDistImportClosure({
|
||||
files: normalized,
|
||||
seedFiles: normalizedInventory,
|
||||
readText: readTarEntry,
|
||||
});
|
||||
for (const importedEntry of expandedInventory) {
|
||||
if (!normalizedInventorySet.has(importedEntry)) {
|
||||
errors.push(`inventory omits imported dist file ${importedEntry}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(
|
||||
@@ -182,6 +303,7 @@ if (entrySet.has("dist/postinstall-inventory.json")) {
|
||||
);
|
||||
}
|
||||
}
|
||||
errors.push(...collectTarImportReferenceErrors());
|
||||
|
||||
if (errors.length > 0) {
|
||||
fail(`OpenClaw package tarball integrity failed:\n${errors.join("\n")}`);
|
||||
@@ -190,4 +312,5 @@ if (errors.length > 0) {
|
||||
for (const warning of warnings) {
|
||||
console.warn(`OpenClaw package tarball integrity warning: ${warning}`);
|
||||
}
|
||||
cleanupExtractDir();
|
||||
console.log("OpenClaw package tarball integrity passed.");
|
||||
|
||||
@@ -20,6 +20,7 @@ COPY packages ./packages
|
||||
COPY extensions ./extensions
|
||||
COPY patches ./patches
|
||||
COPY scripts/postinstall-bundled-plugins.mjs scripts/preinstall-package-manager-warning.mjs scripts/npm-runner.mjs scripts/windows-cmd-helpers.mjs ./scripts/
|
||||
COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
corepack enable \
|
||||
&& if ! pnpm install --frozen-lockfile >/tmp/openclaw-cleanup-pnpm-install.log 2>&1; then \
|
||||
|
||||
@@ -23,6 +23,7 @@ GW_NAME="openclaw-openwebui-gateway-$$"
|
||||
OW_NAME="openclaw-openwebui-$$"
|
||||
DOCKER_COMMAND_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_COMMAND_TIMEOUT:-600s}"
|
||||
DOCKER_PULL_TIMEOUT="${OPENCLAW_OPENWEBUI_DOCKER_PULL_TIMEOUT:-600s}"
|
||||
OPENWEBUI_READY_ATTEMPTS="${OPENCLAW_OPENWEBUI_READY_ATTEMPTS:-420}"
|
||||
|
||||
docker_cmd() {
|
||||
timeout "$DOCKER_COMMAND_TIMEOUT" "$@"
|
||||
@@ -177,7 +178,7 @@ docker_cmd docker run -d \
|
||||
|
||||
echo "Waiting for Open WebUI..."
|
||||
ow_ready=0
|
||||
for _ in $(seq 1 240); do
|
||||
for _ in $(seq 1 "$OPENWEBUI_READY_ATTEMPTS"); do
|
||||
if [ "$(docker_cmd docker inspect -f '{{.State.Running}}' "$OW_NAME" 2>/dev/null || echo false)" != "true" ]; then
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -106,7 +106,7 @@ Options:
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--model <provider/model> Override the model used for the agent-turn smoke.
|
||||
Default: openai/gpt-5.5 for the OpenAI lane
|
||||
Default: openai/gpt-5.4-mini for the OpenAI lane
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
@@ -209,7 +209,7 @@ case "$PROVIDER" in
|
||||
openai)
|
||||
AUTH_CHOICE="openai-api-key"
|
||||
AUTH_KEY_FLAG="openai-api-key"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
|
||||
;;
|
||||
anthropic)
|
||||
@@ -231,6 +231,7 @@ esac
|
||||
|
||||
API_KEY_VALUE="${!API_KEY_ENV:-}"
|
||||
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
|
||||
|
||||
case "${OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR:-}" in
|
||||
1|true|TRUE|yes|YES|on|ON)
|
||||
DISABLE_BONJOUR_FOR_GATEWAY=1
|
||||
|
||||
@@ -49,11 +49,11 @@ BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
|
||||
TIMEOUT_INSTALL_SITE_S=420
|
||||
TIMEOUT_INSTALL_TGZ_S=420
|
||||
TIMEOUT_INSTALL_REGISTRY_S=420
|
||||
TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1200}"
|
||||
TIMEOUT_UPDATE_DEV_S="${OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S:-1800}"
|
||||
TIMEOUT_VERIFY_S=60
|
||||
TIMEOUT_ONBOARD_S=180
|
||||
TIMEOUT_GATEWAY_S=180
|
||||
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-240}"
|
||||
TIMEOUT_GATEWAY_S=420
|
||||
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S:-900}"
|
||||
TIMEOUT_PERMISSION_S=60
|
||||
TIMEOUT_DASHBOARD_S=180
|
||||
TIMEOUT_SNAPSHOT_S=360
|
||||
@@ -144,7 +144,7 @@ Options:
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--model <provider/model> Override the model used for the agent-turn smoke.
|
||||
Default: openai/gpt-5.5 for the OpenAI lane
|
||||
Default: openai/gpt-5.4-mini for the OpenAI lane
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
@@ -266,7 +266,7 @@ case "$PROVIDER" in
|
||||
openai)
|
||||
AUTH_CHOICE="openai-api-key"
|
||||
AUTH_KEY_FLAG="openai-api-key"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
|
||||
;;
|
||||
anthropic)
|
||||
@@ -1109,6 +1109,7 @@ ensure_guest_pnpm_for_dev_update() {
|
||||
|
||||
repair_legacy_dev_source_checkout_if_needed() {
|
||||
local bootstrap_bin update_root update_entry
|
||||
LEGACY_DEV_SOURCE_REPAIR_APPLIED=0
|
||||
bootstrap_bin="/tmp/openclaw-smoke-pnpm-bootstrap/node_modules/.bin"
|
||||
update_root="$(resolve_guest_current_user_home)/openclaw"
|
||||
update_entry="$update_root/openclaw.mjs"
|
||||
@@ -1121,6 +1122,7 @@ repair_legacy_dev_source_checkout_if_needed() {
|
||||
if ! guest_current_user_exec /bin/test -f "$update_root/src/entry.ts"; then
|
||||
return 0
|
||||
fi
|
||||
LEGACY_DEV_SOURCE_REPAIR_APPLIED=1
|
||||
warn "repairing legacy dev source archive into git checkout"
|
||||
ensure_guest_pnpm_for_dev_update
|
||||
guest_current_user_exec /bin/rm -rf "$update_root"
|
||||
@@ -1160,6 +1162,9 @@ EOF
|
||||
guest_current_user_tail_file "$update_log" 120 >&2 || true
|
||||
fi
|
||||
repair_legacy_dev_source_checkout_if_needed
|
||||
if (( update_rc != 0 && LEGACY_DEV_SOURCE_REPAIR_APPLIED == 0 )); then
|
||||
return "$update_rc"
|
||||
fi
|
||||
printf 'update-dev: git-version\n'
|
||||
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" --version
|
||||
printf 'update-dev: git-status\n'
|
||||
@@ -1393,6 +1398,17 @@ EOF
|
||||
guest_current_user_exec /bin/bash -lc "$cmd"
|
||||
}
|
||||
|
||||
reset_openclaw_user_state() {
|
||||
guest_current_user_sh "$(cat <<'EOF'
|
||||
/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true
|
||||
rm -rf "$HOME/.openclaw"
|
||||
rm -f /tmp/openclaw-parallels-macos-gateway.log
|
||||
EOF
|
||||
)"
|
||||
}
|
||||
|
||||
run_ref_onboard() {
|
||||
local daemon_args=("--install-daemon")
|
||||
if headless_guest_fallback; then
|
||||
@@ -1460,15 +1476,36 @@ EOF
|
||||
}
|
||||
|
||||
verify_gateway() {
|
||||
local attempt
|
||||
for attempt in 1 2 3 4 5 6 7 8; do
|
||||
if guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 15000; then
|
||||
local attempt deadline probe_json
|
||||
attempt=1
|
||||
deadline=$((SECONDS + TIMEOUT_GATEWAY_S))
|
||||
while (( SECONDS < deadline )); do
|
||||
if probe_json="$(
|
||||
guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway probe \
|
||||
--url ws://127.0.0.1:18789 \
|
||||
--timeout 30000 \
|
||||
--json
|
||||
)"; then
|
||||
printf '%s\n' "$probe_json"
|
||||
if PROBE_JSON="$probe_json" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = json.loads(os.environ["PROBE_JSON"])
|
||||
raise SystemExit(0 if payload.get("ok") else 1)
|
||||
PY
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
elif ! guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway probe --help >/dev/null 2>&1 &&
|
||||
guest_current_user_exec "$GUEST_OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 30000; then
|
||||
return 0
|
||||
fi
|
||||
if (( attempt < 8 )); then
|
||||
printf 'gateway-status retry %s\n' "$attempt" >&2
|
||||
if (( SECONDS < deadline )); then
|
||||
printf 'gateway-probe retry %s\n' "$attempt" >&2
|
||||
sleep 5
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
@@ -1482,9 +1519,15 @@ show_gateway_status_compat() {
|
||||
}
|
||||
|
||||
verify_turn() {
|
||||
local agent_log agent_done agent_runner attempt rc
|
||||
agent_log="/tmp/openclaw-parallels-agent-turn.log"
|
||||
agent_done="/tmp/openclaw-parallels-agent-turn.done"
|
||||
agent_runner="/tmp/openclaw-parallels-agent-turn.sh"
|
||||
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" models set "$MODEL_ID"
|
||||
guest_current_user_exec "$GUEST_NODE_BIN" "$GUEST_OPENCLAW_ENTRY" config set agents.defaults.skipBootstrap true --strict-json
|
||||
guest_current_user_sh "$(cat <<EOF
|
||||
for attempt in 1 2; do
|
||||
set +e
|
||||
run_logged_guest_current_user_sh "$(cat <<EOF
|
||||
export PATH=$(shell_quote "$GUEST_EXEC_PATH")
|
||||
workspace="\${OPENCLAW_WORKSPACE_DIR:-\$HOME/.openclaw/workspace}"
|
||||
mkdir -p "\$workspace/.openclaw"
|
||||
@@ -1508,7 +1551,19 @@ exec /usr/bin/env $(shell_quote "$API_KEY_ENV=$API_KEY_VALUE") \
|
||||
--message $(shell_quote "Reply with exact ASCII text OK only.") \
|
||||
--json
|
||||
EOF
|
||||
)"
|
||||
)" "$agent_log" "$agent_done" "$TIMEOUT_AGENT_S" "$agent_runner"
|
||||
rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
return 0
|
||||
fi
|
||||
if (( attempt < 2 )) &&
|
||||
guest_current_user_exec /usr/bin/grep -Eq 'plugin load failed: .*ENOENT: .*plugin-runtime-deps.*/dist/' "$agent_log"; then
|
||||
warn "retrying macOS agent turn after staged runtime mirror race"
|
||||
continue
|
||||
fi
|
||||
return "$rc"
|
||||
done
|
||||
}
|
||||
|
||||
resolve_dashboard_url() {
|
||||
@@ -1944,6 +1999,7 @@ run_fresh_main_lane() {
|
||||
local snapshot_id="$1"
|
||||
local host_ip="$2"
|
||||
phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
|
||||
phase_run "fresh.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
|
||||
phase_run "fresh.install-main" "$(install_main_timeout)" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz"
|
||||
FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")"
|
||||
phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
|
||||
@@ -1971,6 +2027,7 @@ run_upgrade_lane() {
|
||||
local snapshot_id="$1"
|
||||
local host_ip="$2"
|
||||
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
|
||||
phase_run "upgrade.reset-state" "$TIMEOUT_ONBOARD_S" reset_openclaw_user_state
|
||||
phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_SITE_S" install_latest_release
|
||||
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")"
|
||||
phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$INSTALL_VERSION"
|
||||
|
||||
@@ -122,7 +122,7 @@ Options:
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--model <provider/model> Override the model used for agent-turn smoke checks.
|
||||
Default: openai/gpt-5.5 for the OpenAI lane
|
||||
Default: openai/gpt-5.4-mini for the OpenAI lane
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
@@ -214,7 +214,7 @@ case "$PROVIDER" in
|
||||
openai)
|
||||
AUTH_CHOICE="openai-api-key"
|
||||
AUTH_KEY_FLAG="openai-api-key"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
|
||||
;;
|
||||
anthropic)
|
||||
@@ -236,6 +236,7 @@ esac
|
||||
|
||||
API_KEY_VALUE="${!API_KEY_ENV:-}"
|
||||
[[ -n "$API_KEY_VALUE" ]] || die "$API_KEY_ENV is required"
|
||||
|
||||
resolve_python_bin
|
||||
|
||||
resolve_linux_vm_name() {
|
||||
@@ -701,35 +702,6 @@ function Stop-OpenClawUpdateProcesses {
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-FuturePluginEntries {
|
||||
$configPath = Join-Path $env:USERPROFILE '.openclaw\openclaw.json'
|
||||
if (-not (Test-Path $configPath)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
$config = Get-Content $configPath -Raw | ConvertFrom-Json -AsHashtable
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
$plugins = $config['plugins']
|
||||
if (-not ($plugins -is [hashtable])) {
|
||||
return
|
||||
}
|
||||
$entries = $plugins['entries']
|
||||
if ($entries -is [hashtable]) {
|
||||
foreach ($pluginId in @('feishu', 'whatsapp')) {
|
||||
if ($entries.ContainsKey($pluginId)) {
|
||||
$entries.Remove($pluginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
$allow = $plugins['allow']
|
||||
if ($allow -is [array]) {
|
||||
$plugins['allow'] = @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp') })
|
||||
}
|
||||
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
|
||||
}
|
||||
|
||||
function Invoke-OpenClawUpdateWithTimeout {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OpenClawPath,
|
||||
@@ -742,7 +714,7 @@ function Invoke-OpenClawUpdateWithTimeout {
|
||||
$previousDisableBundledPlugins = $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS
|
||||
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
|
||||
try {
|
||||
$output = & $Path update --tag $Target --yes --json *>&1
|
||||
$output = & $Path update --tag $Target --yes --no-restart --json *>&1
|
||||
} finally {
|
||||
if ($null -eq $previousDisableBundledPlugins) {
|
||||
Remove-Item Env:OPENCLAW_DISABLE_BUNDLED_PLUGINS -ErrorAction SilentlyContinue
|
||||
@@ -835,6 +807,33 @@ function Invoke-OpenClawAgentWithTimeout {
|
||||
throw "openclaw agent timed out after ${TimeoutSeconds}s"
|
||||
}
|
||||
|
||||
function Invoke-OpenClawVersionWithRetry {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OpenClawPath,
|
||||
[Parameter(Mandatory = $false)][string]$ExpectedNeedle,
|
||||
[int]$Attempts = 12,
|
||||
[int]$SleepSeconds = 2
|
||||
)
|
||||
|
||||
$lastError = ''
|
||||
for ($attempt = 1; $attempt -le $Attempts; $attempt++) {
|
||||
Write-ProgressLog "update.verify-version.attempt-$attempt"
|
||||
try {
|
||||
$version = Invoke-CaptureLogged 'openclaw --version' { & $OpenClawPath --version }
|
||||
if (-not $ExpectedNeedle -or $version -match [regex]::Escape($ExpectedNeedle)) {
|
||||
return $version
|
||||
}
|
||||
$lastError = "version mismatch: expected substring $ExpectedNeedle"
|
||||
} catch {
|
||||
$lastError = $_ | Out-String
|
||||
}
|
||||
if ($attempt -lt $Attempts) {
|
||||
Start-Sleep -Seconds $SleepSeconds
|
||||
}
|
||||
}
|
||||
throw $lastError
|
||||
}
|
||||
|
||||
function Start-GatewayRunFallback {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$OpenClawPath
|
||||
@@ -943,15 +942,11 @@ try {
|
||||
}
|
||||
Set-Item -Path ('Env:' + $ProviderKeyEnv) -Value $ProviderKey
|
||||
$openclaw = Join-Path $env:APPDATA 'npm\openclaw.cmd'
|
||||
Remove-FuturePluginEntries
|
||||
Stop-OpenClawGatewayProcesses
|
||||
Write-ProgressLog 'update.openclaw-update'
|
||||
Invoke-OpenClawUpdateWithTimeout -OpenClawPath $openclaw -UpdateTarget $UpdateTarget
|
||||
Write-ProgressLog 'update.verify-version'
|
||||
$version = Invoke-CaptureLogged 'openclaw --version' { & $openclaw --version }
|
||||
if ($ExpectedNeedle -and $version -notmatch [regex]::Escape($ExpectedNeedle)) {
|
||||
throw "version mismatch: expected substring $ExpectedNeedle"
|
||||
}
|
||||
$version = Invoke-OpenClawVersionWithRetry -OpenClawPath $openclaw -ExpectedNeedle $ExpectedNeedle
|
||||
Write-ProgressLog $version
|
||||
Write-ProgressLog 'update.status'
|
||||
Invoke-Logged 'openclaw update status' { & $openclaw update status --json }
|
||||
@@ -1054,11 +1049,20 @@ gateway_listener_ready() {
|
||||
gateway_log_ready() {
|
||||
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||
[ -n "\$latest" ] || return 1
|
||||
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
|
||||
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -Eq 'ready( \(|[[:space:]]*\$)'
|
||||
}
|
||||
gateway_smoke_ready() {
|
||||
gateway_listener_ready && gateway_log_ready
|
||||
}
|
||||
stop_openclaw_gateway_processes() {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -9 -f openclaw-gateway || true
|
||||
/usr/bin/pkill -9 -f 'openclaw gateway run' || true
|
||||
/usr/bin/pkill -9 -f 'openclaw.mjs gateway' || true
|
||||
for pid in \$(/usr/sbin/lsof -tiTCP:18789 -sTCP:LISTEN 2>/dev/null || true); do
|
||||
/bin/kill -9 "\$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
if [ -n "\$busy" ]; then
|
||||
printf 'update still has active npm/pnpm/openclaw processes\n%s\n' "\$busy" >&2
|
||||
exit 1
|
||||
@@ -1094,6 +1098,18 @@ if [ "\$gateway_ready" != "1" ]; then
|
||||
done
|
||||
fi
|
||||
if [ "\$gateway_ready" != "1" ]; then
|
||||
stop_openclaw_gateway_processes
|
||||
/opt/homebrew/bin/openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-npm-update-macos-recover-gateway.log 2>&1 </dev/null &
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if gateway_smoke_ready; then
|
||||
gateway_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
if [ "\$gateway_ready" != "1" ]; then
|
||||
tail -n 120 /tmp/openclaw-parallels-npm-update-macos-recover-gateway.log 2>/dev/null || true
|
||||
echo "gateway did not become ready after transport recovery" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1489,7 +1505,7 @@ run_windows_script_via_log() {
|
||||
local provider_key="$7"
|
||||
local runner_name log_name done_name done_status launcher_state guest_log
|
||||
local start_seconds poll_deadline startup_checked poll_rc state_rc log_rc
|
||||
local log_state_path provider_key_b64
|
||||
local log_state_path provider_key_b64 plugin_allow_b64
|
||||
runner_name="openclaw-update-$RANDOM-$RANDOM.ps1"
|
||||
log_name="openclaw-update-$RANDOM-$RANDOM.log"
|
||||
done_name="openclaw-update-$RANDOM-$RANDOM.done"
|
||||
@@ -1637,38 +1653,11 @@ gateway_listener_ready() {
|
||||
gateway_log_ready() {
|
||||
latest="\$(/bin/ls -t /tmp/openclaw/openclaw-*.log 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||
[ -n "\$latest" ] || return 1
|
||||
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -q 'ready ('
|
||||
/usr/bin/tail -n 160 "\$latest" | /usr/bin/grep -Eq 'ready( \(|[[:space:]]*\$)'
|
||||
}
|
||||
gateway_smoke_ready() {
|
||||
gateway_listener_ready && gateway_log_ready
|
||||
}
|
||||
scrub_future_plugin_entries() {
|
||||
node - <<'JS' || true
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
if (!fs.existsSync(configPath)) process.exit(0);
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
const plugins = config?.plugins;
|
||||
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
|
||||
const entries = plugins.entries;
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
delete entries.feishu;
|
||||
delete entries.whatsapp;
|
||||
}
|
||||
if (Array.isArray(plugins.allow)) {
|
||||
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
|
||||
JS
|
||||
}
|
||||
stop_openclaw_gateway_processes() {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop >/dev/null 2>&1 || true
|
||||
/usr/bin/pkill -9 -f openclaw-gateway || true
|
||||
@@ -1678,29 +1667,43 @@ stop_openclaw_gateway_processes() {
|
||||
/bin/kill -9 "\$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
openclaw_version_with_retry() {
|
||||
bin="\$1"
|
||||
expected="\$2"
|
||||
version=""
|
||||
last_output=""
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
set +e
|
||||
version="\$("\$bin" --version 2>&1)"
|
||||
status=\$?
|
||||
set -e
|
||||
if [ "\$status" -eq 0 ]; then
|
||||
printf '%s\n' "\$version"
|
||||
if [ -z "\$expected" ] || [[ "\$version" == *"\$expected"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
last_output="version mismatch: expected substring \$expected"
|
||||
else
|
||||
last_output="\$version"
|
||||
printf '%s\n' "\$last_output" >&2
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
printf '%s\n' "\$last_output" >&2
|
||||
return 1
|
||||
}
|
||||
# Stop the pre-update gateway before replacing the package. Otherwise the old
|
||||
# host can observe new plugin metadata mid-update and abort config validation.
|
||||
scrub_future_plugin_entries
|
||||
stop_openclaw_gateway_processes
|
||||
# The baseline updater process may run its post-install doctor through the old
|
||||
# host while new bundled plugin metadata is already on disk. Keep this
|
||||
# same-guest update hop focused on core/package migration; post-update smoke
|
||||
# below starts the fresh gateway with bundled plugins enabled.
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --no-restart --json
|
||||
# Same-guest npm upgrades can leave the old gateway process holding the old
|
||||
# bundled plugin host version. Stop it before post-update config commands.
|
||||
stop_openclaw_gateway_processes
|
||||
version="\$(/opt/homebrew/bin/openclaw --version)"
|
||||
printf '%s\n' "\$version"
|
||||
if [ -n "$expected_needle" ]; then
|
||||
case "\$version" in
|
||||
*"$expected_needle"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $expected_needle" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
version="\$(openclaw_version_with_retry /opt/homebrew/bin/openclaw "$expected_needle")"
|
||||
/opt/homebrew/bin/openclaw update status --json
|
||||
/opt/homebrew/bin/openclaw models set "$MODEL_ID"
|
||||
/opt/homebrew/bin/openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
@@ -1775,33 +1778,6 @@ run_linux_update() {
|
||||
set -euo pipefail
|
||||
export HOME=/root
|
||||
cd "\$HOME"
|
||||
scrub_future_plugin_entries() {
|
||||
node - <<'JS' || true
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const configPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
if (!fs.existsSync(configPath)) process.exit(0);
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
} catch {
|
||||
process.exit(0);
|
||||
}
|
||||
const plugins = config?.plugins;
|
||||
if (!plugins || typeof plugins !== "object" || Array.isArray(plugins)) process.exit(0);
|
||||
const entries = plugins.entries;
|
||||
if (entries && typeof entries === "object" && !Array.isArray(entries)) {
|
||||
delete entries.feishu;
|
||||
delete entries.whatsapp;
|
||||
}
|
||||
if (Array.isArray(plugins.allow)) {
|
||||
plugins.allow = plugins.allow.filter((pluginId) => pluginId !== "feishu" && pluginId !== "whatsapp");
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
|
||||
JS
|
||||
}
|
||||
stop_openclaw_gateway_processes() {
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw gateway stop >/dev/null 2>&1 || true
|
||||
pkill -9 -f openclaw-gateway || true
|
||||
@@ -1816,25 +1792,39 @@ stop_openclaw_gateway_processes() {
|
||||
done
|
||||
fi
|
||||
}
|
||||
openclaw_version_with_retry() {
|
||||
bin="\$1"
|
||||
expected="\$2"
|
||||
version=""
|
||||
last_output=""
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do
|
||||
set +e
|
||||
version="\$("\$bin" --version 2>&1)"
|
||||
status=\$?
|
||||
set -e
|
||||
if [ "\$status" -eq 0 ]; then
|
||||
printf '%s\n' "\$version"
|
||||
if [ -z "\$expected" ] || [[ "\$version" == *"\$expected"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
last_output="version mismatch: expected substring \$expected"
|
||||
else
|
||||
last_output="\$version"
|
||||
printf '%s\n' "\$last_output" >&2
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
printf '%s\n' "\$last_output" >&2
|
||||
return 1
|
||||
}
|
||||
# Stop the pre-update manual gateway before replacing the package. Otherwise
|
||||
# the old host can observe new plugin metadata mid-update and abort validation.
|
||||
scrub_future_plugin_entries
|
||||
stop_openclaw_gateway_processes
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --json
|
||||
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --no-restart --json
|
||||
# The fresh Linux lane starts a manual gateway; stop the old process before
|
||||
# post-update config validation sees mixed old-host/new-plugin metadata.
|
||||
stop_openclaw_gateway_processes
|
||||
version="\$(openclaw --version)"
|
||||
printf '%s\n' "\$version"
|
||||
if [ -n "$expected_needle" ]; then
|
||||
case "\$version" in
|
||||
*"$expected_needle"*) ;;
|
||||
*)
|
||||
echo "version mismatch: expected substring $expected_needle" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
version="\$(openclaw_version_with_retry openclaw "$expected_needle")"
|
||||
openclaw update status --json
|
||||
openclaw models set "$MODEL_ID"
|
||||
openclaw config set agents.defaults.skipBootstrap true --strict-json
|
||||
|
||||
@@ -50,9 +50,9 @@ TIMEOUT_UPDATE_POLL_GRACE_S=60
|
||||
TIMEOUT_VERIFY_S=120
|
||||
TIMEOUT_ONBOARD_S=600
|
||||
TIMEOUT_ONBOARD_PHASE_S=$((TIMEOUT_ONBOARD_S + 120))
|
||||
# verify_gateway_reachable runs six 30s probes plus short retry sleeps.
|
||||
TIMEOUT_GATEWAY_S=420
|
||||
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S:-900}"
|
||||
GATEWAY_RECOVERY_AFTER_S="${OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S:-180}"
|
||||
TIMEOUT_AGENT_S="${OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S:-1500}"
|
||||
PHASE_STALE_WARN_S=60
|
||||
|
||||
FRESH_MAIN_STATUS="skip"
|
||||
@@ -140,7 +140,7 @@ Options:
|
||||
--provider <openai|anthropic|minimax>
|
||||
Provider auth/model lane. Default: openai
|
||||
--model <provider/model> Override the model used for the agent-turn smoke.
|
||||
Default: openai/gpt-5.5 for the OpenAI lane
|
||||
Default: openai/gpt-5.4-mini for the OpenAI lane
|
||||
--api-key-env <var> Host env var name for provider API key.
|
||||
Default: OPENAI_API_KEY for openai, ANTHROPIC_API_KEY for anthropic
|
||||
--openai-api-key-env <var> Alias for --api-key-env (backward compatible)
|
||||
@@ -257,7 +257,7 @@ case "$PROVIDER" in
|
||||
openai)
|
||||
AUTH_CHOICE="openai-api-key"
|
||||
AUTH_KEY_FLAG="openai-api-key"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.5}"
|
||||
[[ "$MODEL_ID_EXPLICIT" -eq 1 ]] || MODEL_ID="${OPENCLAW_PARALLELS_OPENAI_MODEL:-openai/gpt-5.4-mini}"
|
||||
[[ -n "$API_KEY_ENV" ]] || API_KEY_ENV="OPENAI_API_KEY"
|
||||
;;
|
||||
anthropic)
|
||||
@@ -616,7 +616,7 @@ EOF
|
||||
|
||||
guest_run_agent_turn_process() {
|
||||
local env_name_q env_value_q runner_basename runner_script_path runner_url runner_url_q
|
||||
local runner_name stdout_name stderr_name done_name
|
||||
local runner_name stdout_name stderr_name done_name ok_name
|
||||
local start_seconds poll_deadline startup_checked state_rc log_rc done_rc
|
||||
local agent_combined done_status launcher_state
|
||||
env_name_q="$(ps_single_quote "$API_KEY_ENV")"
|
||||
@@ -629,6 +629,7 @@ guest_run_agent_turn_process() {
|
||||
stdout_name="openclaw-parallels-agent-$RANDOM-$RANDOM.out.log"
|
||||
stderr_name="openclaw-parallels-agent-$RANDOM-$RANDOM.err.log"
|
||||
done_name="openclaw-parallels-agent-$RANDOM-$RANDOM.done"
|
||||
ok_name="openclaw-parallels-agent-$RANDOM-$RANDOM.ok"
|
||||
start_seconds="$SECONDS"
|
||||
poll_deadline=$((SECONDS + TIMEOUT_AGENT_S + 60))
|
||||
startup_checked=0
|
||||
@@ -637,21 +638,33 @@ guest_run_agent_turn_process() {
|
||||
param(
|
||||
[string]$StdoutPath,
|
||||
[string]$StderrPath,
|
||||
[string]$OkPath,
|
||||
[string]$DonePath,
|
||||
[string]$EnvName,
|
||||
[string]$EnvValue
|
||||
[string]$EnvName
|
||||
)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
if ($EnvName -ne '') {
|
||||
Set-Item -Path ('Env:' + $EnvName) -Value $EnvValue
|
||||
}
|
||||
$node = Join-Path $env:ProgramFiles 'nodejs\node.exe'
|
||||
if (-not (Test-Path $node)) {
|
||||
$node = 'node'
|
||||
}
|
||||
$entry = Join-Path $env:APPDATA 'npm\node_modules\openclaw\openclaw.mjs'
|
||||
& $node $entry agent --local --agent main --session-id 'parallels-windows-smoke' --message 'Reply with exact ASCII text OK only.' --json > $StdoutPath 2> $StderrPath
|
||||
$ok = '0'
|
||||
try {
|
||||
$raw = Get-Content -Path $StdoutPath -Raw -ErrorAction Stop
|
||||
$json = $raw | ConvertFrom-Json -ErrorAction Stop
|
||||
foreach ($payload in @($json.payloads)) {
|
||||
if ($payload.text -eq 'OK') {
|
||||
$ok = '1'
|
||||
}
|
||||
}
|
||||
if ($json.meta.finalAssistantVisibleText -eq 'OK' -or $json.meta.finalAssistantRawText -eq 'OK') {
|
||||
$ok = '1'
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
Set-Content -Path $OkPath -Value $ok
|
||||
Set-Content -Path $DonePath -Value ([string]$LASTEXITCODE)
|
||||
exit $LASTEXITCODE
|
||||
} catch {
|
||||
@@ -666,8 +679,12 @@ EOF
|
||||
\$stdout = Join-Path \$env:TEMP '$stdout_name'
|
||||
\$stderr = Join-Path \$env:TEMP '$stderr_name'
|
||||
\$done = Join-Path \$env:TEMP '$done_name'
|
||||
Remove-Item \$runner, \$stdout, \$stderr, \$done -Force -ErrorAction SilentlyContinue
|
||||
\$ok = Join-Path \$env:TEMP '$ok_name'
|
||||
Remove-Item \$runner, \$stdout, \$stderr, \$done, \$ok -Force -ErrorAction SilentlyContinue
|
||||
curl.exe -fsSL '${runner_url_q}' -o \$runner
|
||||
if ('${env_name_q}' -ne '') {
|
||||
Set-Item -Path ('Env:' + '${env_name_q}') -Value '${env_value_q}'
|
||||
}
|
||||
Start-Process powershell.exe -ArgumentList @(
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
@@ -678,12 +695,12 @@ Start-Process powershell.exe -ArgumentList @(
|
||||
\$stdout,
|
||||
'-StderrPath',
|
||||
\$stderr,
|
||||
'-OkPath',
|
||||
\$ok,
|
||||
'-DonePath',
|
||||
\$done,
|
||||
'-EnvName',
|
||||
'${env_name_q}',
|
||||
'-EnvValue',
|
||||
'${env_value_q}'
|
||||
'${env_name_q}'
|
||||
) -WindowStyle Hidden | Out-Null
|
||||
EOF
|
||||
)"
|
||||
@@ -697,7 +714,7 @@ EOF
|
||||
set -e
|
||||
if [[ $log_rc -eq 0 ]] && printf '%s\n' "$agent_combined" | grep -Eq '"finalAssistant(Raw|Visible)Text":[[:space:]]*"OK"'; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -708,10 +725,19 @@ EOF
|
||||
done_rc=$?
|
||||
set -e
|
||||
if [[ $done_rc -eq 0 && -n "$done_status" ]]; then
|
||||
ok_status="$(guest_powershell_poll 20 "\$ok = Join-Path \$env:TEMP '$ok_name'; if (Test-Path \$ok) { (Get-Content \$ok -Raw).Trim() }" 2>/dev/null || true)"
|
||||
ok_status="${ok_status//$'\r'/}"
|
||||
if [[ "$ok_status" == "1" ]]; then
|
||||
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
fi
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
|
||||
return 0
|
||||
fi
|
||||
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
fi
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
|
||||
warn "openclaw agent finished without OK response (exit $done_status)"
|
||||
return 1
|
||||
fi
|
||||
@@ -719,7 +745,7 @@ EOF
|
||||
if [[ "$startup_checked" -eq 0 && $((SECONDS - start_seconds)) -ge 20 ]]; then
|
||||
set +e
|
||||
launcher_state="$(
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' process=' + [bool]\$process"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$stdout = Join-Path \$env:TEMP '$stdout_name'; \$stderr = Join-Path \$env:TEMP '$stderr_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; \$currentPid = \$PID; \$process = Get-CimInstance Win32_Process | Where-Object { \$_.ProcessId -ne \$currentPid -and ((\$_.CommandLine -like '*$runner_name*') -or (\$_.CommandLine -like '*openclaw.mjs*agent*parallels-windows-smoke*')) } | Select-Object -First 1; 'runner=' + (Test-Path \$runner) + ' stdout=' + (Test-Path \$stdout) + ' stderr=' + (Test-Path \$stderr) + ' done=' + (Test-Path \$done) + ' ok=' + (Test-Path \$ok) + ' process=' + [bool]\$process"
|
||||
)"
|
||||
state_rc=$?
|
||||
set -e
|
||||
@@ -735,7 +761,7 @@ EOF
|
||||
if [[ $log_rc -eq 0 && -n "$agent_combined" ]]; then
|
||||
printf '%s\n' "$agent_combined"
|
||||
fi
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; Remove-Item \$runner, \$done -Force -ErrorAction SilentlyContinue"
|
||||
guest_powershell_poll 20 "\$runner = Join-Path \$env:TEMP '$runner_name'; \$done = Join-Path \$env:TEMP '$done_name'; \$ok = Join-Path \$env:TEMP '$ok_name'; Remove-Item \$runner, \$done, \$ok -Force -ErrorAction SilentlyContinue"
|
||||
warn "openclaw agent timed out after $TIMEOUT_AGENT_S seconds"
|
||||
return 1
|
||||
fi
|
||||
@@ -1029,11 +1055,24 @@ for wanted in preferred_names:
|
||||
break
|
||||
|
||||
if best is None:
|
||||
candidates = []
|
||||
for asset in assets:
|
||||
name = asset.get("name", "")
|
||||
if name.startswith("MinGit-") and name.endswith(".zip") and "busybox" not in name:
|
||||
best = asset
|
||||
break
|
||||
if not (name.startswith("MinGit-") and name.endswith(".zip")):
|
||||
continue
|
||||
if "busybox" in name:
|
||||
continue
|
||||
if "-arm64." in name:
|
||||
rank = 0
|
||||
elif "-64-bit." in name:
|
||||
rank = 1
|
||||
elif "-32-bit." in name:
|
||||
rank = 2
|
||||
else:
|
||||
rank = 3
|
||||
candidates.append((rank, name, asset))
|
||||
if candidates:
|
||||
best = sorted(candidates, key=lambda item: (item[0], item[1]))[0][2]
|
||||
|
||||
if best is None:
|
||||
raise SystemExit("no MinGit asset found")
|
||||
@@ -1137,7 +1176,7 @@ ensure_mingit_zip() {
|
||||
MINGIT_ZIP_PATH="$MAIN_TGZ_DIR/$mingit_name"
|
||||
if [[ ! -f "$MINGIT_ZIP_PATH" ]]; then
|
||||
say "Download $MINGIT_ZIP_NAME"
|
||||
curl -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
|
||||
curl --retry 5 --retry-delay 3 --retry-all-errors -fsSL "$mingit_url" -o "$MINGIT_ZIP_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -2382,8 +2421,13 @@ verify_gateway() {
|
||||
}
|
||||
|
||||
verify_gateway_reachable() {
|
||||
local probe_json attempt
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
local probe_json attempt start_seconds deadline recovery_tried
|
||||
start_seconds="$SECONDS"
|
||||
deadline=$((SECONDS + TIMEOUT_GATEWAY_S))
|
||||
recovery_tried=0
|
||||
attempt=1
|
||||
|
||||
while (( SECONDS < deadline )); do
|
||||
probe_json="$(
|
||||
guest_run_openclaw "" "" gateway probe --url ws://127.0.0.1:18789 --timeout 30000 --json
|
||||
)"
|
||||
@@ -2398,11 +2442,25 @@ PY
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
if (( attempt < 6 )); then
|
||||
printf 'gateway-reachable retry %s\n' "$attempt" >&2
|
||||
sleep 3
|
||||
|
||||
if [[ "$recovery_tried" -eq 0 && $((SECONDS - start_seconds)) -ge "$GATEWAY_RECOVERY_AFTER_S" ]]; then
|
||||
printf 'gateway-reachable recovery: gateway start after %ss\n' "$((SECONDS - start_seconds))" >&2
|
||||
if ! run_gateway_daemon_action start; then
|
||||
printf 'gateway-reachable recovery start failed; continuing probes\n' >&2
|
||||
fi
|
||||
recovery_tried=1
|
||||
fi
|
||||
|
||||
printf 'gateway-reachable retry %s elapsed=%ss\n' "$attempt" "$((SECONDS - start_seconds))" >&2
|
||||
attempt=$((attempt + 1))
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if [[ "$recovery_tried" -eq 0 ]]; then
|
||||
printf 'gateway-reachable recovery: gateway start after timeout\n' >&2
|
||||
run_gateway_daemon_action start || true
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
186
scripts/lib/package-dist-imports.mjs
Normal file
186
scripts/lib/package-dist-imports.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
import path from "node:path";
|
||||
|
||||
const JS_DIST_FILE_RE = /^dist\/.*\.(?:cjs|js|mjs)$/u;
|
||||
|
||||
function normalizePackagePath(value) {
|
||||
return value.replace(/\\/gu, "/").replace(/^package\//u, "");
|
||||
}
|
||||
|
||||
function stripSpecifierSuffix(value) {
|
||||
return value.replace(/[?#].*$/u, "");
|
||||
}
|
||||
|
||||
function resolveDistImportPath(importerPath, specifier) {
|
||||
if (!specifier.startsWith(".")) {
|
||||
return null;
|
||||
}
|
||||
const stripped = stripSpecifierSuffix(specifier);
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
return path.posix.normalize(path.posix.join(path.posix.dirname(importerPath), stripped));
|
||||
}
|
||||
|
||||
function findStatementStart(source, index) {
|
||||
return (
|
||||
Math.max(
|
||||
source.lastIndexOf(";", index),
|
||||
source.lastIndexOf("{", index),
|
||||
source.lastIndexOf("}", index),
|
||||
source.lastIndexOf("\n", index),
|
||||
source.lastIndexOf("\r", index),
|
||||
) + 1
|
||||
);
|
||||
}
|
||||
|
||||
function isImportSpecifierContext(source, index) {
|
||||
const dynamicPrefix = source.slice(Math.max(0, index - 32), index);
|
||||
if (/\bimport\s*\(\s*$/u.test(dynamicPrefix)) {
|
||||
return true;
|
||||
}
|
||||
const statementPrefix = source.slice(findStatementStart(source, index), index).trimStart();
|
||||
return (
|
||||
/^(?:import|export)\b[\s\S]*\bfrom\s*$/u.test(statementPrefix) ||
|
||||
/^import\s*$/u.test(statementPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
function collectImportSpecifiers(source) {
|
||||
const specifiers = [];
|
||||
let inBlockComment = false;
|
||||
let inLineComment = false;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (inBlockComment) {
|
||||
if (source[index] === "*" && source[index + 1] === "/") {
|
||||
inBlockComment = false;
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inLineComment) {
|
||||
if (source[index] === "\n" || source[index] === "\r") {
|
||||
inLineComment = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (source[index] === "/" && source[index + 1] === "*") {
|
||||
inBlockComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (source[index] === "/" && source[index + 1] === "/") {
|
||||
inLineComment = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const quote = source[index];
|
||||
if (quote !== '"' && quote !== "'") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = index + 1;
|
||||
let value = "";
|
||||
while (cursor < source.length) {
|
||||
const char = source[cursor];
|
||||
if (char === "\\") {
|
||||
value += source.slice(cursor, cursor + 2);
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
break;
|
||||
}
|
||||
value += char;
|
||||
cursor += 1;
|
||||
}
|
||||
if (cursor >= source.length) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (value.startsWith(".")) {
|
||||
if (isImportSpecifierContext(source, index)) {
|
||||
specifiers.push(value);
|
||||
}
|
||||
}
|
||||
index = cursor;
|
||||
}
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
export function collectPackageDistImportErrors(params) {
|
||||
const files = [...new Set(params.files.map(normalizePackagePath))];
|
||||
const fileSet = new Set(files);
|
||||
const errors = [];
|
||||
const imports = params.imports ?? collectPackageDistImports({ files, readText: params.readText });
|
||||
|
||||
for (const { importerPath, importedPath } of imports) {
|
||||
if (!fileSet.has(importedPath)) {
|
||||
errors.push(`${importerPath} imports missing ${importedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function collectPackageDistImports(params) {
|
||||
const files = [...new Set(params.files.map(normalizePackagePath))];
|
||||
const imports = [];
|
||||
|
||||
for (const importerPath of files.toSorted((left, right) => left.localeCompare(right))) {
|
||||
if (!JS_DIST_FILE_RE.test(importerPath) || importerPath.includes("/node_modules/")) {
|
||||
continue;
|
||||
}
|
||||
const source = params.readText(importerPath);
|
||||
for (const specifier of collectImportSpecifiers(source)) {
|
||||
const importedPath = resolveDistImportPath(importerPath, specifier);
|
||||
if (!importedPath) {
|
||||
continue;
|
||||
}
|
||||
imports.push({ importerPath, importedPath });
|
||||
}
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
export function expandPackageDistImportClosure(params) {
|
||||
const files = [...new Set(params.files.map(normalizePackagePath))];
|
||||
const fileSet = new Set(files);
|
||||
const expectedSet = new Set(params.seedFiles.map(normalizePackagePath));
|
||||
const importsByImporter = new Map();
|
||||
if (params.imports) {
|
||||
for (const { importerPath, importedPath } of params.imports) {
|
||||
const normalizedImporterPath = normalizePackagePath(importerPath);
|
||||
const importerImports = importsByImporter.get(normalizedImporterPath) ?? [];
|
||||
importerImports.push(normalizePackagePath(importedPath));
|
||||
importsByImporter.set(normalizedImporterPath, importerImports);
|
||||
}
|
||||
}
|
||||
|
||||
const queue = [...expectedSet].filter((file) => fileSet.has(file));
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const importerPath = queue[index];
|
||||
let importedPaths = importsByImporter.get(importerPath);
|
||||
if (!params.imports) {
|
||||
importedPaths = [];
|
||||
if (JS_DIST_FILE_RE.test(importerPath) && !importerPath.includes("/node_modules/")) {
|
||||
const source = params.readText(importerPath);
|
||||
for (const specifier of collectImportSpecifiers(source)) {
|
||||
const importedPath = resolveDistImportPath(importerPath, specifier);
|
||||
if (importedPath) {
|
||||
importedPaths.push(importedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const importedPath of importedPaths ?? []) {
|
||||
if (fileSet.has(importedPath) && !expectedSet.has(importedPath)) {
|
||||
expectedSet.add(importedPath);
|
||||
queue.push(importedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...expectedSet].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export const WORKSPACE_TEMPLATE_PACK_PATHS = [
|
||||
"docs/reference/templates/AGENTS.md",
|
||||
@@ -23,6 +23,47 @@ const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [
|
||||
"BOOTSTRAP.md",
|
||||
];
|
||||
|
||||
export const WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS = 15_000;
|
||||
const SAFE_UNIX_SMOKE_PATH = "/usr/bin:/bin";
|
||||
|
||||
export function createWorkspaceBootstrapSmokeEnv(env, homeDir, overrides = {}) {
|
||||
const allowlistedEnvEntries = [
|
||||
"TMPDIR",
|
||||
"TMP",
|
||||
"TEMP",
|
||||
"SystemRoot",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"WINDIR",
|
||||
];
|
||||
const windowsRoot = env.SystemRoot ?? env.WINDIR ?? "C:\\Windows";
|
||||
const nodeBinDir = dirname(process.execPath);
|
||||
const safePath =
|
||||
process.platform === "win32"
|
||||
? `${nodeBinDir};${windowsRoot}\\System32;${windowsRoot}`
|
||||
: `${nodeBinDir}:${SAFE_UNIX_SMOKE_PATH}`;
|
||||
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
allowlistedEnvEntries.flatMap((key) => {
|
||||
const value = env[key];
|
||||
return typeof value === "string" && value.length > 0 ? [[key, value]] : [];
|
||||
}),
|
||||
),
|
||||
PATH: safePath,
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
OPENCLAW_HOME: homeDir,
|
||||
OPENCLAW_NO_ONBOARD: "1",
|
||||
OPENCLAW_SUPPRESS_NOTES: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
AWS_SHARED_CREDENTIALS_FILE: join(homeDir, ".aws", "credentials"),
|
||||
AWS_CONFIG_FILE: join(homeDir, ".aws", "config"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function collectMissingBootstrapWorkspaceFiles(workspaceDir) {
|
||||
return REQUIRED_BOOTSTRAP_WORKSPACE_FILES.filter(
|
||||
(filename) => !existsSync(join(workspaceDir, filename)),
|
||||
@@ -77,12 +118,8 @@ export function runInstalledWorkspaceBootstrapSmoke(params) {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
OPENCLAW_HOME: homeDir,
|
||||
OPENCLAW_SUPPRESS_NOTES: "1",
|
||||
},
|
||||
timeout: WORKSPACE_BOOTSTRAP_SMOKE_TIMEOUT_MS,
|
||||
env: createWorkspaceBootstrapSmokeEnv(process.env, homeDir),
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -38,7 +39,7 @@ const providerConfig = {
|
||||
extensionId: "openai",
|
||||
secretEnv: "OPENAI_API_KEY",
|
||||
authChoice: "openai-api-key",
|
||||
model: "openai/gpt-5.5",
|
||||
model: "openai/gpt-5.4-mini",
|
||||
},
|
||||
anthropic: {
|
||||
extensionId: "anthropic",
|
||||
@@ -54,6 +55,19 @@ const providerConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE = [
|
||||
"acpx",
|
||||
"bonjour",
|
||||
"browser",
|
||||
"device-pair",
|
||||
"phone-control",
|
||||
"talk-voice",
|
||||
];
|
||||
|
||||
export function buildCrossOsReleaseSmokePluginAllowlist(providerMeta) {
|
||||
return [...new Set([providerMeta.extensionId, ...RELEASE_SMOKE_PLUGIN_ALLOWLIST_BASE])];
|
||||
}
|
||||
|
||||
const PACKAGE_DIST_INVENTORY_RELATIVE_PATH = "dist/postinstall-inventory.json";
|
||||
const OMITTED_QA_EXTENSION_PREFIXES = [
|
||||
"dist/extensions/qa-channel/",
|
||||
@@ -691,18 +705,22 @@ async function runUpgradeLane(params) {
|
||||
"--timeout",
|
||||
String(updateStepTimeoutSeconds()),
|
||||
];
|
||||
await runOpenClaw({
|
||||
const updateResult = await runOpenClaw({
|
||||
lane,
|
||||
env: updateEnv,
|
||||
args: updateArgs,
|
||||
logPath: join(params.logsDir, "upgrade-update.log"),
|
||||
timeoutMs: updateTimeoutMs(),
|
||||
check: false,
|
||||
});
|
||||
verifyPackagedUpgradeUpdateResult(updateResult, {
|
||||
candidateVersion: params.build.candidateVersion,
|
||||
});
|
||||
|
||||
logLanePhase(lane, "update-status");
|
||||
await runOpenClaw({
|
||||
lane,
|
||||
env,
|
||||
env: updateEnv,
|
||||
args: ["update", "status", "--json"],
|
||||
logPath: join(params.logsDir, "upgrade-update-status.log"),
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
@@ -1218,11 +1236,55 @@ export function shouldSkipInstallerDaemonHealthCheck(platform = process.platform
|
||||
}
|
||||
|
||||
export function buildRealUpdateEnv(env) {
|
||||
const updateEnv = { ...env };
|
||||
const updateEnv = {
|
||||
...env,
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
};
|
||||
delete updateEnv.OPENCLAW_DISABLE_BUNDLED_PLUGIN_POSTINSTALL;
|
||||
delete updateEnv.NODE_COMPILE_CACHE;
|
||||
return updateEnv;
|
||||
}
|
||||
|
||||
export function verifyPackagedUpgradeUpdateResult(result, options) {
|
||||
if (result.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
const steps = Array.isArray(payload?.steps) ? payload.steps : [];
|
||||
const allStepsSucceeded = steps.every((step) => step?.exitCode === 0);
|
||||
const afterVersion = typeof payload?.after?.version === "string" ? payload.after.version : "";
|
||||
if (
|
||||
payload?.status === "ok" &&
|
||||
afterVersion === options.candidateVersion &&
|
||||
allStepsSucceeded &&
|
||||
isSelfSwappedPackageProcessExit(result.stderr)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Packaged upgrade failed (${result.exitCode}): ${trimForSummary(
|
||||
`${result.stdout}\n${result.stderr}`,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isSelfSwappedPackageProcessExit(stderr) {
|
||||
return (
|
||||
typeof stderr === "string" &&
|
||||
stderr.includes("[openclaw] Failed to start CLI:") &&
|
||||
stderr.includes("ERR_MODULE_NOT_FOUND") &&
|
||||
/[\\/]node_modules[\\/]openclaw[\\/]dist[\\/]/u.test(stderr)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveExplicitBaselineVersion(baselineSpec) {
|
||||
const trimmed = baselineSpec.trim();
|
||||
if (!trimmed || trimmed === "openclaw@latest") {
|
||||
@@ -1302,7 +1364,9 @@ export function resolveInstalledPrefixDirFromCliPath(cliPath, platform = process
|
||||
}
|
||||
|
||||
function readInstalledMetadataFromCliPath(cliPath, platform = process.platform) {
|
||||
return readInstalledMetadata(resolveInstalledPrefixDirFromCliPath(cliPath, platform));
|
||||
return readInstalledMetadataFromPackageRoot(
|
||||
resolveInstalledPackageRootFromCliPath(cliPath, platform),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstalledCliInvocation(cliPath, platform = process.platform) {
|
||||
@@ -1793,6 +1857,20 @@ async function runInstalledModelsSet(params) {
|
||||
logPath: params.logPath,
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
});
|
||||
await runInstalledCli({
|
||||
cliPath: params.cliPath,
|
||||
args: [
|
||||
"config",
|
||||
"set",
|
||||
"plugins.allow",
|
||||
JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)),
|
||||
"--strict-json",
|
||||
],
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
logPath: params.logPath,
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
});
|
||||
await runInstalledCli({
|
||||
cliPath: params.cliPath,
|
||||
args: ["config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json"],
|
||||
@@ -1815,6 +1893,8 @@ async function runInstalledAgentTurn(params) {
|
||||
sessionId,
|
||||
"--message",
|
||||
"Reply with exact ASCII text OK only.",
|
||||
"--thinking",
|
||||
"minimal",
|
||||
"--json",
|
||||
],
|
||||
cwd: params.cwd,
|
||||
@@ -2547,6 +2627,19 @@ async function runModelsSet(params) {
|
||||
logPath: params.logPath,
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
});
|
||||
await runOpenClaw({
|
||||
lane: params.lane,
|
||||
env: params.env,
|
||||
args: [
|
||||
"config",
|
||||
"set",
|
||||
"plugins.allow",
|
||||
JSON.stringify(buildCrossOsReleaseSmokePluginAllowlist(params.providerConfig)),
|
||||
"--strict-json",
|
||||
],
|
||||
logPath: params.logPath,
|
||||
timeoutMs: 2 * 60 * 1000,
|
||||
});
|
||||
await runOpenClaw({
|
||||
lane: params.lane,
|
||||
env: params.env,
|
||||
@@ -2572,6 +2665,8 @@ async function runAgentTurn(params) {
|
||||
sessionId,
|
||||
"--message",
|
||||
"Reply with exact ASCII text OK only.",
|
||||
"--thinking",
|
||||
"minimal",
|
||||
"--json",
|
||||
],
|
||||
logPath: params.logPath,
|
||||
@@ -2781,6 +2876,10 @@ async function runOpenClaw(params) {
|
||||
|
||||
function readInstalledPackageManifest(prefixDir) {
|
||||
const packageRoot = installedPackageRoot(prefixDir);
|
||||
return readInstalledPackageManifestFromPackageRoot(packageRoot);
|
||||
}
|
||||
|
||||
function readInstalledPackageManifestFromPackageRoot(packageRoot) {
|
||||
const packageJsonPath = join(packageRoot, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
throw new Error(`Installed package manifest missing: ${packageJsonPath}`);
|
||||
@@ -2798,6 +2897,15 @@ export function readInstalledVersion(prefixDir) {
|
||||
|
||||
function readInstalledMetadata(prefixDir) {
|
||||
const { packageJson, packageRoot } = readInstalledPackageManifest(prefixDir);
|
||||
return readInstalledMetadataFromManifest(packageJson, packageRoot);
|
||||
}
|
||||
|
||||
function readInstalledMetadataFromPackageRoot(packageRoot) {
|
||||
const { packageJson } = readInstalledPackageManifestFromPackageRoot(packageRoot);
|
||||
return readInstalledMetadataFromManifest(packageJson, packageRoot);
|
||||
}
|
||||
|
||||
function readInstalledMetadataFromManifest(packageJson, packageRoot) {
|
||||
const buildInfoPath = join(packageRoot, "dist", "build-info.json");
|
||||
if (!existsSync(buildInfoPath)) {
|
||||
throw new Error(`Installed build info missing: ${buildInfoPath}`);
|
||||
@@ -2824,8 +2932,55 @@ function verifyInstalledCandidate(installed, build) {
|
||||
}
|
||||
}
|
||||
|
||||
function installedPackageRoot(prefixDir) {
|
||||
return process.platform === "win32"
|
||||
export function resolveInstalledPackageRootFromCliPath(
|
||||
cliPath,
|
||||
platform = process.platform,
|
||||
env = process.env,
|
||||
) {
|
||||
const prefixDir = resolveInstalledPrefixDirFromCliPath(cliPath, platform);
|
||||
const candidates = [installedPackageRoot(prefixDir, platform)];
|
||||
|
||||
if (platform !== "win32") {
|
||||
const resolvedCliPath = String(cliPath ?? "").trim();
|
||||
if (resolvedCliPath) {
|
||||
try {
|
||||
const realCliPath = realpathSync(resolvedCliPath);
|
||||
candidates.push(dirname(realCliPath));
|
||||
candidates.push(dirname(dirname(realCliPath)));
|
||||
} catch {
|
||||
// Some installer shims are shell wrappers, not symlinks. Fall through to
|
||||
// common user-local npm prefixes below.
|
||||
}
|
||||
}
|
||||
|
||||
for (const prefix of [
|
||||
env.NPM_CONFIG_PREFIX,
|
||||
env.npm_config_prefix,
|
||||
env.HOME && join(env.HOME, ".npm-global"),
|
||||
env.HOME && join(env.HOME, ".local"),
|
||||
]) {
|
||||
if (typeof prefix === "string" && prefix.trim()) {
|
||||
candidates.push(installedPackageRoot(prefix, platform));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checked: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || checked.includes(candidate)) {
|
||||
continue;
|
||||
}
|
||||
checked.push(candidate);
|
||||
if (existsSync(join(candidate, "package.json"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Installed package manifest missing. Checked: ${checked.join(", ")}`);
|
||||
}
|
||||
|
||||
function installedPackageRoot(prefixDir, platform = process.platform) {
|
||||
return platform === "win32"
|
||||
? join(prefixDir, "node_modules", "openclaw")
|
||||
: join(prefixDir, "lib", "node_modules", "openclaw");
|
||||
}
|
||||
|
||||
@@ -39,16 +39,33 @@ function parseArgs(argv) {
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, cwd) {
|
||||
function run(command, args, cwd, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let timedOut = false;
|
||||
const timeout =
|
||||
options.timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
|
||||
}, options.timeoutMs);
|
||||
timeout?.unref?.();
|
||||
child.stdout.pipe(process.stderr, { end: false });
|
||||
child.stderr.pipe(process.stderr, { end: false });
|
||||
child.on("error", reject);
|
||||
child.on("close", (status, signal) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
|
||||
return;
|
||||
}
|
||||
if (status === 0) {
|
||||
resolve();
|
||||
return;
|
||||
@@ -115,6 +132,8 @@ async function main() {
|
||||
if (!options.skipBuild) {
|
||||
console.error("==> Building OpenClaw package artifacts");
|
||||
await run("pnpm", ["build"], sourceDir);
|
||||
console.error("==> Building OpenClaw Control UI assets");
|
||||
await run("pnpm", ["ui:build"], sourceDir);
|
||||
}
|
||||
|
||||
console.error("==> Writing OpenClaw package inventory");
|
||||
@@ -148,10 +167,15 @@ async function main() {
|
||||
}
|
||||
|
||||
console.error("==> Checking OpenClaw package tarball");
|
||||
const checkStartedAt = Date.now();
|
||||
await run(
|
||||
"node",
|
||||
[path.join(ROOT_DIR, "scripts/check-openclaw-package-tarball.mjs"), tarball],
|
||||
sourceDir,
|
||||
{ timeoutMs: 5 * 60 * 1000 },
|
||||
);
|
||||
console.error(
|
||||
`==> OpenClaw package tarball check finished in ${Math.round((Date.now() - checkStartedAt) / 1000)}s`,
|
||||
);
|
||||
|
||||
process.stdout.write(`${tarball}\n`);
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { expandPackageDistImportClosure } from "./lib/package-dist-imports.mjs";
|
||||
import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
|
||||
export const BUNDLED_PLUGIN_INSTALL_TARGETS = [];
|
||||
@@ -292,6 +294,16 @@ export function pruneInstalledPackageDist(params = {}) {
|
||||
}
|
||||
}
|
||||
const installedFiles = listInstalledDistFiles(params);
|
||||
const readFile = params.readFileSync ?? readFileSync;
|
||||
expectedFiles = new Set(
|
||||
expandPackageDistImportClosure({
|
||||
files: installedFiles,
|
||||
seedFiles: [...expectedFiles],
|
||||
readText(relativePath) {
|
||||
return readFile(join(packageRoot, relativePath), "utf8");
|
||||
},
|
||||
}),
|
||||
);
|
||||
const removed = [];
|
||||
|
||||
for (const relativePath of installedFiles) {
|
||||
@@ -716,6 +728,29 @@ function shouldRunBundledPluginPostinstall(params) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pruneOpenClawCompileCache(params = {}) {
|
||||
const env = params.env ?? process.env;
|
||||
const pathExists = params.existsSync ?? existsSync;
|
||||
const remove = params.rmSync ?? rmSync;
|
||||
const log = params.log ?? console;
|
||||
const baseDirs = [
|
||||
env.NODE_DISABLE_COMPILE_CACHE ? "" : env.NODE_COMPILE_CACHE,
|
||||
join(tmpdir(), "node-compile-cache"),
|
||||
].filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
|
||||
for (const baseDir of baseDirs) {
|
||||
const cacheRoot = join(baseDir, "openclaw");
|
||||
if (!pathExists(cacheRoot)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
remove(cacheRoot, { recursive: true, force: true, maxRetries: 2, retryDelay: 100 });
|
||||
} catch (error) {
|
||||
log.warn?.(`[postinstall] could not prune OpenClaw compile cache: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runBundledPluginPostinstall(params = {}) {
|
||||
const env = params.env ?? process.env;
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
@@ -726,6 +761,12 @@ export function runBundledPluginPostinstall(params = {}) {
|
||||
if (env?.[DISABLE_POSTINSTALL_ENV]?.trim()) {
|
||||
return;
|
||||
}
|
||||
pruneOpenClawCompileCache({
|
||||
env,
|
||||
existsSync: pathExists,
|
||||
rmSync: params.rmSync,
|
||||
log,
|
||||
});
|
||||
if (isSourceCheckoutRoot({ packageRoot, existsSync: pathExists })) {
|
||||
try {
|
||||
pruneBundledPluginSourceNodeModules({
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { COMPLETION_SKIP_PLUGIN_COMMANDS_ENV } from "../src/cli/completion-runtime.ts";
|
||||
import {
|
||||
isBundledRuntimeDepsInstallStagePath,
|
||||
LOCAL_BUILD_METADATA_DIST_PATHS,
|
||||
@@ -69,6 +70,7 @@ const requiredPathGroups = [
|
||||
...WORKSPACE_TEMPLATE_PACK_PATHS,
|
||||
"scripts/npm-runner.mjs",
|
||||
"scripts/preinstall-package-manager-warning.mjs",
|
||||
"scripts/lib/package-dist-imports.mjs",
|
||||
"scripts/postinstall-bundled-plugins.mjs",
|
||||
"dist/plugin-sdk/compat.js",
|
||||
"dist/plugin-sdk/root-alias.cjs",
|
||||
@@ -120,6 +122,12 @@ export const PACKED_CLI_SMOKE_COMMANDS = [
|
||||
["config", "schema"],
|
||||
["models", "list", "--provider", "amazon-bedrock"],
|
||||
] as const;
|
||||
export const PACKED_COMPLETION_SMOKE_ARGS = [
|
||||
"completion",
|
||||
"--write-state",
|
||||
"--shell",
|
||||
"zsh",
|
||||
] as const;
|
||||
|
||||
function collectBundledExtensions(): BundledExtension[] {
|
||||
const extensionsDir = resolve("extensions");
|
||||
@@ -298,6 +306,19 @@ export function createPackedCliSmokeEnv(
|
||||
};
|
||||
}
|
||||
|
||||
export function createPackedCompletionSmokeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
overrides: NodeJS.ProcessEnv = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
...overrides,
|
||||
OPENCLAW_SUPPRESS_NOTES: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
|
||||
[COMPLETION_SKIP_PLUGIN_COMMANDS_ENV]: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function runPackedBundledPluginPostinstall(packageRoot: string): void {
|
||||
execFileSync(process.execPath, [join(packageRoot, "scripts/postinstall-bundled-plugins.mjs")], {
|
||||
cwd: packageRoot,
|
||||
@@ -482,20 +503,25 @@ function runPackedTaskRegistryControlRuntimeSmoke(packageRoot: string): void {
|
||||
if (!existsSync(runtimePath)) {
|
||||
throw new Error("release-check: packed task-registry control runtime is missing.");
|
||||
}
|
||||
const source = `
|
||||
const runtime = await import(${JSON.stringify(pathToFileURL(runtimePath).href)});
|
||||
if (typeof runtime.getAcpSessionManager !== "function") {
|
||||
throw new Error("missing getAcpSessionManager export");
|
||||
}
|
||||
if (typeof runtime.killSubagentRunAdmin !== "function") {
|
||||
throw new Error("missing killSubagentRunAdmin export");
|
||||
}
|
||||
`;
|
||||
execFileSync(process.execPath, ["--input-type=module", "--eval", source], {
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
env: createPackedCliSmokeEnv(process.env),
|
||||
});
|
||||
const source = [
|
||||
'const dynamicImport = Function("specifier", "return im" + "port(specifier)");',
|
||||
"const runtime = await dynamicImport(process.argv[1]);",
|
||||
'if (typeof runtime.getAcpSessionManager !== "function") {',
|
||||
' throw new Error("missing getAcpSessionManager export");',
|
||||
"}",
|
||||
'if (typeof runtime.killSubagentRunAdmin !== "function") {',
|
||||
' throw new Error("missing killSubagentRunAdmin export");',
|
||||
"}",
|
||||
].join("\n");
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
["--input-type=module", "--eval", source, pathToFileURL(runtimePath).href],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
env: createPackedCliSmokeEnv(process.env),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function runPackedCliSmoke(params: {
|
||||
@@ -579,17 +605,14 @@ function runPackedBundledChannelEntrySmoke(): void {
|
||||
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(packageRoot, "openclaw.mjs"), "completion", "--write-state"],
|
||||
[join(packageRoot, "openclaw.mjs"), ...PACKED_COMPLETION_SMOKE_ARGS],
|
||||
{
|
||||
cwd: packageRoot,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
env: createPackedCompletionSmokeEnv(process.env, {
|
||||
HOME: homeDir,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
OPENCLAW_SUPPRESS_NOTES: "1",
|
||||
OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1",
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -93,6 +93,16 @@ function run(command, args, options = {}) {
|
||||
cwd: options.cwd ?? ROOT_DIR,
|
||||
stdio: options.capture ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
||||
});
|
||||
let timedOut = false;
|
||||
const timeout =
|
||||
options.timeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => child.kill("SIGKILL"), 5_000).unref?.();
|
||||
}, options.timeoutMs);
|
||||
timeout?.unref?.();
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
if (options.capture) {
|
||||
@@ -105,6 +115,13 @@ function run(command, args, options = {}) {
|
||||
}
|
||||
child.on("error", reject);
|
||||
child.on("close", (status, signal) => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
if (timedOut) {
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`));
|
||||
return;
|
||||
}
|
||||
if (status === 0) {
|
||||
resolve(stdout);
|
||||
return;
|
||||
@@ -406,7 +423,14 @@ async function resolveCandidate(options) {
|
||||
}
|
||||
|
||||
const digest = await assertExpectedSha256(target, options.packageSha256);
|
||||
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target]);
|
||||
console.error(`Checking OpenClaw package tarball: ${target}`);
|
||||
const checkStartedAt = Date.now();
|
||||
await run("node", ["scripts/check-openclaw-package-tarball.mjs", target], {
|
||||
timeoutMs: 5 * 60 * 1000,
|
||||
});
|
||||
console.error(
|
||||
`OpenClaw package tarball check finished in ${Math.round((Date.now() - checkStartedAt) / 1000)}s`,
|
||||
);
|
||||
const pkg = await readPackageJson(target);
|
||||
const metadata = {
|
||||
name: pkg.name,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { copyBundledPluginMetadata } from "./copy-bundled-plugin-metadata.mjs";
|
||||
import { copyPluginSdkRootAlias } from "./copy-plugin-sdk-root-alias.mjs";
|
||||
@@ -10,6 +11,12 @@ import { writeOfficialChannelCatalog } from "./write-official-channel-catalog.mj
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const ROOT_RUNTIME_ALIAS_PATTERN = /^(?<base>.+\.(?:runtime|contract))-[A-Za-z0-9_-]+\.js$/u;
|
||||
export const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
|
||||
{
|
||||
dest: "dist/memory-state-CcqRgDZU.js",
|
||||
contents: "export function hasMemoryRuntime() {\n return false;\n}\n",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Copy static (non-transpiled) runtime assets that are referenced by their
|
||||
@@ -25,6 +32,14 @@ export const STATIC_EXTENSION_ASSETS = [
|
||||
src: "extensions/acpx/src/runtime-internals/mcp-proxy.mjs",
|
||||
dest: "dist/extensions/acpx/mcp-proxy.mjs",
|
||||
},
|
||||
{
|
||||
src: "extensions/acpx/src/runtime-internals/error-format.mjs",
|
||||
dest: "dist/extensions/acpx/error-format.mjs",
|
||||
},
|
||||
{
|
||||
src: "extensions/acpx/src/runtime-internals/mcp-command-line.mjs",
|
||||
dest: "dist/extensions/acpx/mcp-command-line.mjs",
|
||||
},
|
||||
// diffs viewer runtime bundle — co-deployed inside the plugin package so the
|
||||
// built bundle can resolve `./assets/viewer-runtime.js` from dist.
|
||||
{
|
||||
@@ -81,14 +96,35 @@ export function writeStableRootRuntimeAliases(params = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLegacyCliExitCompatChunks(params = {}) {
|
||||
const rootDir = params.rootDir ?? ROOT;
|
||||
const chunks = params.chunks ?? LEGACY_CLI_EXIT_COMPAT_CHUNKS;
|
||||
for (const { dest, contents } of chunks) {
|
||||
writeTextFileIfChanged(path.join(rootDir, dest), contents);
|
||||
}
|
||||
}
|
||||
|
||||
export function runRuntimePostBuild(params = {}) {
|
||||
copyPluginSdkRootAlias(params);
|
||||
copyBundledPluginMetadata(params);
|
||||
writeOfficialChannelCatalog(params);
|
||||
stageBundledPluginRuntimeDeps(params);
|
||||
stageBundledPluginRuntime(params);
|
||||
writeStableRootRuntimeAliases(params);
|
||||
copyStaticExtensionAssets(params);
|
||||
const timingsEnabled = params.timings ?? process.env.OPENCLAW_RUNTIME_POSTBUILD_TIMINGS !== "0";
|
||||
const runPhase = (label, action) => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
if (timingsEnabled) {
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
console.error(`runtime-postbuild: ${label} completed in ${durationMs}ms`);
|
||||
}
|
||||
}
|
||||
};
|
||||
runPhase("plugin SDK root alias", () => copyPluginSdkRootAlias(params));
|
||||
runPhase("bundled plugin metadata", () => copyBundledPluginMetadata(params));
|
||||
runPhase("official channel catalog", () => writeOfficialChannelCatalog(params));
|
||||
runPhase("bundled plugin runtime deps", () => stageBundledPluginRuntimeDeps(params));
|
||||
runPhase("bundled plugin runtime overlay", () => stageBundledPluginRuntime(params));
|
||||
runPhase("stable root runtime aliases", () => writeStableRootRuntimeAliases(params));
|
||||
runPhase("legacy CLI exit compat chunks", () => writeLegacyCliExitCompatChunks(params));
|
||||
runPhase("static extension assets", () => copyStaticExtensionAssets(params));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import semverSatisfies from "semver/functions/satisfies.js";
|
||||
import { resolveNpmRunner } from "./npm-runner.mjs";
|
||||
@@ -499,7 +500,7 @@ function createInstalledRuntimeClosureFingerprint(rootNodeModulesDir, dependency
|
||||
if (depRoot === null || !fs.existsSync(depRoot)) {
|
||||
return null;
|
||||
}
|
||||
hash.update(`package:${depName}\n`);
|
||||
hash.update(`package:${depName}:${fs.realpathSync(depRoot)}\n`);
|
||||
appendDirectoryFingerprint(hash, depRoot);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
@@ -1222,69 +1223,102 @@ export function stageBundledPluginRuntimeDeps(params = {}) {
|
||||
params.installPluginRuntimeDepsImpl ?? installPluginRuntimeDeps;
|
||||
const installAttempts = params.installAttempts ?? 3;
|
||||
const pruneConfig = resolveRuntimeDepPruneConfig(params);
|
||||
const timingsEnabled =
|
||||
params.timings ?? process.env.OPENCLAW_RUNTIME_DEPS_STAGING_TIMINGS === "1";
|
||||
const runPluginPhase = (pluginId, label, action) => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
return action();
|
||||
} finally {
|
||||
if (timingsEnabled) {
|
||||
const durationMs = Math.round(performance.now() - startedAt);
|
||||
console.error(
|
||||
`stage-bundled-plugin-runtime-deps: ${pluginId} ${label} completed in ${durationMs}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const pluginDir of listBundledPluginRuntimeDirs(repoRoot)) {
|
||||
const pluginId = path.basename(pluginDir);
|
||||
const sourcePluginRoot = resolveInstalledWorkspacePluginRoot(repoRoot, pluginId);
|
||||
const directDependencyPackageRoot = fs.existsSync(path.join(sourcePluginRoot, "package.json"))
|
||||
? sourcePluginRoot
|
||||
: null;
|
||||
const packageJson = sanitizeBundledManifestForRuntimeInstall(pluginDir);
|
||||
const packageJson = runPluginPhase(pluginId, "sanitize manifest", () =>
|
||||
sanitizeBundledManifestForRuntimeInstall(pluginDir),
|
||||
);
|
||||
const nodeModulesDir = path.join(pluginDir, "node_modules");
|
||||
const stampPath = resolveRuntimeDepsStampPath(repoRoot, pluginId);
|
||||
const legacyStampPath = resolveLegacyRuntimeDepsStampPath(pluginDir);
|
||||
removePathIfExists(legacyStampPath);
|
||||
removeStaleRuntimeDepsTempDirs(pluginDir);
|
||||
runPluginPhase(pluginId, "cleanup stale runtime dirs", () => {
|
||||
removePathIfExists(legacyStampPath);
|
||||
removeStaleRuntimeDepsTempDirs(pluginDir);
|
||||
});
|
||||
if (!hasRuntimeDeps(packageJson) || !shouldStageRuntimeDeps(packageJson)) {
|
||||
removePathIfExists(nodeModulesDir);
|
||||
removePathIfExists(stampPath);
|
||||
runPluginPhase(pluginId, "remove unstaged runtime deps", () => {
|
||||
removePathIfExists(nodeModulesDir);
|
||||
removePathIfExists(stampPath);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const cheapFingerprint = createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, {
|
||||
repoRoot,
|
||||
});
|
||||
const cheapFingerprint = runPluginPhase(pluginId, "cheap fingerprint", () =>
|
||||
createRuntimeDepsCheapFingerprint(packageJson, pruneConfig, {
|
||||
repoRoot,
|
||||
}),
|
||||
);
|
||||
const stamp = readRuntimeDepsStamp(stampPath);
|
||||
const rootInstalledRuntimeFingerprint = resolveInstalledRuntimeClosureFingerprint({
|
||||
directDependencyPackageRoot,
|
||||
packageJson,
|
||||
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
|
||||
});
|
||||
const rootInstalledRuntimeFingerprint = runPluginPhase(
|
||||
pluginId,
|
||||
"installed runtime fingerprint",
|
||||
() =>
|
||||
resolveInstalledRuntimeClosureFingerprint({
|
||||
directDependencyPackageRoot,
|
||||
packageJson,
|
||||
rootNodeModulesDir: path.join(repoRoot, "node_modules"),
|
||||
}),
|
||||
);
|
||||
const fingerprint = createRuntimeDepsFingerprint(packageJson, pruneConfig, {
|
||||
repoRoot,
|
||||
rootInstalledRuntimeFingerprint,
|
||||
});
|
||||
if (fs.existsSync(nodeModulesDir) && stamp?.fingerprint === fingerprint) {
|
||||
runPluginPhase(pluginId, "reuse staged runtime deps", () => {});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
stageInstalledRootRuntimeDeps({
|
||||
directDependencyPackageRoot,
|
||||
fingerprint,
|
||||
cheapFingerprint,
|
||||
packageJson,
|
||||
pluginDir,
|
||||
pruneConfig,
|
||||
repoRoot,
|
||||
stampPath,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
installPluginRuntimeDepsWithRetries({
|
||||
attempts: installAttempts,
|
||||
install: installPluginRuntimeDepsImpl,
|
||||
installParams: {
|
||||
runPluginPhase(pluginId, "stage installed root runtime deps", () =>
|
||||
stageInstalledRootRuntimeDeps({
|
||||
directDependencyPackageRoot,
|
||||
fingerprint,
|
||||
cheapFingerprint,
|
||||
packageJson,
|
||||
pluginDir,
|
||||
pluginId,
|
||||
pruneConfig,
|
||||
repoRoot,
|
||||
stampPath,
|
||||
},
|
||||
});
|
||||
}),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
runPluginPhase(pluginId, "fallback install runtime deps", () =>
|
||||
installPluginRuntimeDepsWithRetries({
|
||||
attempts: installAttempts,
|
||||
install: installPluginRuntimeDepsImpl,
|
||||
installParams: {
|
||||
directDependencyPackageRoot,
|
||||
fingerprint,
|
||||
cheapFingerprint,
|
||||
packageJson,
|
||||
pluginDir,
|
||||
pluginId,
|
||||
pruneConfig,
|
||||
repoRoot,
|
||||
stampPath,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
throw createRootRuntimeStagingError({ packageJson, pluginId, cause: error });
|
||||
}
|
||||
|
||||
@@ -430,11 +430,12 @@ async function runBasicAgentCommand() {
|
||||
}
|
||||
|
||||
function expectFallbackOverrideCalls(first: boolean, second: boolean) {
|
||||
expect(state.resolveEffectiveModelFallbacksMock).toHaveBeenCalledTimes(2);
|
||||
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[0][0]).toMatchObject({
|
||||
const calls = state.resolveEffectiveModelFallbacksMock.mock.calls.slice(-2);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[0]?.[0]).toMatchObject({
|
||||
hasSessionModelOverride: first,
|
||||
});
|
||||
expect(state.resolveEffectiveModelFallbacksMock.mock.calls[1][0]).toMatchObject({
|
||||
expect(calls[1]?.[0]).toMatchObject({
|
||||
hasSessionModelOverride: second,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { CliDeps } from "../cli/deps.types.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
clearAgentRunContext,
|
||||
emitAgentEvent,
|
||||
@@ -251,6 +252,50 @@ function normalizeExplicitOverrideInput(raw: string, kind: "provider" | "model")
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveModelCatalogProviderScope(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
defaultProvider: string;
|
||||
defaultModel: string;
|
||||
hasStoredOverride: boolean;
|
||||
storedModelOverrideSource?: "auto" | "user";
|
||||
explicitProviderOverride?: string;
|
||||
explicitModelOverride?: string;
|
||||
}): string[] {
|
||||
const providers = new Set<string>();
|
||||
const addProvider = (provider: string | undefined) => {
|
||||
const normalized = provider?.trim();
|
||||
if (normalized) {
|
||||
providers.add(normalized);
|
||||
}
|
||||
};
|
||||
const addModelRef = (raw: string | undefined, defaultProvider: string) => {
|
||||
const parsed = raw ? parseModelRef(raw, defaultProvider) : null;
|
||||
addProvider(parsed?.provider);
|
||||
};
|
||||
|
||||
addProvider(params.defaultProvider);
|
||||
addModelRef(params.defaultModel, params.defaultProvider);
|
||||
addProvider(params.explicitProviderOverride);
|
||||
addModelRef(
|
||||
params.explicitModelOverride,
|
||||
params.explicitProviderOverride ?? params.defaultProvider,
|
||||
);
|
||||
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||
addModelRef(raw, params.defaultProvider);
|
||||
}
|
||||
for (const raw of resolveEffectiveModelFallbacks({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
hasSessionModelOverride: params.hasStoredOverride,
|
||||
modelOverrideSource: params.storedModelOverrideSource,
|
||||
}) ?? []) {
|
||||
addModelRef(raw, params.defaultProvider);
|
||||
}
|
||||
|
||||
return [...providers].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
async function prepareAgentCommandExecution(
|
||||
opts: AgentCommandOpts & { senderIsOwner: boolean },
|
||||
runtime: RuntimeEnv,
|
||||
@@ -723,7 +768,19 @@ async function agentCommandInternal(
|
||||
let allowAnyModel = !hasAllowlist;
|
||||
|
||||
if (needsModelCatalog) {
|
||||
modelCatalog = await loadModelCatalog({ config: cfg });
|
||||
modelCatalog = await loadModelCatalog({
|
||||
config: cfg,
|
||||
providerDiscoveryProviderIds: resolveModelCatalogProviderScope({
|
||||
cfg,
|
||||
agentId: sessionAgentId,
|
||||
defaultProvider,
|
||||
defaultModel,
|
||||
hasStoredOverride,
|
||||
storedModelOverrideSource,
|
||||
explicitProviderOverride,
|
||||
explicitModelOverride,
|
||||
}),
|
||||
});
|
||||
const allowed = buildAllowedModelSet({
|
||||
cfg,
|
||||
catalog: modelCatalog,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeFakeClaudeCli,
|
||||
writeFakeClaudeLiveCli,
|
||||
} from "./bundle-mcp.test-harness.js";
|
||||
import { __testing as cliBackendsTesting } from "./cli-backends.js";
|
||||
|
||||
vi.mock("./cli-runner/helpers.js", async () => {
|
||||
const original =
|
||||
@@ -28,6 +29,43 @@ vi.mock("./cli-runner/helpers.js", async () => {
|
||||
// also reaches this test after several gateway/plugin restart exercises.
|
||||
const E2E_TIMEOUT_MS = 90_000;
|
||||
|
||||
function installTestClaudeBackend(params: { commandPath: string; liveSession?: "claude-stdio" }) {
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolveRuntimeCliBackends: () => [],
|
||||
resolvePluginSetupCliBackend: ({ backend }) =>
|
||||
backend === "claude-cli"
|
||||
? {
|
||||
pluginId: "anthropic",
|
||||
backend: {
|
||||
id: "claude-cli",
|
||||
bundleMcp: true,
|
||||
bundleMcpMode: "claude-config-file",
|
||||
config: {
|
||||
command: "node",
|
||||
args: [params.commandPath],
|
||||
input: "stdin",
|
||||
output: "jsonl",
|
||||
clearEnv: [],
|
||||
...(params.liveSession ? { liveSession: params.liveSession } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function resetBundleMcpPluginState() {
|
||||
const { resetPluginLoaderTestStateForTest } = await import("../plugins/loader.test-fixtures.js");
|
||||
const { clearPluginSetupRegistryCache } = await import("../plugins/setup-registry.js");
|
||||
resetPluginLoaderTestStateForTest();
|
||||
clearPluginSetupRegistryCache();
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
await resetBundleMcpPluginState();
|
||||
});
|
||||
|
||||
describe("runCliAgent bundle MCP e2e", () => {
|
||||
it(
|
||||
"routes enabled bundle MCP config into the claude-cli backend and executes the tool",
|
||||
@@ -35,9 +73,20 @@ describe("runCliAgent bundle MCP e2e", () => {
|
||||
async () => {
|
||||
const { runCliAgent } = await import("./cli-runner.js");
|
||||
const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js");
|
||||
const envSnapshot = captureEnv(["HOME"]);
|
||||
await resetBundleMcpPluginState();
|
||||
const envSnapshot = captureEnv([
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY",
|
||||
]);
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-bundle-mcp-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1";
|
||||
resetGlobalHookRunner();
|
||||
|
||||
const workspaceDir = path.join(tempHome, "workspace");
|
||||
@@ -50,21 +99,16 @@ describe("runCliAgent bundle MCP e2e", () => {
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
await writeFakeClaudeCli(fakeClaudePath);
|
||||
await writeClaudeBundle({ pluginRoot, serverScriptPath });
|
||||
installTestClaudeBackend({ commandPath: fakeClaudePath });
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
cliBackends: {
|
||||
"claude-cli": {
|
||||
command: "node",
|
||||
args: [fakeClaudePath],
|
||||
clearEnv: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginRoot] },
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
@@ -102,9 +146,20 @@ describe("runCliAgent bundle MCP e2e", () => {
|
||||
const { closeMcpLoopbackServer, getActiveMcpLoopbackRuntime } =
|
||||
await import("../gateway/mcp-http.js");
|
||||
const { resetGlobalHookRunner } = await import("../plugins/hook-runner-global.js");
|
||||
const envSnapshot = captureEnv(["HOME"]);
|
||||
await resetBundleMcpPluginState();
|
||||
const envSnapshot = captureEnv([
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY",
|
||||
]);
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-live-cleanup-"));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
delete process.env.OPENCLAW_HOME;
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY = "1";
|
||||
resetGlobalHookRunner();
|
||||
await closeMcpLoopbackServer();
|
||||
|
||||
@@ -119,22 +174,16 @@ describe("runCliAgent bundle MCP e2e", () => {
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
await writeFakeClaudeLiveCli({ filePath: fakeClaudePath, pidPath: fakeClaudePidPath });
|
||||
await writeClaudeBundle({ pluginRoot, serverScriptPath });
|
||||
installTestClaudeBackend({ commandPath: fakeClaudePath, liveSession: "claude-stdio" });
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
cliBackends: {
|
||||
"claude-cli": {
|
||||
command: "node",
|
||||
args: [fakeClaudePath],
|
||||
clearEnv: [],
|
||||
liveSession: "claude-stdio",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [pluginRoot] },
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ const CONTEXT_WINDOW_RUNTIME_STATE_KEY = Symbol.for("openclaw.contextWindowRunti
|
||||
|
||||
type ContextWindowRuntimeState = {
|
||||
loadPromise: Promise<void> | null;
|
||||
loadPromises: Map<string, Promise<void>>;
|
||||
configuredConfig: OpenClawConfig | undefined;
|
||||
configLoadFailures: number;
|
||||
nextConfigLoadAttemptAtMs: number;
|
||||
@@ -18,6 +19,7 @@ export const CONTEXT_WINDOW_RUNTIME_STATE = (() => {
|
||||
if (!globalState[CONTEXT_WINDOW_RUNTIME_STATE_KEY]) {
|
||||
globalState[CONTEXT_WINDOW_RUNTIME_STATE_KEY] = {
|
||||
loadPromise: null,
|
||||
loadPromises: new Map(),
|
||||
configuredConfig: undefined,
|
||||
configLoadFailures: 0,
|
||||
nextConfigLoadAttemptAtMs: 0,
|
||||
@@ -29,6 +31,7 @@ export const CONTEXT_WINDOW_RUNTIME_STATE = (() => {
|
||||
|
||||
export function resetContextWindowCacheForTest(): void {
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = null;
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.clear();
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.configuredConfig = undefined;
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.configLoadFailures = 0;
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.nextConfigLoadAttemptAtMs = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
type DiscoveredModel = { id: string; contextWindow?: number; contextTokens?: number };
|
||||
type ContextModule = typeof import("./context.js");
|
||||
@@ -165,6 +166,34 @@ describe("lookupContextTokens", () => {
|
||||
expect(lookupContextTokens("gpt-5.4", { allowAsyncLoad: false })).toBe(272_000);
|
||||
});
|
||||
|
||||
it("scopes async discovery warmup to the explicit provider", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://example.com",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { ensureOpenClawModelsJson } = mockContextModuleDeps(() => cfg);
|
||||
|
||||
const { resolveContextTokensForModel } = await importContextModule();
|
||||
expect(
|
||||
resolveContextTokensForModel({
|
||||
cfg,
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
|
||||
await flushAsyncWarmup();
|
||||
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(cfg, undefined, {
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rehydrates config-backed cache entries after module reload when runtime config survives", async () => {
|
||||
const firstLoadConfigMock = vi.fn(() => ({
|
||||
models: {
|
||||
|
||||
@@ -215,9 +215,21 @@ function primeConfiguredContextWindows(): OpenClawConfig | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureContextWindowCacheLoaded(): Promise<void> {
|
||||
if (CONTEXT_WINDOW_RUNTIME_STATE.loadPromise) {
|
||||
return CONTEXT_WINDOW_RUNTIME_STATE.loadPromise;
|
||||
function resolveContextWindowLoadScopeKey(providerIds: readonly string[] | undefined): string {
|
||||
const scoped = providerIds
|
||||
?.map((value) => normalizeProviderId(value))
|
||||
.filter(Boolean)
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
return scoped?.length ? scoped.join(",") : "*";
|
||||
}
|
||||
|
||||
function ensureContextWindowCacheLoaded(options?: {
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
}): Promise<void> {
|
||||
const scopeKey = resolveContextWindowLoadScopeKey(options?.providerDiscoveryProviderIds);
|
||||
const existing = CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.get(scopeKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const cfg = primeConfiguredContextWindows();
|
||||
@@ -225,9 +237,17 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = (async () => {
|
||||
const pending = (async () => {
|
||||
try {
|
||||
await (await loadModelsConfigRuntime()).ensureOpenClawModelsJson(cfg);
|
||||
await (
|
||||
await loadModelsConfigRuntime()
|
||||
).ensureOpenClawModelsJson(
|
||||
cfg,
|
||||
undefined,
|
||||
options?.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
// Continue with best-effort discovery/overrides.
|
||||
}
|
||||
@@ -257,12 +277,16 @@ function ensureContextWindowCacheLoaded(): Promise<void> {
|
||||
})().catch(() => {
|
||||
// Keep lookup best-effort.
|
||||
});
|
||||
return CONTEXT_WINDOW_RUNTIME_STATE.loadPromise;
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.loadPromises.set(scopeKey, pending);
|
||||
if (scopeKey === "*") {
|
||||
CONTEXT_WINDOW_RUNTIME_STATE.loadPromise = pending;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function lookupContextTokens(
|
||||
modelId?: string,
|
||||
options?: { allowAsyncLoad?: boolean },
|
||||
options?: { allowAsyncLoad?: boolean; providerDiscoveryProviderIds?: readonly string[] },
|
||||
): number | undefined {
|
||||
if (!modelId) {
|
||||
return undefined;
|
||||
@@ -273,7 +297,11 @@ export function lookupContextTokens(
|
||||
primeConfiguredContextWindows();
|
||||
} else {
|
||||
// Best-effort: kick off loading on demand, but don't block lookups.
|
||||
void ensureContextWindowCacheLoaded();
|
||||
void ensureContextWindowCacheLoaded(
|
||||
options?.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
return lookupCachedContextTokens(modelId);
|
||||
}
|
||||
@@ -459,6 +487,7 @@ export function resolveContextTokensForModel(params: {
|
||||
model: params.model,
|
||||
});
|
||||
const explicitProvider = params.provider?.trim();
|
||||
const discoveryProviderIds = explicitProvider && ref ? ([ref.provider] as const) : undefined;
|
||||
if (ref) {
|
||||
const modelParams = resolveConfiguredModelParams(params.cfg, ref.provider, ref.model);
|
||||
if (modelParams?.context1m === true && isAnthropic1MModel(ref.provider, ref.model)) {
|
||||
@@ -502,7 +531,10 @@ export function resolveContextTokensForModel(params: {
|
||||
if (params.provider && ref && !ref.model.includes("/")) {
|
||||
const qualifiedResult = lookupContextTokens(
|
||||
`${normalizeProviderId(ref.provider)}/${ref.model}`,
|
||||
{ allowAsyncLoad: params.allowAsyncLoad },
|
||||
{
|
||||
allowAsyncLoad: params.allowAsyncLoad,
|
||||
...(discoveryProviderIds ? { providerDiscoveryProviderIds: discoveryProviderIds } : {}),
|
||||
},
|
||||
);
|
||||
if (qualifiedResult !== undefined) {
|
||||
return qualifiedResult;
|
||||
@@ -513,6 +545,7 @@ export function resolveContextTokensForModel(params: {
|
||||
// (e.g. "google/gemini-2.5-pro") this IS the raw discovery cache key.
|
||||
const bareResult = lookupContextTokens(params.model, {
|
||||
allowAsyncLoad: params.allowAsyncLoad,
|
||||
...(discoveryProviderIds ? { providerDiscoveryProviderIds: discoveryProviderIds } : {}),
|
||||
});
|
||||
if (bareResult !== undefined) {
|
||||
return bareResult;
|
||||
|
||||
@@ -3,10 +3,12 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { getShellEnvAppliedKeys } from "../infra/shell-env.js";
|
||||
import { resolvePluginSetupProvider } from "../plugins/setup-registry.js";
|
||||
import { CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "../secrets/provider-env-vars.js";
|
||||
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
|
||||
import { resolveProviderEnvApiKeyCandidates } from "./model-auth-env-vars.js";
|
||||
import { GCP_VERTEX_CREDENTIALS_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveProviderIdForAuth } from "./provider-auth-aliases.js";
|
||||
import { normalizeProviderId } from "./provider-id.js";
|
||||
|
||||
export type EnvApiKeyResult = {
|
||||
apiKey: string;
|
||||
@@ -40,8 +42,7 @@ export function resolveEnvApiKey(
|
||||
provider: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): EnvApiKeyResult | null {
|
||||
const normalized = resolveProviderIdForAuth(provider, { env });
|
||||
const candidateMap = resolveProviderEnvApiKeyCandidates({ env });
|
||||
const rawProvider = normalizeProviderId(provider);
|
||||
const applied = new Set(getShellEnvAppliedKeys());
|
||||
const pick = (envVar: string): EnvApiKeyResult | null => {
|
||||
const value = normalizeOptionalSecretInput(env[envVar]);
|
||||
@@ -52,6 +53,33 @@ export function resolveEnvApiKey(
|
||||
return { apiKey: value, source };
|
||||
};
|
||||
|
||||
const coreCandidates = Object.hasOwn(CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES, rawProvider)
|
||||
? CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES[
|
||||
rawProvider as keyof typeof CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES
|
||||
]
|
||||
: undefined;
|
||||
if (Array.isArray(coreCandidates)) {
|
||||
for (const envVar of coreCandidates) {
|
||||
const resolved = pick(envVar);
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rawProvider === "google-vertex") {
|
||||
const envKey = resolveGoogleVertexEnvApiKey(env);
|
||||
if (envKey) {
|
||||
return { apiKey: envKey, source: "gcloud adc" };
|
||||
}
|
||||
}
|
||||
|
||||
const candidateMap = resolveProviderEnvApiKeyCandidates({ env });
|
||||
const normalized = resolveProviderIdForAuth(rawProvider, { env });
|
||||
if (normalized === rawProvider && coreCandidates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.hasOwn(candidateMap, normalized) ? candidateMap[normalized] : undefined;
|
||||
if (Array.isArray(candidates)) {
|
||||
for (const envVar of candidates) {
|
||||
|
||||
@@ -175,6 +175,20 @@ describe("loadModelCatalog", () => {
|
||||
expect(discoverAuthStorage).toHaveBeenCalledWith("/tmp/openclaw", { readOnly: true });
|
||||
});
|
||||
|
||||
it("scopes models.json preparation when catalog provider scope is supplied", async () => {
|
||||
mockSingleOpenAiCatalogModel();
|
||||
const cfg = {} as OpenClawConfig;
|
||||
|
||||
await loadModelCatalog({
|
||||
config: cfg,
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
|
||||
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, undefined, {
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not synthesize stale openai-codex/gpt-5.3-codex-spark entries from gpt-5.4", async () => {
|
||||
mockPiDiscoveryModels([
|
||||
{
|
||||
|
||||
@@ -108,12 +108,19 @@ export async function loadModelCatalog(params?: {
|
||||
config?: OpenClawConfig;
|
||||
useCache?: boolean;
|
||||
readOnly?: boolean;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
}): Promise<ModelCatalogEntry[]> {
|
||||
const readOnly = params?.readOnly === true;
|
||||
const providerDiscoveryProviderIds = params?.providerDiscoveryProviderIds
|
||||
?.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
const scopedCatalog =
|
||||
providerDiscoveryProviderIds !== undefined && providerDiscoveryProviderIds.length > 0;
|
||||
const useSharedCache = !readOnly && !scopedCatalog;
|
||||
if (!readOnly && params?.useCache === false) {
|
||||
modelCatalogPromise = null;
|
||||
}
|
||||
if (!readOnly && modelCatalogPromise) {
|
||||
if (useSharedCache && modelCatalogPromise) {
|
||||
return modelCatalogPromise;
|
||||
}
|
||||
|
||||
@@ -139,7 +146,11 @@ export async function loadModelCatalog(params?: {
|
||||
try {
|
||||
const cfg = params?.config ?? getRuntimeConfig();
|
||||
if (!readOnly) {
|
||||
await ensureOpenClawModelsJson(cfg);
|
||||
await ensureOpenClawModelsJson(
|
||||
cfg,
|
||||
undefined,
|
||||
scopedCatalog ? { providerDiscoveryProviderIds } : undefined,
|
||||
);
|
||||
logStage("models-json-ready");
|
||||
}
|
||||
// IMPORTANT: keep the dynamic import *inside* the try/catch.
|
||||
@@ -188,6 +199,7 @@ export async function loadModelCatalog(params?: {
|
||||
const supplemental = await augmentModelCatalogWithProviderPlugins({
|
||||
config: cfg,
|
||||
env: process.env,
|
||||
...(scopedCatalog ? { providerDiscoveryProviderIds } : {}),
|
||||
context: {
|
||||
config: cfg,
|
||||
agentDir,
|
||||
@@ -208,7 +220,7 @@ export async function loadModelCatalog(params?: {
|
||||
|
||||
if (models.length === 0) {
|
||||
// If we found nothing, don't cache this result so we can try again.
|
||||
if (!readOnly) {
|
||||
if (useSharedCache) {
|
||||
modelCatalogPromise = null;
|
||||
}
|
||||
}
|
||||
@@ -222,7 +234,7 @@ export async function loadModelCatalog(params?: {
|
||||
log.warn(`Failed to load model catalog: ${String(error)}`);
|
||||
}
|
||||
// Don't poison the cache on transient dependency/filesystem issues.
|
||||
if (!readOnly) {
|
||||
if (useSharedCache) {
|
||||
modelCatalogPromise = null;
|
||||
}
|
||||
if (models.length > 0) {
|
||||
@@ -232,7 +244,7 @@ export async function loadModelCatalog(params?: {
|
||||
}
|
||||
};
|
||||
|
||||
if (readOnly) {
|
||||
if (!useSharedCache) {
|
||||
return loadCatalog();
|
||||
}
|
||||
|
||||
|
||||
@@ -361,12 +361,12 @@ function resolveLiveModelsJsonTimeoutMs(
|
||||
modelsJsonTimeoutRaw?: string,
|
||||
setupTimeoutMs = LIVE_SETUP_TIMEOUT_MS,
|
||||
): number {
|
||||
return Math.max(setupTimeoutMs, toInt(modelsJsonTimeoutRaw, 120_000));
|
||||
return Math.max(setupTimeoutMs, toInt(modelsJsonTimeoutRaw, 180_000));
|
||||
}
|
||||
|
||||
describe("resolveLiveModelsJsonTimeoutMs", () => {
|
||||
it("defaults models.json preparation to a longer setup timeout", () => {
|
||||
expect(resolveLiveModelsJsonTimeoutMs(undefined, 45_000)).toBe(120_000);
|
||||
expect(resolveLiveModelsJsonTimeoutMs(undefined, 45_000)).toBe(180_000);
|
||||
});
|
||||
|
||||
it("never goes below the shared live setup timeout", () => {
|
||||
|
||||
@@ -18,6 +18,14 @@ import {
|
||||
installEmbeddedRunnerFastRunE2eMocks,
|
||||
} from "./test-helpers/pi-embedded-runner-e2e-mocks.js";
|
||||
|
||||
type ResolveModelAsyncResult = Awaited<
|
||||
ReturnType<typeof import("./pi-embedded-runner/model.js").resolveModelAsync>
|
||||
>;
|
||||
|
||||
function asResolveModelAsyncResult(value: unknown): ResolveModelAsyncResult {
|
||||
return value as ResolveModelAsyncResult;
|
||||
}
|
||||
|
||||
const runEmbeddedAttemptMock = vi.fn();
|
||||
const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>(async () => {
|
||||
return undefined;
|
||||
@@ -25,7 +33,7 @@ const disposeSessionMcpRuntimeMock = vi.fn<(sessionId: string) => Promise<void>>
|
||||
const resolveSessionKeyForRequestMock = vi.fn();
|
||||
const resolveStoredSessionKeyForSessionIdMock = vi.fn();
|
||||
const resolveModelAsyncMock = vi.fn(async (provider: string, modelId: string) =>
|
||||
createResolvedEmbeddedRunnerModel(provider, modelId),
|
||||
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel(provider, modelId)),
|
||||
);
|
||||
const ensureOpenClawModelsJsonMock = vi.fn(async () => ({ wrote: false }));
|
||||
const loggerWarnMock = vi.fn();
|
||||
@@ -183,7 +191,7 @@ beforeEach(() => {
|
||||
resolveStoredSessionKeyForSessionIdMock.mockReset();
|
||||
resolveModelAsyncMock.mockReset();
|
||||
resolveModelAsyncMock.mockImplementation(async (provider: string, modelId: string) =>
|
||||
createResolvedEmbeddedRunnerModel(provider, modelId),
|
||||
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel(provider, modelId)),
|
||||
);
|
||||
ensureOpenClawModelsJsonMock.mockReset();
|
||||
ensureOpenClawModelsJsonMock.mockResolvedValue({ wrote: false });
|
||||
@@ -326,6 +334,49 @@ describe("runEmbeddedPiAgent", () => {
|
||||
expect(ensureOpenClawModelsJsonMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes models.json fallback discovery to the requested provider", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
|
||||
resolveModelAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
model: undefined,
|
||||
error: "Unknown model: openai/mock-1",
|
||||
authStorage: {
|
||||
setRuntimeApiKey: () => undefined,
|
||||
},
|
||||
modelRegistry: {},
|
||||
} as unknown as ResolveModelAsyncResult)
|
||||
.mockResolvedValueOnce(
|
||||
asResolveModelAsyncResult(createResolvedEmbeddedRunnerModel("openai", "mock-1")),
|
||||
);
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "scoped-model-discovery",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
runId: nextRunId("scoped-model-discovery"),
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, agentDir, {
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
});
|
||||
|
||||
it("backfills a trimmed session key from sessionId when the embedded run omits it", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = createEmbeddedPiRunnerOpenAiConfig(["mock-1"]);
|
||||
|
||||
@@ -431,7 +431,9 @@ export async function runEmbeddedPiAgent(
|
||||
dynamicModelResolution.model || pluginHarnessOwnsTransport
|
||||
? dynamicModelResolution
|
||||
: await (async () => {
|
||||
await ensureOpenClawModelsJson(params.config, agentDir);
|
||||
await ensureOpenClawModelsJson(params.config, agentDir, {
|
||||
providerDiscoveryProviderIds: [provider],
|
||||
});
|
||||
return await resolveModelAsync(provider, modelId, agentDir, params.config);
|
||||
})();
|
||||
const { model, error, authStorage, modelRegistry } = modelResolution;
|
||||
|
||||
44
src/agents/runtime-plan/auth.test.ts
Normal file
44
src/agents/runtime-plan/auth.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildAgentRuntimeAuthPlan } from "./auth.js";
|
||||
|
||||
const resolveProviderIdForAuth = vi.hoisted(() => vi.fn((provider: string) => provider));
|
||||
|
||||
vi.mock("../provider-auth-aliases.js", () => ({
|
||||
resolveProviderIdForAuth,
|
||||
}));
|
||||
|
||||
describe("buildAgentRuntimeAuthPlan", () => {
|
||||
it("does not load plugin auth aliases when no profile can be forwarded", () => {
|
||||
const plan = buildAgentRuntimeAuthPlan({
|
||||
provider: "OpenAI",
|
||||
config: {},
|
||||
workspaceDir: "/tmp/openclaw-runtime-plan",
|
||||
});
|
||||
|
||||
expect(plan).toEqual({
|
||||
providerForAuth: "openai",
|
||||
authProfileProviderForAuth: "openai",
|
||||
});
|
||||
expect(resolveProviderIdForAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses plugin auth aliases when profile providers differ", () => {
|
||||
resolveProviderIdForAuth.mockImplementation((provider: string) =>
|
||||
provider === "codex-cli" ? "openai-codex" : provider,
|
||||
);
|
||||
|
||||
const plan = buildAgentRuntimeAuthPlan({
|
||||
provider: "openai",
|
||||
authProfileProvider: "codex-cli",
|
||||
sessionAuthProfileId: "codex-cli:default",
|
||||
config: {},
|
||||
workspaceDir: "/tmp/openclaw-runtime-plan",
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
providerForAuth: "openai",
|
||||
authProfileProviderForAuth: "openai-codex",
|
||||
});
|
||||
expect(resolveProviderIdForAuth).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { normalizeEmbeddedAgentRuntime } from "../pi-embedded-runner/runtime.js";
|
||||
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
|
||||
import { normalizeProviderId } from "../provider-id.js";
|
||||
import type { AgentRuntimeAuthPlan } from "./types.js";
|
||||
|
||||
const CODEX_HARNESS_AUTH_PROVIDER = "openai-codex";
|
||||
@@ -24,16 +25,29 @@ export function buildAgentRuntimeAuthPlan(params: {
|
||||
harnessRuntime?: string;
|
||||
allowHarnessAuthProfileForwarding?: boolean;
|
||||
}): AgentRuntimeAuthPlan {
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
const normalizedAuthProfileProvider = normalizeProviderId(
|
||||
params.authProfileProvider ?? params.provider,
|
||||
);
|
||||
const aliasLookupParams = {
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
};
|
||||
const providerForAuth = resolveProviderIdForAuth(params.provider, aliasLookupParams);
|
||||
const authProfileProviderForAuth = resolveProviderIdForAuth(
|
||||
params.authProfileProvider ?? params.provider,
|
||||
aliasLookupParams,
|
||||
);
|
||||
const harnessAuthProvider = resolveHarnessAuthProvider(params);
|
||||
if (!params.sessionAuthProfileId && !harnessAuthProvider) {
|
||||
return {
|
||||
providerForAuth: normalizedProvider,
|
||||
authProfileProviderForAuth: normalizedAuthProfileProvider,
|
||||
};
|
||||
}
|
||||
const providerForAuth =
|
||||
normalizedProvider === normalizedAuthProfileProvider
|
||||
? normalizedProvider
|
||||
: resolveProviderIdForAuth(params.provider, aliasLookupParams);
|
||||
const authProfileProviderForAuth =
|
||||
normalizedProvider === normalizedAuthProfileProvider
|
||||
? normalizedAuthProfileProvider
|
||||
: resolveProviderIdForAuth(params.authProfileProvider ?? params.provider, aliasLookupParams);
|
||||
const harnessProviderForAuth = harnessAuthProvider
|
||||
? resolveProviderIdForAuth(harnessAuthProvider, aliasLookupParams)
|
||||
: undefined;
|
||||
|
||||
@@ -1,84 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
resolveRuntimePluginRegistry: vi.fn(),
|
||||
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
|
||||
() => "default",
|
||||
),
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getActivePluginRuntimeSubagentMode: vi.fn(() => "default"),
|
||||
resolveGatewayStartupPluginIds: vi.fn((_params: unknown) => ["active-memory", "telegram"]),
|
||||
resolveRuntimePluginRegistry: vi.fn((_params: unknown) => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/loader.js", () => ({
|
||||
resolveRuntimePluginRegistry: hoisted.resolveRuntimePluginRegistry,
|
||||
resolveRuntimePluginRegistry: (params: unknown) => mocks.resolveRuntimePluginRegistry(params),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/gateway-startup-plugin-ids.js", () => ({
|
||||
resolveGatewayStartupPluginIds: (params: unknown) => mocks.resolveGatewayStartupPluginIds(params),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/runtime.js", () => ({
|
||||
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
|
||||
getActivePluginRuntimeSubagentMode: () => mocks.getActivePluginRuntimeSubagentMode(),
|
||||
}));
|
||||
|
||||
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
|
||||
|
||||
describe("ensureRuntimePluginsLoaded", () => {
|
||||
let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded;
|
||||
|
||||
beforeEach(async () => {
|
||||
hoisted.resolveRuntimePluginRegistry.mockReset();
|
||||
hoisted.resolveRuntimePluginRegistry.mockReturnValue(undefined);
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
|
||||
vi.resetModules();
|
||||
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
|
||||
mocks.resolveGatewayStartupPluginIds.mockReturnValue(["active-memory", "telegram"]);
|
||||
});
|
||||
|
||||
it("does not reactivate plugins when a process already has an active registry", async () => {
|
||||
hoisted.resolveRuntimePluginRegistry.mockReturnValue({});
|
||||
it("loads only startup-scoped plugins for configured local agent runs", () => {
|
||||
const config = { plugins: { allow: ["telegram"] } };
|
||||
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
allowGatewaySubagentBinding: true,
|
||||
config,
|
||||
workspaceDir: "/workspace",
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves runtime plugins through the shared runtime helper", async () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
allowGatewaySubagentBinding: true,
|
||||
expect(mocks.resolveGatewayStartupPluginIds).toHaveBeenCalledWith({
|
||||
config,
|
||||
workspaceDir: "/workspace",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
runtimeOptions: {
|
||||
allowGatewaySubagentBinding: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not enable gateway subagent binding for normal runtime loads", async () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config,
|
||||
workspaceDir: "/workspace",
|
||||
onlyPluginIds: ["active-memory", "telegram"],
|
||||
runtimeOptions: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("inherits gateway-bindable mode from an active gateway registry", async () => {
|
||||
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable");
|
||||
it("preserves unscoped loading for callers without config", () => {
|
||||
ensureRuntimePluginsLoaded({});
|
||||
|
||||
expect(mocks.resolveGatewayStartupPluginIds).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
workspaceDir: undefined,
|
||||
runtimeOptions: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps gateway subagent binding on scoped loads", () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
|
||||
expect(hoisted.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {} as never,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
expect(mocks.resolveRuntimePluginRegistry).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
workspaceDir: "/workspace",
|
||||
onlyPluginIds: ["active-memory", "telegram"],
|
||||
runtimeOptions: {
|
||||
allowGatewaySubagentBinding: true,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveGatewayStartupPluginIds } from "../plugins/gateway-startup-plugin-ids.js";
|
||||
import { resolveRuntimePluginRegistry } from "../plugins/loader.js";
|
||||
import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
function resolveRuntimePluginIds(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
}): readonly string[] | undefined {
|
||||
if (!params.config) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveGatewayStartupPluginIds({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: process.env,
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureRuntimePluginsLoaded(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string | null;
|
||||
@@ -18,6 +33,16 @@ export function ensureRuntimePluginsLoaded(params: {
|
||||
const loadOptions = {
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
...(params.config
|
||||
? {
|
||||
onlyPluginIds: [
|
||||
...(resolveRuntimePluginIds({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
}) ?? []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
runtimeOptions: allowGatewaySubagentBinding
|
||||
? {
|
||||
allowGatewaySubagentBinding: true,
|
||||
|
||||
@@ -377,7 +377,7 @@ describeLive("tool replay repair live", () => {
|
||||
|
||||
expect(response.stopReason).not.toBe("error");
|
||||
if (text.length > 0) {
|
||||
expect(text).toMatch(/^transport replay ok\.?$/i);
|
||||
expect(text).toMatch(/^transport replay(?: ok)?\.?$/i);
|
||||
}
|
||||
},
|
||||
3 * 60 * 1000,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createWebSearchTool } from "./tools/web-search.js";
|
||||
|
||||
const XAI_KEY = process.env.XAI_API_KEY ?? "";
|
||||
const LIVE = isLiveTestEnabled(["XAI_LIVE_TEST"]);
|
||||
const XAI_WEB_SEARCH_LIVE_TIMEOUT_SECONDS = 60;
|
||||
|
||||
const describeLive = LIVE && XAI_KEY ? describe : describe.skip;
|
||||
|
||||
@@ -133,6 +134,7 @@ describeLive("xai live", () => {
|
||||
web: {
|
||||
search: {
|
||||
provider: "grok",
|
||||
timeoutSeconds: XAI_WEB_SEARCH_LIVE_TIMEOUT_SECONDS,
|
||||
grok: {
|
||||
model: "grok-4-1-fast",
|
||||
},
|
||||
@@ -165,5 +167,5 @@ describeLive("xai live", () => {
|
||||
(Array.isArray(details.citations) ? details.citations.length : 0) +
|
||||
(Array.isArray(details.inlineCitations) ? details.inlineCitations.length : 0);
|
||||
expect(citationCount).toBeGreaterThan(0);
|
||||
}, 45_000);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
|
||||
{
|
||||
commandPath: ["agent"],
|
||||
policy: {
|
||||
loadPlugins: "always",
|
||||
loadPlugins: "text-only",
|
||||
networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -67,6 +67,15 @@ describe("command-path-policy", () => {
|
||||
});
|
||||
|
||||
it("keeps config-only agent commands on config-only startup", () => {
|
||||
expect(resolveCliCommandPathPolicy(["agent"])).toEqual({
|
||||
bypassConfigGuard: false,
|
||||
routeConfigGuard: "never",
|
||||
loadPlugins: "text-only",
|
||||
hideBanner: false,
|
||||
ensureCliPath: true,
|
||||
networkProxy: expect.any(Function),
|
||||
});
|
||||
|
||||
for (const commandPath of [
|
||||
["agents", "bind"],
|
||||
["agents", "bindings"],
|
||||
|
||||
@@ -145,6 +145,10 @@ describe("registerPreActionHooks", () => {
|
||||
program.command("onboard").action(() => {});
|
||||
const channels = program.command("channels");
|
||||
channels.command("add").action(() => {});
|
||||
channels
|
||||
.command("send")
|
||||
.option("--json")
|
||||
.action(() => {});
|
||||
program
|
||||
.command("plugins")
|
||||
.command("install")
|
||||
@@ -229,7 +233,7 @@ describe("registerPreActionHooks", () => {
|
||||
processTitleSetSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("loads plugins for local agent runs", async () => {
|
||||
it("loads plugins for text local agent runs", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["agent"],
|
||||
processArgv: ["node", "openclaw", "agent", "--local", "--message", "hi"],
|
||||
@@ -242,6 +246,20 @@ describe("registerPreActionHooks", () => {
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "all" });
|
||||
});
|
||||
|
||||
it("skips broad plugin preload for json local agent runs", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["agent"],
|
||||
processArgv: ["node", "openclaw", "agent", "--local", "--message", "hi", "--json"],
|
||||
});
|
||||
|
||||
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
|
||||
runtime: runtimeMock,
|
||||
commandPath: ["agent", "hi"],
|
||||
suppressDoctorStdout: true,
|
||||
});
|
||||
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps setup alias and channels add manifest-first", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["onboard"],
|
||||
@@ -394,8 +412,8 @@ describe("registerPreActionHooks", () => {
|
||||
|
||||
it("routes logs to stderr in --json mode so stdout stays clean", async () => {
|
||||
await runPreAction({
|
||||
parseArgv: ["agent"],
|
||||
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
|
||||
parseArgv: ["channels", "send"],
|
||||
processArgv: ["node", "openclaw", "channels", "send", "--json"],
|
||||
});
|
||||
|
||||
expect(routeLogsToStderrMock).toHaveBeenCalledOnce();
|
||||
@@ -474,8 +492,8 @@ describe("registerPreActionHooks", () => {
|
||||
});
|
||||
|
||||
await runPreAction({
|
||||
parseArgv: ["agent"],
|
||||
processArgv: ["node", "openclaw", "agent", "--message", "hi", "--json"],
|
||||
parseArgv: ["channels", "send"],
|
||||
processArgv: ["node", "openclaw", "channels", "send", "--json"],
|
||||
});
|
||||
|
||||
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalled();
|
||||
|
||||
@@ -591,6 +591,7 @@ describe("update-cli", () => {
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
|
||||
}),
|
||||
@@ -601,6 +602,36 @@ describe("update-cli", () => {
|
||||
expect(runDaemonRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respawns into the updated git root before requested channel persistence", async () => {
|
||||
const { entrypoints } = setupUpdatedRootRefresh({
|
||||
gatewayUpdateImpl: async (root) =>
|
||||
makeOkUpdateResult({
|
||||
mode: "git",
|
||||
root,
|
||||
before: { sha: "old-sha", version: "2026.4.26" },
|
||||
after: { sha: "new-sha", version: "2026.4.27" },
|
||||
}),
|
||||
});
|
||||
|
||||
await updateCommand({ channel: "dev", yes: true, restart: false });
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/node/),
|
||||
[entrypoints[0], "update", "--no-restart", "--yes"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_UPDATE_POST_CORE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
|
||||
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(replaceConfigFile).not.toHaveBeenCalled();
|
||||
expect(syncPluginsForUpdateChannel).not.toHaveBeenCalled();
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps downgrade post-update work in the current process", async () => {
|
||||
const downgradedRoot = createCaseDir("openclaw-downgraded-root");
|
||||
setupUpdatedRootRefresh({
|
||||
@@ -685,6 +716,47 @@ describe("update-cli", () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("post-core resume mode persists the requested update channel with the updated process", async () => {
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
parsed: { update: { channel: "stable" } },
|
||||
resolved: { update: { channel: "stable" } } as OpenClawConfig,
|
||||
sourceConfig: { update: { channel: "stable" } } as OpenClawConfig,
|
||||
runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig,
|
||||
config: { update: { channel: "stable" } } as OpenClawConfig,
|
||||
hash: "stable-hash",
|
||||
});
|
||||
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_UPDATE_POST_CORE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "dev",
|
||||
OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL: "dev",
|
||||
},
|
||||
async () => {
|
||||
await updateCommand({ restart: false });
|
||||
},
|
||||
);
|
||||
|
||||
expect(runGatewayUpdate).not.toHaveBeenCalled();
|
||||
expect(replaceConfigFile).toHaveBeenCalledWith({
|
||||
nextConfig: {
|
||||
update: {
|
||||
channel: "dev",
|
||||
},
|
||||
},
|
||||
baseHash: "stable-hash",
|
||||
});
|
||||
expect(syncPluginsForUpdateChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "dev",
|
||||
config: expect.objectContaining({
|
||||
update: expect.objectContaining({ channel: "dev" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the update timeout budget into post-core plugin updates", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
@@ -1537,7 +1609,18 @@ describe("update-cli", () => {
|
||||
[expect.stringMatching(/node/), entryPath, "doctor", "--non-interactive", "--fix"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).toHaveBeenCalled();
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/node/),
|
||||
[entryPath, "update", "--no-restart", "--yes"],
|
||||
expect.objectContaining({
|
||||
stdio: "inherit",
|
||||
env: expect.objectContaining({
|
||||
OPENCLAW_UPDATE_POST_CORE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(updateNpmInstalledPlugins).not.toHaveBeenCalled();
|
||||
expect(
|
||||
vi
|
||||
.mocked(defaultRuntime.log)
|
||||
@@ -1934,10 +2017,14 @@ describe("update-cli", () => {
|
||||
const syncConfig = vi.mocked(syncPluginsForUpdateChannel).mock.calls[0]?.[0]?.config as
|
||||
| OpenClawConfig
|
||||
| undefined;
|
||||
const updateCall = vi.mocked(updateNpmInstalledPlugins).mock.calls[0]?.[0] as
|
||||
| { skipDisabledPlugins?: boolean }
|
||||
| undefined;
|
||||
expect(syncConfig?.plugins?.installs).toEqual(pluginInstallRecords);
|
||||
expect(syncConfig?.update?.channel).toBe("beta");
|
||||
expect(syncConfig?.gateway?.auth).toBeUndefined();
|
||||
expect(syncConfig?.plugins?.entries).toBeUndefined();
|
||||
expect(updateCall?.skipDisabledPlugins).toBe(true);
|
||||
});
|
||||
|
||||
it("persists channel and runs post-update work after switching from package to git", async () => {
|
||||
|
||||
@@ -54,7 +54,6 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeOptionalString } from "../../shared/string-coerce.js";
|
||||
import { stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { pathExists } from "../../utils.js";
|
||||
import { replaceCliName, resolveCliName } from "../cli-name.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { installCompletion } from "../completion-runtime.js";
|
||||
@@ -93,7 +92,10 @@ const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;
|
||||
const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
|
||||
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
|
||||
const POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_REQUESTED_CHANNEL";
|
||||
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
|
||||
const UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV =
|
||||
"OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE";
|
||||
const SERVICE_REFRESH_PATH_ENV_KEYS = [
|
||||
"OPENCLAW_HOME",
|
||||
"OPENCLAW_STATE_DIR",
|
||||
@@ -302,6 +304,20 @@ function resolveServiceRefreshEnv(
|
||||
return resolvedEnv;
|
||||
}
|
||||
|
||||
function disableUpdatedPackageCompileCacheEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...env,
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveUpdatedInstallCommandEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
invocationCwd?: string,
|
||||
): NodeJS.ProcessEnv {
|
||||
return disableUpdatedPackageCompileCacheEnv(resolveServiceRefreshEnv(env, invocationCwd));
|
||||
}
|
||||
|
||||
type UpdateDryRunPreview = {
|
||||
dryRun: true;
|
||||
root: string;
|
||||
@@ -376,7 +392,7 @@ async function refreshGatewayServiceEnv(params: {
|
||||
if (entrypoint) {
|
||||
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
|
||||
cwd: params.result.root,
|
||||
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
|
||||
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
|
||||
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
@@ -415,7 +431,7 @@ async function runUpdatedInstallGatewayRestart(params: {
|
||||
}
|
||||
const res = await runCommandWithTimeout([resolveNodeRunner(), entrypoint, ...args], {
|
||||
cwd: params.result.root,
|
||||
env: resolveServiceRefreshEnv(params.env ?? process.env, params.invocationCwd),
|
||||
env: resolveUpdatedInstallCommandEnv(params.env ?? process.env, params.invocationCwd),
|
||||
timeoutMs: SERVICE_REFRESH_TIMEOUT_MS,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
@@ -553,8 +569,9 @@ async function runPackageInstallUpdate(params: {
|
||||
name: `${CLI_NAME} doctor`,
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive", "--fix"],
|
||||
env: {
|
||||
...process.env,
|
||||
...disableUpdatedPackageCompileCacheEnv(process.env),
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
@@ -728,6 +745,7 @@ async function updatePluginsAfterCoreUpdate(params: {
|
||||
config: pluginConfig,
|
||||
timeoutMs: params.timeoutMs,
|
||||
skipIds: new Set(syncResult.summary.switchedToNpm),
|
||||
skipDisabledPlugins: true,
|
||||
logger: pluginLogger,
|
||||
onIntegrityDrift: async (drift) => {
|
||||
integrityDrifts.push({
|
||||
@@ -1015,6 +1033,7 @@ async function maybeRestartService(params: {
|
||||
defaultRuntime.log(theme.success("Daemon restarted successfully."));
|
||||
defaultRuntime.log("");
|
||||
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
|
||||
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
|
||||
try {
|
||||
const interactiveDoctor =
|
||||
process.stdin.isTTY && !params.opts.json && params.opts.yes !== true;
|
||||
@@ -1025,6 +1044,7 @@ async function maybeRestartService(params: {
|
||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||
} finally {
|
||||
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1078,6 +1098,40 @@ async function runPostCorePluginUpdate(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function persistRequestedUpdateChannel(params: {
|
||||
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
|
||||
requestedChannel: "stable" | "beta" | "dev" | null;
|
||||
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
|
||||
if (!params.requestedChannel || !params.configSnapshot.valid) {
|
||||
return params.configSnapshot;
|
||||
}
|
||||
const storedChannel = normalizeUpdateChannel(params.configSnapshot.config.update?.channel);
|
||||
if (params.requestedChannel === storedChannel) {
|
||||
return params.configSnapshot;
|
||||
}
|
||||
|
||||
const next = {
|
||||
...params.configSnapshot.sourceConfig,
|
||||
update: {
|
||||
...params.configSnapshot.sourceConfig.update,
|
||||
channel: params.requestedChannel,
|
||||
},
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: params.configSnapshot.hash,
|
||||
});
|
||||
return {
|
||||
...params.configSnapshot,
|
||||
hash: undefined,
|
||||
parsed: next,
|
||||
sourceConfig: asResolvedSourceConfig(next),
|
||||
resolved: asResolvedSourceConfig(next),
|
||||
runtimeConfig: asRuntimeConfig(next),
|
||||
config: asRuntimeConfig(next),
|
||||
};
|
||||
}
|
||||
|
||||
async function writePostCorePluginUpdateResultFile(
|
||||
filePath: string | undefined,
|
||||
result: PostCorePluginUpdateResult,
|
||||
@@ -1110,10 +1164,11 @@ async function readPostCorePluginUpdateResultFile(
|
||||
async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
root: string;
|
||||
channel: "stable" | "beta" | "dev";
|
||||
requestedChannel: "stable" | "beta" | "dev" | null;
|
||||
opts: UpdateCommandOptions;
|
||||
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
|
||||
const entryPath = path.join(params.root, "dist", "entry.js");
|
||||
if (!(await pathExists(entryPath))) {
|
||||
const entryPath = await resolveGatewayInstallEntrypoint(params.root);
|
||||
if (!entryPath) {
|
||||
return { resumed: false };
|
||||
}
|
||||
|
||||
@@ -1140,9 +1195,12 @@ async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
const child = spawn(resolveNodeRunner(), argv, {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
...disableUpdatedPackageCompileCacheEnv(process.env),
|
||||
[POST_CORE_UPDATE_ENV]: "1",
|
||||
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
|
||||
...(params.requestedChannel
|
||||
? { [POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]: params.requestedChannel }
|
||||
: {}),
|
||||
...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}),
|
||||
},
|
||||
});
|
||||
@@ -1180,7 +1238,23 @@ function shouldResumePostCoreUpdateInFreshProcess(params: {
|
||||
result: UpdateRunResult;
|
||||
downgradeRisk: boolean;
|
||||
}): boolean {
|
||||
return isPackageManagerUpdateMode(params.result.mode) && !params.downgradeRisk;
|
||||
if (params.downgradeRisk) {
|
||||
return false;
|
||||
}
|
||||
if (isPackageManagerUpdateMode(params.result.mode)) {
|
||||
return true;
|
||||
}
|
||||
if (params.result.mode !== "git") {
|
||||
return false;
|
||||
}
|
||||
const beforeSha = normalizeOptionalString(params.result.before?.sha);
|
||||
const afterSha = normalizeOptionalString(params.result.after?.sha);
|
||||
if (beforeSha && afterSha && beforeSha !== afterSha) {
|
||||
return true;
|
||||
}
|
||||
const beforeVersion = normalizeOptionalString(params.result.before?.version);
|
||||
const afterVersion = normalizeOptionalString(params.result.after?.version);
|
||||
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
|
||||
}
|
||||
|
||||
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
@@ -1188,6 +1262,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
const invocationCwd = tryResolveInvocationCwd();
|
||||
const postCoreUpdateResume = process.env[POST_CORE_UPDATE_ENV] === "1";
|
||||
const postCoreUpdateChannel = process.env[POST_CORE_UPDATE_CHANNEL_ENV]?.trim();
|
||||
const postCoreRequestedChannelInput =
|
||||
process.env[POST_CORE_UPDATE_REQUESTED_CHANNEL_ENV]?.trim() ?? "";
|
||||
|
||||
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
|
||||
const shouldRestart = opts.restart !== false;
|
||||
@@ -1208,10 +1284,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const postCoreRequestedChannel = postCoreRequestedChannelInput
|
||||
? normalizeUpdateChannel(postCoreRequestedChannelInput)
|
||||
: null;
|
||||
if (postCoreRequestedChannelInput && !postCoreRequestedChannel) {
|
||||
defaultRuntime.error("Invalid post-core requested update channel context.");
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const postCoreConfigSnapshot = await persistRequestedUpdateChannel({
|
||||
configSnapshot: await readConfigFileSnapshot(),
|
||||
requestedChannel: postCoreRequestedChannel,
|
||||
});
|
||||
|
||||
const pluginUpdate = await runPostCorePluginUpdate({
|
||||
root,
|
||||
channel: postCoreUpdateChannel,
|
||||
configSnapshot: await readConfigFileSnapshot(),
|
||||
configSnapshot: postCoreConfigSnapshot,
|
||||
opts,
|
||||
timeoutMs: updateStepTimeoutMs,
|
||||
});
|
||||
@@ -1552,46 +1642,45 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldResumePostCoreInFreshProcess = shouldResumePostCoreUpdateInFreshProcess({
|
||||
result,
|
||||
downgradeRisk,
|
||||
});
|
||||
|
||||
let postUpdateConfigSnapshot = configSnapshot;
|
||||
if (requestedChannel && configSnapshot.valid && requestedChannel !== storedChannel) {
|
||||
const next = {
|
||||
...configSnapshot.sourceConfig,
|
||||
update: {
|
||||
...configSnapshot.sourceConfig.update,
|
||||
channel: requestedChannel,
|
||||
},
|
||||
};
|
||||
await replaceConfigFile({
|
||||
nextConfig: next,
|
||||
baseHash: configSnapshot.hash,
|
||||
if (!shouldResumePostCoreInFreshProcess) {
|
||||
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
|
||||
configSnapshot,
|
||||
requestedChannel,
|
||||
});
|
||||
postUpdateConfigSnapshot = {
|
||||
...configSnapshot,
|
||||
hash: undefined,
|
||||
parsed: next,
|
||||
sourceConfig: asResolvedSourceConfig(next),
|
||||
resolved: asResolvedSourceConfig(next),
|
||||
runtimeConfig: asRuntimeConfig(next),
|
||||
config: asRuntimeConfig(next),
|
||||
};
|
||||
if (!opts.json) {
|
||||
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
|
||||
}
|
||||
}
|
||||
if (
|
||||
requestedChannel &&
|
||||
configSnapshot.valid &&
|
||||
requestedChannel !== storedChannel &&
|
||||
!shouldResumePostCoreInFreshProcess &&
|
||||
!opts.json
|
||||
) {
|
||||
defaultRuntime.log(theme.muted(`Update channel set to ${requestedChannel}.`));
|
||||
} else if (
|
||||
requestedChannel &&
|
||||
configSnapshot.valid &&
|
||||
requestedChannel !== storedChannel &&
|
||||
shouldResumePostCoreInFreshProcess &&
|
||||
!opts.json
|
||||
) {
|
||||
defaultRuntime.log(theme.muted(`Update channel will be set to ${requestedChannel}.`));
|
||||
}
|
||||
|
||||
const postUpdateRoot = result.root ?? root;
|
||||
|
||||
let postCorePluginUpdate: PostCorePluginUpdateResult | undefined;
|
||||
let pluginsUpdatedInFreshProcess = false;
|
||||
if (
|
||||
shouldResumePostCoreUpdateInFreshProcess({
|
||||
result,
|
||||
downgradeRisk,
|
||||
})
|
||||
) {
|
||||
if (shouldResumePostCoreInFreshProcess) {
|
||||
const freshProcessResult = await continuePostCoreUpdateInFreshProcess({
|
||||
root: postUpdateRoot,
|
||||
channel,
|
||||
requestedChannel,
|
||||
opts,
|
||||
});
|
||||
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
|
||||
@@ -1599,6 +1688,12 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
}
|
||||
|
||||
if (!pluginsUpdatedInFreshProcess) {
|
||||
if (shouldResumePostCoreInFreshProcess) {
|
||||
postUpdateConfigSnapshot = await persistRequestedUpdateChannel({
|
||||
configSnapshot,
|
||||
requestedChannel,
|
||||
});
|
||||
}
|
||||
postCorePluginUpdate = await runPostCorePluginUpdate({
|
||||
root: postUpdateRoot,
|
||||
channel,
|
||||
|
||||
@@ -613,6 +613,11 @@ describe("agentCommand", () => {
|
||||
{ provider: "anthropic", model: "claude-opus-4-6" },
|
||||
{ provider: "openai", model: "gpt-5.4" },
|
||||
]);
|
||||
expect(loadModelCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerDiscoveryProviderIds: ["anthropic", "openai"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -595,12 +595,16 @@ export async function noteStateIntegrity(
|
||||
cfg: OpenClawConfig,
|
||||
prompter: DoctorPrompterLike,
|
||||
configPath?: string,
|
||||
params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homedir?: () => string;
|
||||
} = {},
|
||||
) {
|
||||
const warnings: string[] = [];
|
||||
const changes: string[] = [];
|
||||
const noteFn = prompter.note ?? note;
|
||||
const env = process.env;
|
||||
const homedir = () => resolveRequiredHomeDir(env, os.homedir);
|
||||
const env = params.env ?? process.env;
|
||||
const homedir = () => resolveRequiredHomeDir(env, params.homedir ?? os.homedir);
|
||||
const stateDir = resolveStateDir(env, homedir);
|
||||
const defaultStateDir = path.join(homedir(), ".openclaw");
|
||||
const oauthDir = resolveOAuthDir(env, stateDir);
|
||||
|
||||
@@ -112,9 +112,29 @@ describe("doctor command", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await doctorCommand(createDoctorRuntime(), { yes: true });
|
||||
const previousConfigWriteSupport =
|
||||
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
|
||||
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE = "1";
|
||||
try {
|
||||
await doctorCommand(createDoctorRuntime(), { yes: true });
|
||||
} finally {
|
||||
if (previousConfigWriteSupport === undefined) {
|
||||
delete process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE;
|
||||
} else {
|
||||
process.env.OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE =
|
||||
previousConfigWriteSupport;
|
||||
}
|
||||
}
|
||||
|
||||
const written = writeConfigFile.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
const written = writeConfigFile.mock.calls
|
||||
.map((call) => call[0] as Record<string, unknown>)
|
||||
.find((candidate) => {
|
||||
const auth = candidate.auth as { profiles?: unknown } | undefined;
|
||||
return Boolean(auth?.profiles);
|
||||
});
|
||||
if (!written) {
|
||||
throw new Error("Expected doctor to write migrated auth profiles");
|
||||
}
|
||||
const profiles = (written.auth as { profiles: Record<string, unknown> }).profiles;
|
||||
expect(profiles["anthropic:me@example.com"]).toBeTruthy();
|
||||
expect(profiles["anthropic:default"]).toBeUndefined();
|
||||
|
||||
@@ -94,6 +94,9 @@ describe("doctor command", () => {
|
||||
const missingDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-missing-state-"));
|
||||
fs.rmSync(missingDir, { recursive: true, force: true });
|
||||
process.env.OPENCLAW_STATE_DIR = missingDir;
|
||||
doctorCommand = await loadDoctorCommandForTest({
|
||||
unmockModules: ["../flows/doctor-health-contributions.js", "./doctor-state-integrity.js"],
|
||||
});
|
||||
await doctorCommand(createDoctorRuntime(), {
|
||||
nonInteractive: true,
|
||||
workspaceSuggestions: false,
|
||||
|
||||
@@ -153,12 +153,17 @@ export function upsertCanonicalModelConfigEntry(
|
||||
) {
|
||||
const key = modelKey(params.provider, params.model);
|
||||
const legacyKey = legacyModelKey(params.provider, params.model);
|
||||
if (!models[key]) {
|
||||
if (legacyKey && models[legacyKey]) {
|
||||
models[key] = models[legacyKey];
|
||||
} else {
|
||||
models[key] = {};
|
||||
}
|
||||
if (legacyKey && models[legacyKey]) {
|
||||
models[key] = {
|
||||
...models[legacyKey],
|
||||
...models[key],
|
||||
params: {
|
||||
...models[legacyKey].params,
|
||||
...models[key]?.params,
|
||||
},
|
||||
};
|
||||
} else if (!models[key]) {
|
||||
models[key] = {};
|
||||
}
|
||||
if (legacyKey) {
|
||||
delete models[legacyKey];
|
||||
|
||||
@@ -74,6 +74,20 @@ describe("Dockerfile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("copies postinstall helper imports before pnpm install", async () => {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const postinstallIndex = dockerfile.indexOf("COPY scripts/postinstall-bundled-plugins.mjs");
|
||||
const distImportHelperIndex = dockerfile.indexOf(
|
||||
"COPY scripts/lib/package-dist-imports.mjs ./scripts/lib/package-dist-imports.mjs",
|
||||
);
|
||||
|
||||
expect(postinstallIndex).toBeGreaterThan(-1);
|
||||
expect(distImportHelperIndex).toBeGreaterThan(-1);
|
||||
expect(postinstallIndex).toBeLessThan(installIndex);
|
||||
expect(distImportHelperIndex).toBeLessThan(installIndex);
|
||||
});
|
||||
|
||||
it("prunes runtime dependencies after the build stage", async () => {
|
||||
const dockerfile = await readFile(dockerfilePath, "utf8");
|
||||
expect(dockerfile).toContain("FROM build AS runtime-assets");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js";
|
||||
import {
|
||||
buildOpenClawCompileCacheRespawnPlan,
|
||||
isSourceCheckoutInstallRoot,
|
||||
resolveOpenClawCompileCacheDirectory,
|
||||
resolveEntryInstallRoot,
|
||||
shouldEnableOpenClawCompileCache,
|
||||
} from "./entry.compile-cache.js";
|
||||
@@ -54,6 +55,21 @@ describe("entry compile cache", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes packaged compile cache by package install metadata", async () => {
|
||||
const root = makeTempDir(tempDirs, "openclaw-compile-cache-package-key-");
|
||||
const packageJsonPath = path.join(root, "package.json");
|
||||
await fs.writeFile(packageJsonPath, '{"version":"2026.4.27-beta.1"}\n', "utf8");
|
||||
|
||||
const directory = resolveOpenClawCompileCacheDirectory({
|
||||
env: { NODE_COMPILE_CACHE: path.join(root, ".node-cache") },
|
||||
installRoot: root,
|
||||
});
|
||||
|
||||
expect(directory).toContain(path.join(".node-cache", "openclaw"));
|
||||
expect(directory).toContain("2026.4.27-beta.1");
|
||||
expect(path.basename(directory)).toMatch(/^\d+-\d+$/);
|
||||
});
|
||||
|
||||
it("builds a one-shot no-cache respawn plan when source checkout inherits NODE_COMPILE_CACHE", async () => {
|
||||
const root = makeTempDir(tempDirs, "openclaw-compile-cache-respawn-");
|
||||
await fs.mkdir(path.join(root, "src"), { recursive: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { enableCompileCache, getCompileCacheDir } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function resolveEntryInstallRoot(entryFile: string): string {
|
||||
@@ -34,6 +35,55 @@ export function shouldEnableOpenClawCompileCache(params: {
|
||||
return !isSourceCheckoutInstallRoot(params.installRoot);
|
||||
}
|
||||
|
||||
function sanitizeCompileCachePathSegment(value: string): string {
|
||||
const normalized = value.replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
|
||||
return normalized.length > 0 ? normalized : "unknown";
|
||||
}
|
||||
|
||||
function readPackageVersion(packageJsonPath: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as unknown;
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
"version" in parsed &&
|
||||
typeof parsed.version === "string" &&
|
||||
parsed.version.trim().length > 0
|
||||
) {
|
||||
return parsed.version;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to an install-metadata-only cache key.
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function resolveOpenClawCompileCacheDirectory(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
installRoot: string;
|
||||
}): string {
|
||||
const env = params.env ?? process.env;
|
||||
const packageJsonPath = path.join(params.installRoot, "package.json");
|
||||
const version = sanitizeCompileCachePathSegment(readPackageVersion(packageJsonPath));
|
||||
let installMarker = "no-package-json";
|
||||
try {
|
||||
const stat = statSync(packageJsonPath);
|
||||
installMarker = `${Math.trunc(stat.mtimeMs)}-${stat.size}`;
|
||||
} catch {
|
||||
// Package archives should always have package.json, but keep startup best-effort.
|
||||
}
|
||||
const baseDirectory =
|
||||
env.NODE_COMPILE_CACHE && !isNodeCompileCacheDisabled(env)
|
||||
? env.NODE_COMPILE_CACHE
|
||||
: path.join(os.tmpdir(), "node-compile-cache");
|
||||
return path.join(
|
||||
baseDirectory,
|
||||
"openclaw",
|
||||
version,
|
||||
sanitizeCompileCachePathSegment(installMarker),
|
||||
);
|
||||
}
|
||||
|
||||
export type OpenClawCompileCacheRespawnPlan = {
|
||||
command: string;
|
||||
args: string[];
|
||||
@@ -107,7 +157,7 @@ export function enableOpenClawCompileCache(params: {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
enableCompileCache();
|
||||
enableCompileCache(resolveOpenClawCompileCacheDirectory(params));
|
||||
} catch {
|
||||
// Best-effort only; never block startup.
|
||||
}
|
||||
|
||||
@@ -7,26 +7,19 @@ import {
|
||||
resolveCliRespawnCommand,
|
||||
} from "./entry.respawn.js";
|
||||
|
||||
const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => false));
|
||||
const isTruthyEnvValueMock = vi.hoisted(() =>
|
||||
vi.fn((value: string | undefined) => value === "1" || value === "true"),
|
||||
);
|
||||
|
||||
vi.mock("./cli/respawn-policy.js", () => ({
|
||||
shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock,
|
||||
}));
|
||||
|
||||
vi.mock("./infra/env.js", () => ({
|
||||
isTruthyEnvValue: isTruthyEnvValueMock,
|
||||
}));
|
||||
|
||||
describe("buildCliRespawnPlan", () => {
|
||||
it("returns null when respawn policy skips the argv", () => {
|
||||
shouldSkipRespawnForArgvMock.mockReturnValueOnce(true);
|
||||
|
||||
expect(
|
||||
buildCliRespawnPlan({
|
||||
argv: ["node", "openclaw", "status"],
|
||||
argv: ["node", "openclaw", "--help"],
|
||||
env: {},
|
||||
execArgv: [],
|
||||
autoNodeExtraCaCerts: "/etc/ssl/certs/ca-certificates.crt",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDoctorHealthContributions } from "./doctor-health-contributions.js";
|
||||
import {
|
||||
resolveDoctorHealthContributions,
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite,
|
||||
} from "./doctor-health-contributions.js";
|
||||
|
||||
describe("doctor health contributions", () => {
|
||||
it("repairs bundled runtime deps before channel-owned doctor paths can import runtimes", () => {
|
||||
@@ -20,4 +23,41 @@ describe("doctor health contributions", () => {
|
||||
expect(ids.indexOf("doctor:plugin-registry")).toBeGreaterThan(-1);
|
||||
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
|
||||
});
|
||||
|
||||
it("skips doctor config writes under legacy update parents", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
env: { OPENCLAW_UPDATE_IN_PROGRESS: "1" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps doctor writes outside legacy update writable", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
env: {},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps current update parents writable", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats falsey update env values as normal writes", () => {
|
||||
expect(
|
||||
shouldSkipLegacyUpdateDoctorConfigWrite({
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "0",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user