mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 18:31:39 +08:00
Compare commits
1 Commits
codex/i18n
...
pe/claw-43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6aecc37164 |
22
.github/workflows/ci.yml
vendored
22
.github/workflows/ci.yml
vendored
@@ -848,28 +848,6 @@ jobs:
|
||||
path: .local/gateway-watch-regression/
|
||||
retention-days: 7
|
||||
|
||||
native-i18n:
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [preflight]
|
||||
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_macos == 'true' || needs.preflight.outputs.run_android == 'true' || needs.preflight.outputs.run_node == 'true') }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check native app i18n inventory
|
||||
run: pnpm native:i18n:check
|
||||
|
||||
checks-fast-core:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
119
.github/workflows/native-app-locale-refresh.yml
vendored
119
.github/workflows/native-app-locale-refresh.yml
vendored
@@ -1,119 +0,0 @@
|
||||
name: Native App Locale Refresh
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- apps/android/app/src/main/**
|
||||
- apps/ios/**
|
||||
- apps/macos/Sources/**
|
||||
- apps/macos/Package.swift
|
||||
- apps/shared/OpenClawKit/Sources/**
|
||||
- apps/.i18n/native-source.json
|
||||
- scripts/control-ui-i18n.ts
|
||||
- scripts/native-app-i18n.ts
|
||||
- .github/workflows/native-app-locale-refresh.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: native-app-locale-refresh-${{ github.event_name == 'push' && github.ref || format('manual-{0}', github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
if: github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main') && (github.event_name != 'push' || github.actor != 'github-actions[bot]')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
locale:
|
||||
[
|
||||
zh-CN,
|
||||
zh-TW,
|
||||
pt-BR,
|
||||
de,
|
||||
es,
|
||||
ja-JP,
|
||||
ko,
|
||||
fr,
|
||||
hi,
|
||||
ar,
|
||||
it,
|
||||
tr,
|
||||
uk,
|
||||
id,
|
||||
pl,
|
||||
th,
|
||||
vi,
|
||||
nl,
|
||||
fa,
|
||||
ru,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
name: Refresh native ${{ matrix.locale }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Ensure translation provider secrets exist
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${OPENAI_API_KEY:-}" ] && [ -z "${ANTHROPIC_API_KEY:-}" ]; then
|
||||
echo "Missing OPENCLAW_DOCS_I18N_OPENAI_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Refresh native locale artifact
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-8' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "0"
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/native-app-i18n.ts sync --write --locale "${LOCALE}"
|
||||
|
||||
- name: Commit and push locale artifact
|
||||
env:
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
TARGET_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! git status --porcelain -- apps/.i18n/native apps/.i18n/native-source.json | grep -q .; then
|
||||
echo "No native locale changes for ${LOCALE}."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -A apps/.i18n/native apps/.i18n/native-source.json
|
||||
git commit --no-verify -m "chore(i18n): refresh native ${LOCALE} locale"
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
git fetch origin "${TARGET_BRANCH}"
|
||||
git rebase --autostash "origin/${TARGET_BRANCH}"
|
||||
if git push origin HEAD:"${TARGET_BRANCH}"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt} for ${LOCALE} failed; retrying."
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
echo "Failed to push ${LOCALE} native locale update after retries."
|
||||
exit 1
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Plugin Init Scaffold Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/plugin-init-scaffold-validation.yml"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/validate-plugin-init-provider-scaffold.ts"
|
||||
- "src/cli/plugins-authoring-command.ts"
|
||||
- "src/cli/plugins-authoring-command.test.ts"
|
||||
- "src/cli/plugins-cli.ts"
|
||||
- "src/plugin-sdk/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- ".github/workflows/plugin-init-scaffold-validation.yml"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "scripts/validate-plugin-init-provider-scaffold.ts"
|
||||
- "src/cli/plugins-authoring-command.ts"
|
||||
- "src/cli/plugins-authoring-command.test.ts"
|
||||
- "src/cli/plugins-cli.ts"
|
||||
- "src/plugin-sdk/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate-provider-scaffold:
|
||||
name: Validate provider scaffold
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Generate and validate provider scaffold
|
||||
run: pnpm test:plugins:init-provider-scaffold
|
||||
@@ -143,9 +143,6 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
## GitHub / PRs
|
||||
|
||||
- Fresh GitHub items: read `CONTRIBUTING.md`, the issue chooser/form, PR template, and `.github/CODEOWNERS`; blank issues are disabled; preserve templates and evidence requirements.
|
||||
- Agent-authored/non-trivial work: create or reuse the issue first; tiny fixes may go direct. PRs use the template, link context, and keep durable problem/impact/evidence sections.
|
||||
- Route support to Discord and security through `SECURITY.md`. Use listed maintainer areas/`CODEOWNERS`; never guess mentions.
|
||||
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
|
||||
- Issue/PR start: `git status -sb`; if clean, `git pull --ff-only`; if dirty, yell before pull/rebase.
|
||||
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
|
||||
|
||||
@@ -97,23 +97,6 @@ Welcome to the lobster tank! 🦞
|
||||
4. **Test/CI-only PRs for known `main` failures** → Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
|
||||
5. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
|
||||
|
||||
## Issue, PR, and Contact Routing
|
||||
|
||||
Start from this routing map before creating GitHub items:
|
||||
|
||||
| Situation | Use | Required evidence |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| Product bug, regression, crash, or behavior defect | [Bug report](https://github.com/openclaw/openclaw/issues/new?template=bug_report.yml) | Repro steps, expected vs actual behavior, version, OS, model/provider route when relevant, logs/screenshots, impact |
|
||||
| Documentation bug or missing/contradictory docs | [Docs bug report](https://github.com/openclaw/openclaw/issues/new?template=docs_bug_report.yml) | Affected docs path or URL, verification steps, expected docs content, actual docs content, impact, evidence |
|
||||
| New feature, architecture change, or product improvement | [Feature request](https://github.com/openclaw/openclaw/issues/new?template=feature_request.yml) or Discord first | Problem, proposed solution, alternatives, impact, examples or prior art |
|
||||
| Onboarding, setup help, or general support question | Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) | Do not open a GitHub issue unless there is a concrete product defect or docs gap |
|
||||
| Security vulnerability | See [Report a Vulnerability](#report-a-vulnerability) below | Do not file public issues for private security reports |
|
||||
| PR for an existing or newly filed issue | Use the [PR template](.github/pull_request_template.md) | Visible `Closes #<issue>` or `Related: #<issue>`, problem, shipped solution, user impact, validation evidence |
|
||||
|
||||
For agent-authored or otherwise non-trivial work, create or reuse the issue first, then open the PR against it. Bugs and very small fixes may go straight to PR, but still link existing context when it exists and fill out the PR template.
|
||||
|
||||
Do not guess who to tag. Let issue forms, labels/automation, `.github/CODEOWNERS`, and the maintainer areas above route the work. Mention a maintainer only when their listed area or owned path is directly relevant and you need a decision; otherwise rely on normal review. For coordinated change sets, ask in **#clawtributors** before opening more than the PR limit.
|
||||
|
||||
## PR Limits
|
||||
|
||||
We cap at **20 open PRs per author**. If you exceed this, the `r: too-many-prs` label is added and your PR is auto-closed. This is a hard limit.
|
||||
|
||||
@@ -304,9 +304,6 @@ by Peter Steinberger and the community.
|
||||
## Community
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||
Use the [issue chooser](https://github.com/openclaw/openclaw/issues/new/choose) for bugs, docs bugs, and feature requests;
|
||||
ask setup/support questions in [Discord](https://discord.gg/clawd); and report vulnerabilities through [SECURITY.md](SECURITY.md).
|
||||
PRs should link the relevant issue when possible and follow the [PR template](.github/pull_request_template.md) with problem, impact, and evidence.
|
||||
AI/vibe-coded PRs welcome! 🤖
|
||||
|
||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
||||
|
||||
174
appcast.xml
174
appcast.xml
@@ -2,53 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.6.10</title>
|
||||
<pubDate>Fri, 26 Jun 2026 23:37:36 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2606001090</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.10</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.10</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li><strong>Automatic fast mode for talks:</strong> OpenClaw can enable fast mode for short conversational turns, then return to normal mode for longer runs with bounded fallback and delivery behavior. (#85104) Thanks @alexph-dev and @vincentkoc.</li>
|
||||
<li><strong>More reliable model routing:</strong> Zai model synthesis, GLM overload failover, and native reasoning-level selection now follow the active model catalog more consistently. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
|
||||
<li><strong>Safer session and channel state:</strong> channel switches reset stale origin fields, and cron delivery awareness stays attached to the target session. (#95328, #93580) Thanks @ZengWen-DT, @jalehman, @gorkem2020, and @scotthuang.</li>
|
||||
<li><strong>Trusted policies survive hook composition:</strong> composed hook registries keep the trusted tool policies required by approval-sensitive flows. (#94545) Thanks @jesse-merhi.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li><strong>Agent and channel runtime:</strong> fast-mode state now survives retries, fallback transitions, progress events, and embedded/CLI/ACP normalization; session and channel routing retain the current target and delivery context. (#85104, #93580, #95328) Thanks @alexph-dev, @vincentkoc, @scotthuang, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
|
||||
<li><strong>Provider behavior:</strong> model catalogs now supply the correct Zai base URL, overload classification, and native reasoning controls for live-discovered models. (#94461, #93241, #94067, #94136) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @openperf, @civiltox, and @BorClaw.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li><strong>Fast-mode and policy correctness:</strong> fallback cutoffs and reset notices are bounded, repeated progress events remain visible, Codex service-tier state is normalized, and trusted policies are not lost when hook registries are composed. (#85104, #94545) Thanks @alexph-dev, @vincentkoc, and @jesse-merhi.</li>
|
||||
<li><strong>Model and delivery edge cases:</strong> Zai and GLM failover paths use the right runtime metadata, while stale channel-origin state no longer leaks across session changes. (#94461, #93241, #95328) Thanks @Pandah97, @chrysb, @0xghost42, @zhengli0922, @ZengWen-DT, @jalehman, and @gorkem2020.</li>
|
||||
<li><strong>Provider plugin onboarding:</strong> setup refreshes provider plugin registry metadata after installing setup-selected provider plugins, so auth continuation uses the newly installed provider instead of stale registry state. (#95792) Thanks @snowzlmbot.</li>
|
||||
</ul>
|
||||
<h3>Complete contribution record</h3>
|
||||
This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
|
||||
<h4>Pull requests</h4>
|
||||
<ul>
|
||||
<li><strong>PR #86627</strong> Keep core doctor health in contribution order. Thanks @giodl73-repo.</li>
|
||||
<li><strong>PR #93580</strong> fix: preserve cron delivery awareness for target sessions. Thanks @scotthuang and @jalehman.</li>
|
||||
<li><strong>PR #95030</strong> refactor: add SDK transcript identity target API. Thanks @jalehman.</li>
|
||||
<li><strong>PR #94838</strong> refactor(copilot): complete harness lifecycle parity. Thanks @vincentkoc.</li>
|
||||
<li><strong>PR #95328</strong> fix(sessions): reset stale per-channel origin fields on channel switch. Related #95325. Thanks @ZengWen-DT and @jalehman and @gorkem2020.</li>
|
||||
<li><strong>PR #94461</strong> fix(zai): fall back to manifest baseUrl for synthesized GLM-5 models. Related #94269. Thanks @Pandah97 and @chrysb.</li>
|
||||
<li><strong>PR #93241</strong> fix(agents): classify Zhipu GLM overload as overloaded for failover. Related #93211. Thanks @0xghost42 and @zhengli0922.</li>
|
||||
<li><strong>PR #94067</strong> fix(channels): resolve native /think menu levels via runtime catalog for live-discovered models. Related #93835. Thanks @openperf and @civiltox.</li>
|
||||
<li><strong>PR #94136</strong> fix(zai): expose GLM-5.2 reasoning levels [AI-assisted]. Thanks @BorClaw.</li>
|
||||
<li><strong>PR #85104</strong> feat: fast talks auto mode. Related #85087. Thanks @alexph-dev.</li>
|
||||
<li><strong>PR #94545</strong> fix: keep trusted policies with hook registry. Thanks @jesse-merhi.</li>
|
||||
<li><strong>PR #95792</strong> fix(onboard): refresh provider plugin registry after setup installs. Related #95765. Thanks @snowzlmbot.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.10/OpenClaw-2026.6.10.zip" length="56115790" type="application/octet-stream" sparkle:edSignature="MEeGG8+WePhUg9uDShznmdhhAgy/WWe7bAwr4XRTauNdrM441iziQYIlwhfNrtHDHX+uE1/tkRtIMcELfuekAg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.8</title>
|
||||
<pubDate>Tue, 16 Jun 2026 17:17:20 +0000</pubDate>
|
||||
@@ -171,5 +124,132 @@ This audited record covers the complete v2026.6.9..HEAD history: 12 merged PRs.
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.5/OpenClaw-2026.6.5.zip" length="55725877" type="application/octet-stream" sparkle:edSignature="EKr7gCfpEVStis9HSADJk1CWYbmH2MHMqSgNfZvLbBFCBWmk3pjBJS6K2qkxkq5lIbTj4H+Lo7Iri6ip/xTGDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.6.1</title>
|
||||
<pubDate>Wed, 03 Jun 2026 21:26:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026060190</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.6.1</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.6.1</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Agents and CLI-backed runtimes recover more cleanly from interrupted tool calls, stale session bindings, compaction handoffs, and media delivery retries. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Channels and mobile delivery are steadier across Telegram, WhatsApp, iMessage, Slack, Discord, Microsoft Teams, Google Chat, Google Meet, and iOS realtime Talk. (#88096, #88105, #88183, #88231)</li>
|
||||
<li>Provider and plugin requests now bound more timers, retries, OAuth/device-code lifetimes, media downloads, local service probes, and generated-content polling paths before they can hang a run.</li>
|
||||
<li>Skills, session metadata, gateway runtime state, plugin metadata, memory watchers, and store writes do less repeated work on hot paths while keeping config, dispatch, and Linux file-watch behavior stable. (#89185, #89188, #85351) Thanks @RomneyDa and @NianJiuZst.</li>
|
||||
<li>Skills and plugin loading now handle stale disabled snapshots and loader failures more clearly, so channel turns avoid disabled SecretRefs and operators get better recovery guidance. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Workboard, SecretRef plugin manifests, hosted iOS push relay, and external Copilot/Tokenjuice packaging add broader orchestration, integration, and plugin delivery surfaces. (#82326, #87469, #87796, #88107, #88117)</li>
|
||||
<li>Skill Workshop now has a fuller Control UI flow with proposal lists, today actions, revision handoff, searchable file previews, review states, locale coverage, and reusable session routing.</li>
|
||||
<li>Chat and Control UI startup paths keep sends alive through history loading, stream deltas incrementally, skip markdown work while streaming, keep drafts local while typing, clear the composer after sends, trace first-output latency, prioritize first connect, and expose calmer composer controls. (#88772, #88825, #88998, #89030, #89106) Thanks @vincentkoc and @sallyom.</li>
|
||||
<li>Provider coverage and model metadata now include MiniMax M3, account OAuth endpoints, Google/Vertex catalog fixes, OpenRouter SQLite model caching, Copilot Claude 1M capabilities, Foundry reasoning alignment, and OpenAI response replay guards. (#88480, #88512, #88851, #88860)</li>
|
||||
<li>iMessage monitor state, inbound queues, and plugin install ledgers moved toward SQLite-backed state so restarts and local monitors recover with less duplicate filesystem scanning. (#88794, #88797)</li>
|
||||
<li>Release, CI, Docker, E2E, plugin install, and diagnostics lanes now cap more logs, response bodies, readiness probes, artifact checks, status polling, child workflow waits, docker package cleanup, quiet test stalls, and rollback snapshots so failures report bounded proof instead of stalling. (#88966) Thanks @RomneyDa.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Docs: add a dedicated Skill Workshop guide covering governed skill creation, reviewable proposals, CLI, Gateway, agent tool behavior, approval policy, support files, and recovery, and refresh the ClawHub showcase cards. (#88734) Thanks @shakkernerd and @vyctorbrzezowski.</li>
|
||||
<li>Skills: let the <code>skill_workshop</code> agent tool apply, reject, and quarantine explicit proposals through the guarded review flow. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let proposals carry approved support files under standard skill folders, with scanner, hash, and rollback safeguards. Thanks @shakkernerd.</li>
|
||||
<li>Skills: let pending proposals be revised in place with versioned, dated proposal frontmatter before approval. Thanks @shakkernerd.</li>
|
||||
<li>Skills: add Skill Workshop with pending proposals, CLI/Gateway review actions, rollback metadata, and the <code>skill_workshop</code> agent tool. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: add the Control UI navigation, styled dashboard, proposal today view, revision dialog, file preview modal, searchable preview files, reusable session handoff, and localized strings.</li>
|
||||
<li>Plugins: externalize Tokenjuice as the official <code>@openclaw/tokenjuice</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>Plugins: externalize the GitHub Copilot agent runtime as the official <code>@openclaw/copilot</code> plugin with npm and ClawHub publish metadata.</li>
|
||||
<li>iOS: add hosted push relay defaults, realtime Talk playback, and a guarded WebSocket ping path for more reliable mobile sessions. (#88096, #88105, #88231)</li>
|
||||
<li>iOS: support native iPad display layouts.</li>
|
||||
<li>Workboard: add orchestration primitives and agent coordination tools for multi-agent planning and run tracking. (#87469)</li>
|
||||
<li>Workboard: wire task-backed board runs and show task comments in the edit modal.</li>
|
||||
<li>Code mode: add internal namespaces for scoped agent/global sessions and exact namespace tool dispatch. (#88043)</li>
|
||||
<li>Code mode: add MCP API files and docs for code-mode integrations.</li>
|
||||
<li>Control UI: add a Dreaming-tab agent selector and propagate the selected agent through Dreaming status, diary, and diary actions. (#78748) Thanks @stevenepalmer.</li>
|
||||
<li>Control UI: add calmer chat composer controls, local draft typing state, and first-output latency instrumentation for active chat entry. (#88772, #88998) Thanks @vincentkoc.</li>
|
||||
<li>Plugins: add a SecretRef provider integration manifest contract and extract shared LLM core packages for provider/plugin reuse. (#82326, #88117)</li>
|
||||
<li>Plugins: persist the plugin install index in SQLite so installed package lookup survives reloads with less filesystem scanning. (#88794)</li>
|
||||
<li>Providers: add MiniMax M3 model support. (#88860)</li>
|
||||
<li>Doctor: add disk space health checks and stabilize post-upgrade JSON probes.</li>
|
||||
<li>Channels: store inbound queues in SQLite and migrate iMessage monitor state to SQLite-backed tracking. (#88797)</li>
|
||||
<li>Skills: add the core skills index and centralize skills runtime loading, status, filtering, and prompt formatting.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.</li>
|
||||
<li>Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.</li>
|
||||
<li>Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.</li>
|
||||
<li>Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.</li>
|
||||
<li>Scripts/UI: forward direct wrapper SIGHUP shutdown to child processes so terminal hangups do not leave wrapped dev commands running.</li>
|
||||
<li>Gateway: return the post-expiration pending-work revision from node drains so reconnecting nodes do not observe stale queue revisions after expired items are pruned.</li>
|
||||
<li>Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.</li>
|
||||
<li>Release/CI/E2E: normalize inherited Linux <code>C.UTF-8</code> locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.</li>
|
||||
<li>Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.</li>
|
||||
<li>Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.</li>
|
||||
<li>Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as <code>null</code> or arrays.</li>
|
||||
<li>Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.</li>
|
||||
<li>Talk: preserve explicit <code>null</code> payloads on controller-created turn and output-audio lifecycle events.</li>
|
||||
<li>Agents/TUI: keep local custom provider runs from loading plugin runtime and auth alias metadata when plugins are disabled.</li>
|
||||
<li>Agents/TUI: restore in-flight TUI run switch-back behavior, keep no-policy native hook fallback available, guard vanished workspaces, and keep lightweight isolated subagents lightweight.</li>
|
||||
<li>Agents/media: keep async image, music, and video generation starts from ending the Codex turn, so mixed requests can continue with summaries or other work while media renders in the background.</li>
|
||||
<li>Agents/Codex: keep public OpenAI API-key profiles from being treated as native Codex app-server auth while preserving persisted Codex OAuth sessions.</li>
|
||||
<li>Agents/Codex: stream Codex app-server final-answer partials to live reply previews, preserve ACP metadata in SQLite, prefer real tool results over synthetic repair output, prevent aborted app-server turn handles from lingering, migrate legacy OpenAI Codex <code>lastGood</code> auth state, and preserve workspace/session metadata through ACP runtime refactors. (#88405, #88724, #88730) Thanks @vincentkoc.</li>
|
||||
<li>Control UI: keep collapsed tool cards labeled with the tool name and action instead of generic output text. Thanks @shakkernerd.</li>
|
||||
<li>Agents/Codex: surface Skill Workshop guidance in Codex app-server prompts when <code>skill_workshop</code> is available. Thanks @shakkernerd.</li>
|
||||
<li>Skill Workshop: restore and localize the Control UI board/today view switcher so review workflows keep their intended layout toggle across locales. Thanks @shakkernerd.</li>
|
||||
<li>Agents/auth: write auth profiles atomically, dispatch auth failures by type, add force re-login recovery, preserve workspaces during state-only uninstall, and compact before oversized turns so recovery paths avoid partial state. (#89181) Thanks @RomneyDa.</li>
|
||||
<li>Skills: skip disabled skill env overrides from stale persisted snapshots so disabled skill <code>apiKey</code> SecretRefs cannot abort embedded or channel turns. (#79072, #79173) Thanks @zeus1959.</li>
|
||||
<li>Skill Workshop: render the Control UI tab from filtered navigation state and keep filtered fallback routing stable.</li>
|
||||
<li>CLI: avoid live catalog validation during <code>openclaw agents add</code>, so adding a secondary agent no longer depends on provider catalog availability. (#76284, #88314) Thanks @zhangguiping-xydt.</li>
|
||||
<li>CLI: keep <code>plugins list --json</code> on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.</li>
|
||||
<li>CLI/desktop: bridge WSL clipboard operations through the shell, recognize manual-update launchd jobs, and keep machine-readable startup output parseable during progress setup. (#88764, #88689) Thanks @alexzhu0.</li>
|
||||
<li>Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.</li>
|
||||
<li>Plugins: clarify plugin loader failure guidance so missing or incompatible plugin packages point operators at the right repair path.</li>
|
||||
<li>Plugins: preserve npm plugin roots after blocked installs, skip plugin-local <code>openclaw</code> peer symlinks during rollback snapshots, relink those peers after restore, isolate cached tool runtime siblings, and isolate web-provider factory failures so one bad plugin does not poison sibling runtime paths. (#77237, #88807)</li>
|
||||
<li>Cron: keep SQLite cron migrations compatible with legacy run-log tables, archived job stores, diagnostic cron names, and legacy one-shot delete-after-run behavior. (#88285)</li>
|
||||
<li>Cron: keep update delivery validation scoped, harden restart state, and retire MCP runtimes on isolated cron cleanup.</li>
|
||||
<li>Memory: serialize QMD update/embed writes per store, reduce Linux watcher fan-out, retry transient FileProvider-backed reads, preserve phase signals on read errors, harden envelope metadata sanitization, reattach Linux native watchers when directories are recreated, and rewrite generated transcript paths on rollover so memory/search state survives concurrent gateway and CLI activity. (#66339, #85931, #89185, #89188, #85351) Thanks @openperf, @amittell, @RomneyDa, and @NianJiuZst.</li>
|
||||
<li>Memory: keep vector-disabled FTS indexes from resolving embedding providers during sync and search.</li>
|
||||
<li>Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.</li>
|
||||
<li>Providers: resolve Google defaults to <code>google-generative-ai</code>, register Vertex static catalog rows, align Foundry reasoning metadata, skip DeepSeek V4 thinking params on Foundry fallback, use MiniMax account OAuth endpoints, preserve Copilot Claude 1M capabilities, suppress disabled Ollama reasoning output, forward Gemini stop sequences, strip Kimi-incompatible Anthropic cache markers, keep OpenAI stop-finished tool calls, and avoid replay ids when the Responses store is disabled. (#88480, #88512, #76612) Thanks @coder999999999, @BryanTegomoh, and @vliuyt.</li>
|
||||
<li>Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.</li>
|
||||
<li>Cron: retry recurring jobs after transient model rate limits before waiting for the next scheduled slot.</li>
|
||||
<li>Agents/Codex: keep live session locks during cleanup, recover interrupted CLI tool transcripts, preserve Codex auth and compaction session identity, clear orphan tool state, cap app-server idle timers, and keep media completion delivery retryable. (#88129, #88136, #88141, #88162, #88182)</li>
|
||||
<li>Chat/UI: show Gateway chat failures as visible assistant messages in the Control UI instead of only setting an invisible error state.</li>
|
||||
<li>Channels: cap Telegram, Discord, WhatsApp, Signal, Feishu, Google Chat, Microsoft Teams, QQBot, Nostr, Zalo, Zalouser, and Nextcloud-style request/retry timers; preserve SMS approval reply routes; and retry WhatsApp QR login 408 timeouts. (#88183)</li>
|
||||
<li>Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.</li>
|
||||
<li>Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.</li>
|
||||
<li>Release/CI/E2E: bound release candidate reads, beta smoke REST calls, plugin npm verification commands, changelog restore, cross-OS process groups, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Telegram credential timeouts, Control UI i18n and CLI startup metadata generation, Vitest routing, dependency guard admin approvals, child workflow failure detection, quiet Node test shard stalls, docker package cleanup, and mainline test flakes. (#88127, #88137, #88155, #88160, #88966) Thanks @RomneyDa.</li>
|
||||
<li>Release/CI/E2E: keep Kitchen Sink live plugin MCP probes resolving source-checkout workspace packages and align the live gauntlet with current Kitchen Sink diagnostics.</li>
|
||||
<li>Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.</li>
|
||||
<li>Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.</li>
|
||||
<li>Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.</li>
|
||||
<li>Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.</li>
|
||||
<li>Agents: accept hidden <code>sessions_send</code> body aliases before validation while keeping the model-facing <code>message</code> schema canonical. (#88229) Thanks @zhangguiping-xydt.</li>
|
||||
<li>Chat/UI: preserve startup chat sends during history loading, unblock the initial Control UI chat send, stream chat deltas incrementally, skip markdown parsing while streaming, keep drafts local while typing, guard composer rerenders, honor Chromium executable overrides, and detect system Chromium for E2E. (#88998) Thanks @vincentkoc.</li>
|
||||
<li>Channels: stop schema-padded poll modifiers from turning normal <code>send</code> actions into invalid poll sends. (#89601) Thanks @codezz.</li>
|
||||
<li>Channels: preserve long Feishu streaming replies, send visible fallbacks when accepted Feishu turns produce no final reply, tolerate iMessage self-chat timestamp skew, preserve colon-prefixed slash commands in mention parsing, decode Nostr <code>npub</code> allowlists correctly, and suppress raw provider errors during channel delivery. (#87896)</li>
|
||||
<li>Config/status/doctor: skip unresolved shell references in state-dir dotenv files, resolve gateway auth secrets during deep status audits, respect explicit PI runtime policy, report runtime tool-schema errors, and keep post-upgrade JSON stable. (#88288)</li>
|
||||
<li>Gateway/session state: list commands from the Gateway plugin registry, harden MCP loopback tool schemas, hide phantom agent-store rows from <code>sessions.list</code>, make task persistence failures explicit, and carry session UUIDs on interactive dispatch events.</li>
|
||||
<li>Gateway/plugins: narrow plugin lookup memoization to the stable plugin/runtime inputs, avoiding repeated lookup work without mixing disabled or filtered plugin state.</li>
|
||||
<li>OpenAI/TTS: handle speed directives for OpenAI TTS voices. (#74089)</li>
|
||||
<li>CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.</li>
|
||||
<li>CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.</li>
|
||||
<li>CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.</li>
|
||||
<li>CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.</li>
|
||||
<li>CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.</li>
|
||||
<li>CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.</li>
|
||||
<li>CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.</li>
|
||||
<li>CI/tooling: route script edits through conventional owner tests when matching <code>test/scripts</code> or <code>src/scripts</code> coverage already exists.</li>
|
||||
<li>CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.</li>
|
||||
<li>Release/CI/E2E: assert plugin lifecycle runtime inspect output instead of only capturing it.</li>
|
||||
<li>Release/CI/E2E: make gateway-network prove the advertised health RPC and retry early WebSocket closes without burning full open timeouts.</li>
|
||||
<li>Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.</li>
|
||||
<li>Release/CI/E2E: fail plugin gateway gauntlet QA chunks when the requested suite summary is missing or invalid.</li>
|
||||
<li>Performance: prebuild QA runtime probes with generated plugin assets but without CLI startup metadata.</li>
|
||||
<li>Performance: skip declaration bundling for runtime-only CLI startup and gateway watch build profiles.</li>
|
||||
<li>Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, single-entry store writes, and validated/serialized session prompt blobs.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.6.1/OpenClaw-2026.6.1.zip" length="55062100" type="application/octet-stream" sparkle:edSignature="PVp8E2HBCvikB/0LCr36lFEyHPAzoFA2ScT6LW27FlzvP+m4r1AEuVN2UrtgWlpkGSsn4Eav0kPJe32u4ObNBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,38 +56,6 @@ Recommended workflow:
|
||||
|
||||
The third-party flavor is archived as a signed APK for non-Play distribution. It is not uploaded by the Play release lane.
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
Successful Play build uploads create a non-tag Git ref that records the source
|
||||
commit for the uploaded store build:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/android/<versionName>-<versionCode>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/android/2026.6.10-2026061008
|
||||
```
|
||||
|
||||
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
|
||||
not appear on GitHub release or tag pages, and they do not participate in the
|
||||
core OpenClaw release machinery.
|
||||
|
||||
`pnpm android:release:upload` checks the ref before uploading the Play build and
|
||||
records it only after `upload_to_play_store` succeeds. Existing refs are
|
||||
immutable: the same ref at the same SHA is accepted, while the same ref at a
|
||||
different SHA fails. `GOOGLE_PLAY_VALIDATE_ONLY=1` still checks the ref but does
|
||||
not record it because no Play build is published.
|
||||
|
||||
Useful direct commands:
|
||||
|
||||
```bash
|
||||
pnpm mobile:release:preflight -- --platform android --version 2026.6.10 --version-code 2026061008
|
||||
pnpm mobile:release:resolve -- --platform android --version 2026.6.10 --version-code 2026061008
|
||||
```
|
||||
|
||||
## Signing model
|
||||
|
||||
`apps/android/Config/ReleaseSigning.json` pins the Android signing assets in the shared private `apps-signing` repo. The Android pipeline uses the same `MATCH_PASSWORD` release-owner secret as iOS, but the Android files are managed by `scripts/android-release-signing.mjs` instead of Fastlane `match`.
|
||||
|
||||
@@ -198,58 +198,6 @@ def capture_android_screenshots!
|
||||
sh(shell_join(["bash", File.join(repo_root, "scripts", "android-screenshots.sh")]))
|
||||
end
|
||||
|
||||
def mobile_release_ref_script
|
||||
File.join(repo_root, "scripts", "mobile-release-ref.ts")
|
||||
end
|
||||
|
||||
def release_git_sha
|
||||
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
|
||||
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
args = [
|
||||
"node",
|
||||
"--import",
|
||||
"tsx",
|
||||
mobile_release_ref_script,
|
||||
command,
|
||||
"--platform",
|
||||
platform,
|
||||
"--version",
|
||||
version,
|
||||
"--root",
|
||||
repo_root,
|
||||
]
|
||||
args.push("--build", build.to_s) if build
|
||||
args.push("--version-code", version_code.to_s) if version_code
|
||||
args.push("--sha", sha.to_s) if sha
|
||||
sh(shell_join(args))
|
||||
end
|
||||
|
||||
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"preflight",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"record",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def read_android_release_signing_properties!(path)
|
||||
UI.user_error!("Missing materialized Android release signing properties at #{path}.") unless File.exist?(path)
|
||||
|
||||
@@ -334,13 +282,6 @@ def upload_play_store_metadata!(version_metadata)
|
||||
end
|
||||
|
||||
def upload_play_store_build!(version_metadata, upload_metadata: false, upload_images: false, upload_screenshots: false)
|
||||
release_sha = release_git_sha
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "android",
|
||||
version: version_metadata.fetch(:version),
|
||||
version_code: version_metadata.fetch(:version_code),
|
||||
sha: release_sha
|
||||
)
|
||||
ENV["SUPPLY_UPLOAD_SCREENSHOTS"] = "1" if upload_screenshots
|
||||
validate_android_screenshots!
|
||||
sync_android_changelog!(version_metadata.fetch(:version_code))
|
||||
@@ -361,15 +302,6 @@ def upload_play_store_build!(version_metadata, upload_metadata: false, upload_im
|
||||
skip_upload_screenshots: !upload_screenshots,
|
||||
validate_only: play_validate_only?
|
||||
)
|
||||
|
||||
unless play_validate_only?
|
||||
record_mobile_release_ref!(
|
||||
platform: "android",
|
||||
version: version_metadata.fetch(:version),
|
||||
version_code: version_metadata.fetch(:version_code),
|
||||
sha: release_sha
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
load_env_file(File.join(ANDROID_FASTLANE_ROOT, ".env"))
|
||||
|
||||
@@ -129,37 +129,6 @@ pnpm ios:version:pin -- --version 2026.4.10
|
||||
|
||||
This keeps the TestFlight version stable while review is in flight.
|
||||
|
||||
## Release SHA tracking
|
||||
|
||||
Successful App Store Connect uploads create a non-tag Git ref that records the
|
||||
source commit for the uploaded store build:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/<CFBundleShortVersionString>-<CFBundleVersion>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
refs/openclaw/mobile-releases/ios/2026.6.10-8
|
||||
```
|
||||
|
||||
These refs are intentionally outside `refs/tags/*` and `refs/heads/*`. They do
|
||||
not appear on GitHub release or tag pages, and they do not participate in the
|
||||
core OpenClaw release machinery.
|
||||
|
||||
`pnpm ios:release:upload` checks the ref before archive/upload work and records
|
||||
it only after `upload_to_testflight` succeeds. Existing refs are immutable: the
|
||||
same ref at the same SHA is accepted, while the same ref at a different SHA
|
||||
fails.
|
||||
|
||||
Useful direct commands:
|
||||
|
||||
```bash
|
||||
pnpm mobile:release:preflight -- --platform ios --version 2026.6.10 --build 8
|
||||
pnpm mobile:release:resolve -- --platform ios --version 2026.6.10 --build 8
|
||||
```
|
||||
|
||||
## New release promotion workflow
|
||||
|
||||
When you want the next production iOS release to align with the current gateway release:
|
||||
|
||||
@@ -1128,58 +1128,6 @@ def prepare_app_store_release!(version:, build_number:)
|
||||
release_xcconfig
|
||||
end
|
||||
|
||||
def mobile_release_ref_script
|
||||
File.join(repo_root, "scripts", "mobile-release-ref.ts")
|
||||
end
|
||||
|
||||
def release_git_sha
|
||||
stdout, stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: repo_root)
|
||||
UI.user_error!("Unable to resolve release Git SHA: #{stderr.strip}") unless status.success?
|
||||
stdout.strip
|
||||
end
|
||||
|
||||
def mobile_release_ref_command(command, platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
args = [
|
||||
"node",
|
||||
"--import",
|
||||
"tsx",
|
||||
mobile_release_ref_script,
|
||||
command,
|
||||
"--platform",
|
||||
platform,
|
||||
"--version",
|
||||
version,
|
||||
"--root",
|
||||
repo_root,
|
||||
]
|
||||
args.push("--build", build.to_s) if build
|
||||
args.push("--version-code", version_code.to_s) if version_code
|
||||
args.push("--sha", sha.to_s) if sha
|
||||
sh(shell_join(args))
|
||||
end
|
||||
|
||||
def ensure_mobile_release_ref_available!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"preflight",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def record_mobile_release_ref!(platform:, version:, build: nil, version_code: nil, sha: nil)
|
||||
mobile_release_ref_command(
|
||||
"record",
|
||||
platform: platform,
|
||||
version: version,
|
||||
build: build,
|
||||
version_code: version_code,
|
||||
sha: sha
|
||||
)
|
||||
end
|
||||
|
||||
def validate_app_store_ipa!(ipa_path)
|
||||
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
|
||||
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
|
||||
@@ -1361,22 +1309,15 @@ platform :ios do
|
||||
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
|
||||
end
|
||||
|
||||
release_sha = release_git_sha
|
||||
release_signing_check!
|
||||
preserve_local_signing do
|
||||
screenshots
|
||||
end
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
ensure_mobile_release_ref_available!(
|
||||
platform: "ios",
|
||||
version: context[:short_version],
|
||||
build: context[:build_number],
|
||||
sha: release_sha
|
||||
)
|
||||
ENV["DELIVER_SCREENSHOTS"] = "1"
|
||||
ENV["DELIVER_RELEASE_NOTES"] = "1"
|
||||
metadata
|
||||
|
||||
context = prepare_app_store_context(require_api_key: true)
|
||||
build = build_app_store_release(context)
|
||||
|
||||
upload_to_testflight(
|
||||
@@ -1385,12 +1326,6 @@ platform :ios do
|
||||
skip_waiting_for_build_processing: true,
|
||||
uses_non_exempt_encryption: false
|
||||
)
|
||||
record_mobile_release_ref!(
|
||||
platform: "ios",
|
||||
version: build[:short_version],
|
||||
build: build[:build_number],
|
||||
sha: release_sha
|
||||
)
|
||||
|
||||
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
|
||||
UI.important("App Review submission remains manual in App Store Connect.")
|
||||
|
||||
@@ -54,9 +54,8 @@ openclaw plugins update <id-or-npm-spec>
|
||||
openclaw plugins update --all
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
openclaw plugins marketplace list <marketplace> --json
|
||||
openclaw plugins init my-tool --name "My Tool"
|
||||
openclaw plugins init my-provider --name "My Provider" --type provider
|
||||
openclaw plugins init my-provider --name "My Provider" --type provider --directory ./my-provider
|
||||
openclaw plugins init <id>
|
||||
openclaw plugins init <id> --directory ./my-plugin --name "My Plugin"
|
||||
openclaw plugins build --entry ./dist/index.js
|
||||
openclaw plugins build --entry ./dist/index.js --check
|
||||
openclaw plugins validate --entry ./dist/index.js
|
||||
@@ -87,15 +86,12 @@ npm run plugin:build
|
||||
npm run plugin:validate
|
||||
```
|
||||
|
||||
`plugins init` creates a minimal TypeScript tool plugin by default. The first
|
||||
argument is the plugin id; pass `--name` for the display name. OpenClaw uses the
|
||||
id for the default output directory and package naming. Tool scaffolds use
|
||||
`defineToolPlugin`.
|
||||
`plugins build` imports the built entry, reads its static tool metadata, writes
|
||||
`openclaw.plugin.json`, and keeps `package.json` `openclaw.extensions` aligned.
|
||||
`plugins validate` checks that the generated manifest, package metadata, and
|
||||
current entry export still agree. See [Tool Plugins](/plugins/tool-plugins) for
|
||||
the full tool-authoring workflow.
|
||||
`plugins init` creates a minimal TypeScript tool plugin that uses
|
||||
`defineToolPlugin`. `plugins build` imports that entry, reads its static tool
|
||||
metadata, writes `openclaw.plugin.json`, and keeps `package.json`
|
||||
`openclaw.extensions` aligned. `plugins validate` checks that the generated
|
||||
manifest, package metadata, and current entry export still agree. See
|
||||
[Tool Plugins](/plugins/tool-plugins) for the full authoring workflow.
|
||||
|
||||
The scaffold writes TypeScript source but generates metadata from the built
|
||||
`./dist/index.js` entry so the workflow also works with the published CLI. Use
|
||||
@@ -103,29 +99,6 @@ The scaffold writes TypeScript source but generates metadata from the built
|
||||
`plugins build --check` in CI to fail when generated metadata is stale without
|
||||
rewriting files.
|
||||
|
||||
### Provider Scaffold
|
||||
|
||||
```bash
|
||||
openclaw plugins init acme-models --name "Acme Models" --type provider
|
||||
cd acme-models
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
npm run validate
|
||||
```
|
||||
|
||||
Provider scaffolds create a generic text/model provider plugin with OpenAI-compatible
|
||||
API-key plumbing, a built-in `npm run validate` script for `clawhub package
|
||||
validate`, ClawHub package metadata, and a manually dispatched GitHub workflow
|
||||
for future trusted publishing through GitHub Actions OIDC. Provider scaffolds do
|
||||
not generate skills and do not use `openclaw plugins build` or
|
||||
`openclaw plugins validate`; those commands are for the tool scaffold's
|
||||
generated metadata path.
|
||||
|
||||
Before publishing, replace the placeholder API base URL, model catalog, docs
|
||||
route, credential text, and README copy with real provider details. Use the
|
||||
generated README for first-time ClawHub publishing and trusted publisher setup.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
readCodexNotificationItem,
|
||||
readNotificationItemId,
|
||||
shouldDisarmAssistantCompletionIdleWatch,
|
||||
updateActiveCompletionBlockerItemIds,
|
||||
updateActiveTurnItemIds,
|
||||
} from "./attempt-notifications.js";
|
||||
import { CODEX_POST_REASONING_REPLY_IDLE_TIMEOUT_MS } from "./attempt-timeouts.js";
|
||||
@@ -93,7 +92,6 @@ export function applyCodexTurnNotificationState(params: {
|
||||
currentPromptTexts: string[];
|
||||
turnWatches: CodexAttemptTurnWatchController;
|
||||
activeTurnItemIds: Set<string>;
|
||||
activeCompletionBlockerItemIds: Set<string>;
|
||||
activeAppServerTurnRequests: number;
|
||||
pendingOpenClawDynamicToolCompletionIds: Set<string>;
|
||||
turnCrossedToolHandoff: boolean;
|
||||
@@ -123,7 +121,6 @@ export function applyCodexTurnNotificationState(params: {
|
||||
});
|
||||
params.onReportExecutionNotification(notification);
|
||||
updateActiveTurnItemIds(notification, params.activeTurnItemIds);
|
||||
updateActiveCompletionBlockerItemIds(notification, params.activeCompletionBlockerItemIds);
|
||||
if (notification.method === "item/completed" && params.activeTurnItemIds.size === 0) {
|
||||
params.onScheduleTerminalDynamicToolReleaseCheck();
|
||||
}
|
||||
|
||||
@@ -63,45 +63,6 @@ export function updateActiveTurnItemIds(
|
||||
activeItemIds.delete(itemId);
|
||||
}
|
||||
|
||||
export function updateActiveCompletionBlockerItemIds(
|
||||
notification: CodexServerNotification,
|
||||
activeItemIds: Set<string>,
|
||||
): void {
|
||||
if (notification.method !== "item/started" && notification.method !== "item/completed") {
|
||||
return;
|
||||
}
|
||||
const itemId = readNotificationItemId(notification);
|
||||
if (!itemId) {
|
||||
return;
|
||||
}
|
||||
if (notification.method === "item/completed") {
|
||||
activeItemIds.delete(itemId);
|
||||
return;
|
||||
}
|
||||
const item = readCodexNotificationItem(notification.params);
|
||||
if (item && isCompletionBlockingItem(item)) {
|
||||
activeItemIds.add(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
function isCompletionBlockingItem(item: CodexThreadItem): boolean {
|
||||
// Codex emits paired item/started and item/completed notifications for these
|
||||
// execution items. Completion must not time out while any pair is still open.
|
||||
switch (item.type) {
|
||||
case "collabAgentToolCall":
|
||||
case "commandExecution":
|
||||
case "dynamicToolCall":
|
||||
case "fileChange":
|
||||
case "imageGeneration":
|
||||
case "imageView":
|
||||
case "mcpToolCall":
|
||||
case "webSearch":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isCompletedAssistantNotification(notification: CodexServerNotification): boolean {
|
||||
if (!isJsonObject(notification.params)) {
|
||||
return false;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Codex tests cover attempt turn watches plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { updateActiveCompletionBlockerItemIds } from "./attempt-notifications.js";
|
||||
import { createCodexAttemptTurnWatchController } from "./attempt-turn-watches.js";
|
||||
|
||||
describe("Codex app-server attempt turn watches", () => {
|
||||
@@ -24,7 +23,6 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
let terminalQueued = false;
|
||||
let activeRequests = 0;
|
||||
let activeItems = 0;
|
||||
let activeCompletionBlockers = 0;
|
||||
const interrupts: Array<Record<string, unknown>> = [];
|
||||
const timeouts: Array<Record<string, unknown>> = [];
|
||||
const events: Array<{ name: string; fields: Record<string, unknown> }> = [];
|
||||
@@ -38,7 +36,6 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
isTerminalTurnNotificationQueued: () => terminalQueued,
|
||||
getActiveAppServerTurnRequests: () => activeRequests,
|
||||
getActiveTurnItemCount: () => activeItems,
|
||||
getActiveCompletionBlockerItemCount: () => activeCompletionBlockers,
|
||||
turnCompletionIdleTimeoutMs: 10,
|
||||
turnAssistantCompletionIdleTimeoutMs: 10,
|
||||
turnAttemptIdleTimeoutMs: 10,
|
||||
@@ -72,9 +69,6 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
set activeItems(value: number) {
|
||||
activeItems = value;
|
||||
},
|
||||
set activeCompletionBlockers(value: number) {
|
||||
activeCompletionBlockers = value;
|
||||
},
|
||||
interrupts,
|
||||
timeouts,
|
||||
events,
|
||||
@@ -161,32 +155,6 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for active completion blocker items before firing completion idle timeout", () => {
|
||||
const harness = createController();
|
||||
harness.activeCompletionBlockers = 1;
|
||||
|
||||
harness.controller.touchActivity("request:mcpServer/elicitation/request:response", {
|
||||
arm: true,
|
||||
});
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toEqual([]);
|
||||
expect(harness.abortController.signal.aborted).toBe(false);
|
||||
|
||||
harness.activeCompletionBlockers = 0;
|
||||
harness.controller.touchActivity("notification:item/completed");
|
||||
vi.advanceTimersByTime(10);
|
||||
|
||||
expect(harness.timeouts).toMatchObject([
|
||||
{
|
||||
kind: "completion",
|
||||
idleMs: 10,
|
||||
timeoutMs: 10,
|
||||
lastActivityReason: "notification:item/completed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("releases a completed assistant item after the assistant idle guard expires", () => {
|
||||
const harness = createController();
|
||||
|
||||
@@ -246,41 +214,3 @@ describe("Codex app-server attempt turn watches", () => {
|
||||
expect(harness.abortController.signal.reason).toBe("turn_progress_idle_timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Codex completion blocker item tracking", () => {
|
||||
it.each([
|
||||
"collabAgentToolCall",
|
||||
"commandExecution",
|
||||
"dynamicToolCall",
|
||||
"fileChange",
|
||||
"imageGeneration",
|
||||
"imageView",
|
||||
"mcpToolCall",
|
||||
"webSearch",
|
||||
])("tracks the %s lifecycle", (type) => {
|
||||
const activeItemIds = new Set<string>();
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/started", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set(["item-1"]));
|
||||
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/completed", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it.each(["agentMessage", "contextCompaction", "plan", "reasoning", "subAgentActivity"])(
|
||||
"does not track the %s lifecycle",
|
||||
(type) => {
|
||||
const activeItemIds = new Set<string>();
|
||||
updateActiveCompletionBlockerItemIds(
|
||||
{ method: "item/started", params: { item: { id: "item-1", type } } },
|
||||
activeItemIds,
|
||||
);
|
||||
expect(activeItemIds).toEqual(new Set());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -36,7 +36,6 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
isTerminalTurnNotificationQueued: () => boolean;
|
||||
getActiveAppServerTurnRequests: () => number;
|
||||
getActiveTurnItemCount: () => number;
|
||||
getActiveCompletionBlockerItemCount: () => number;
|
||||
turnCompletionIdleTimeoutMs: number;
|
||||
turnAssistantCompletionIdleTimeoutMs: number;
|
||||
turnAttemptIdleTimeoutMs: number;
|
||||
@@ -122,8 +121,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isCompleted() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -185,8 +183,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
@@ -305,8 +302,7 @@ export function createCodexAttemptTurnWatchController(params: {
|
||||
params.isTerminalTurnNotificationQueued() ||
|
||||
params.signal.aborted ||
|
||||
!completionIdleWatchArmed ||
|
||||
params.getActiveAppServerTurnRequests() > 0 ||
|
||||
params.getActiveCompletionBlockerItemCount() > 0
|
||||
params.getActiveAppServerTurnRequests() > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1578,7 +1578,6 @@ export async function runCodexAppServerAttempt(
|
||||
let activeAppServerTurnRequests = 0;
|
||||
const pendingOpenClawDynamicToolCompletionIds = new Set<string>();
|
||||
const activeTurnItemIds = new Set<string>();
|
||||
const activeCompletionBlockerItemIds = new Set<string>();
|
||||
let turnCrossedToolHandoff = false;
|
||||
let pendingTerminalDynamicToolRelease:
|
||||
| {
|
||||
@@ -1628,7 +1627,6 @@ export async function runCodexAppServerAttempt(
|
||||
isTerminalTurnNotificationQueued: () => terminalTurnNotificationQueued,
|
||||
getActiveAppServerTurnRequests: () => activeAppServerTurnRequests,
|
||||
getActiveTurnItemCount: () => activeTurnItemIds.size,
|
||||
getActiveCompletionBlockerItemCount: () => activeCompletionBlockerItemIds.size,
|
||||
turnCompletionIdleTimeoutMs,
|
||||
turnAssistantCompletionIdleTimeoutMs,
|
||||
turnAttemptIdleTimeoutMs,
|
||||
@@ -1901,7 +1899,6 @@ export async function runCodexAppServerAttempt(
|
||||
currentPromptTexts: [codexTurnPromptText],
|
||||
turnWatches,
|
||||
activeTurnItemIds,
|
||||
activeCompletionBlockerItemIds,
|
||||
activeAppServerTurnRequests,
|
||||
pendingOpenClawDynamicToolCompletionIds,
|
||||
turnCrossedToolHandoff,
|
||||
|
||||
@@ -49,7 +49,9 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
|
||||
web_search: "disabled",
|
||||
});
|
||||
|
||||
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
|
||||
function writeCodexAppServerBinding(
|
||||
...args: Parameters<typeof writeRawCodexAppServerBinding>
|
||||
) {
|
||||
const [sessionFile, binding, lookup] = args;
|
||||
return writeRawCodexAppServerBinding(
|
||||
sessionFile,
|
||||
@@ -76,7 +78,6 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
isTerminalTurnNotificationQueued: () => false,
|
||||
getActiveAppServerTurnRequests: () => 0,
|
||||
getActiveTurnItemCount: () => 0,
|
||||
getActiveCompletionBlockerItemCount: () => 0,
|
||||
turnCompletionIdleTimeoutMs: 500,
|
||||
turnAssistantCompletionIdleTimeoutMs: 500,
|
||||
turnAttemptIdleTimeoutMs: 200,
|
||||
@@ -806,93 +807,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an eliciting MCP tool active past the completion timeout", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const bridgedResponse = {
|
||||
action: "accept",
|
||||
content: null,
|
||||
_meta: null,
|
||||
} as const;
|
||||
vi.spyOn(elicitationBridge, "handleCodexAppServerElicitationRequest").mockResolvedValue(
|
||||
bridgedResponse,
|
||||
);
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session-mcp-elicitation.jsonl"),
|
||||
path.join(tempDir, "workspace-mcp-elicitation"),
|
||||
);
|
||||
params.timeoutMs = 500;
|
||||
|
||||
let settled = false;
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
turnCompletionIdleTimeoutMs: 15,
|
||||
turnAssistantCompletionIdleTimeoutMs: 1_000,
|
||||
turnTerminalIdleTimeoutMs: 1_000,
|
||||
}).finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
id: "mcp-1",
|
||||
type: "mcpToolCall",
|
||||
server: "computer-use",
|
||||
tool: "computer",
|
||||
status: "inProgress",
|
||||
arguments: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.handleServerRequest({
|
||||
id: "request-mcp-elicitation",
|
||||
method: "mcpServer/elicitation/request",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
mode: "form",
|
||||
message: "Approve?",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
serverName: "computer-use",
|
||||
_meta: null,
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(bridgedResponse);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
await harness.notify({
|
||||
method: "item/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
id: "mcp-1",
|
||||
type: "mcpToolCall",
|
||||
server: "computer-use",
|
||||
tool: "computer",
|
||||
status: "completed",
|
||||
arguments: {},
|
||||
result: { content: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(false);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(result.promptError).toBeNull();
|
||||
});
|
||||
|
||||
it("counts pending user input requests as turn attempt progress", async () => {
|
||||
const harness = createStartedThreadHarness();
|
||||
const params = createParams(
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.6.27
|
||||
|
||||
### Fixes
|
||||
|
||||
- Matrix: remove the injectable Node bootstrap command path from the packaged crypto runtime bootstrap while preserving the fixed dependency repair behavior.
|
||||
|
||||
## 2026.6.10
|
||||
|
||||
### Changes
|
||||
|
||||
4
extensions/matrix/npm-shrinkwrap.json
generated
4
extensions/matrix/npm-shrinkwrap.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.27",
|
||||
"dependencies": {
|
||||
"@matrix-org/matrix-sdk-crypto-nodejs": "0.6.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "18.3.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.6.10",
|
||||
"version": "2026.6.27",
|
||||
"description": "OpenClaw Matrix channel plugin for rooms and direct messages.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -56,55 +56,54 @@ function resolveTestNativeBindingFilename(): string | null {
|
||||
|
||||
describe("ensureMatrixCryptoRuntime", () => {
|
||||
it("returns immediately when matrix SDK loads", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const requireFn = vi.fn(() => ({}));
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps missing crypto runtime and retries matrix SDK load", async () => {
|
||||
let bootstrapped = false;
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-crypto-bootstrap-"));
|
||||
const scriptPath = path.join(tmpDir, "download-lib.js");
|
||||
const markerPath = path.join(tmpDir, "bootstrapped");
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
'const fs = require("node:fs");',
|
||||
`if (fs.realpathSync(process.cwd()) !== ${JSON.stringify(fs.realpathSync(tmpDir))}) process.exit(2);`,
|
||||
'if (process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT !== "0") process.exit(3);',
|
||||
`fs.writeFileSync(${JSON.stringify(markerPath)}, "ok");`,
|
||||
].join("\n"),
|
||||
);
|
||||
const requireFn = vi.fn(() => {
|
||||
if (!bootstrapped) {
|
||||
if (!fs.existsSync(markerPath)) {
|
||||
throw new Error(
|
||||
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const runCommand = vi.fn(async () => {
|
||||
bootstrapped = true;
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
try {
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
resolveFn: () => scriptPath,
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledWith({
|
||||
argv: ["/usr/bin/node", "/tmp/download-lib.js"],
|
||||
cwd: "/tmp",
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
});
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
expect(fs.readFileSync(markerPath, "utf8")).toBe("ok");
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rethrows non-crypto module errors without bootstrapping", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const requireFn = vi.fn(() => {
|
||||
throw new Error("Cannot find module 'not-the-matrix-crypto-runtime'");
|
||||
});
|
||||
@@ -113,13 +112,10 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => "/tmp/download-lib.js",
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
}),
|
||||
).rejects.toThrow("Cannot find module 'not-the-matrix-crypto-runtime'");
|
||||
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(requireFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -132,38 +128,39 @@ describe("ensureMatrixCryptoRuntime", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "matrix-crypto-runtime-"));
|
||||
const scriptPath = path.join(tmpDir, "download-lib.js");
|
||||
const nativeBindingPath = path.join(tmpDir, nativeBindingFilename);
|
||||
fs.writeFileSync(scriptPath, "");
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
[
|
||||
'const fs = require("node:fs");',
|
||||
`fs.writeFileSync(${JSON.stringify(nativeBindingPath)}, Buffer.alloc(1_000_000));`,
|
||||
].join("\n"),
|
||||
);
|
||||
fs.writeFileSync(nativeBindingPath, Buffer.alloc(16));
|
||||
|
||||
let bootstrapped = false;
|
||||
const requireFn = vi.fn(() => {
|
||||
if (!bootstrapped) {
|
||||
if (!fs.existsSync(nativeBindingPath) || fs.statSync(nativeBindingPath).size < 1_000_000) {
|
||||
throw new Error(
|
||||
"Cannot find module '@matrix-org/matrix-sdk-crypto-nodejs-linux-x64-gnu' (required by matrix sdk)",
|
||||
);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
const runCommand = vi.fn(async () => {
|
||||
bootstrapped = true;
|
||||
fs.writeFileSync(nativeBindingPath, Buffer.alloc(1_000_000));
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
runCommand,
|
||||
resolveFn: () => scriptPath,
|
||||
nodeExecutable: "/usr/bin/node",
|
||||
});
|
||||
try {
|
||||
await ensureMatrixCryptoRuntime({
|
||||
log: logStub,
|
||||
requireFn,
|
||||
resolveFn: () => scriptPath,
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(1);
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
expect(fs.statSync(nativeBindingPath).size).toBe(1_000_000);
|
||||
expect(logStub).toHaveBeenCalledWith(
|
||||
"matrix: removed incomplete native crypto runtime (16 bytes); it will be downloaded again",
|
||||
);
|
||||
expect(requireFn).toHaveBeenCalledTimes(2);
|
||||
expect(fs.statSync(nativeBindingPath).size).toBe(1_000_000);
|
||||
expect(logStub).toHaveBeenCalledWith(
|
||||
"matrix: removed incomplete native crypto runtime (16 bytes); it will be downloaded again",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,14 +16,7 @@ export const MATRIX_COMMAND_OUTPUT_TAIL_BYTES = 64 * 1024;
|
||||
|
||||
type MatrixCryptoRuntimeDeps = {
|
||||
requireFn?: (id: string) => unknown;
|
||||
runCommand?: (params: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}) => Promise<CommandResult>;
|
||||
resolveFn?: (id: string) => string;
|
||||
nodeExecutable?: string;
|
||||
log?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -266,8 +259,7 @@ function removeIncompleteMatrixCryptoNativeBinding(params: {
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: MatrixCryptoRuntimeDeps = {},
|
||||
): Promise<void> {
|
||||
const usesDefaultRuntime =
|
||||
!params.requireFn && !params.runCommand && !params.resolveFn && !params.nodeExecutable;
|
||||
const usesDefaultRuntime = !params.requireFn && !params.resolveFn;
|
||||
if (usesDefaultRuntime && defaultMatrixCryptoRuntimeEnsurePromise) {
|
||||
await defaultMatrixCryptoRuntimeEnsurePromise;
|
||||
return;
|
||||
@@ -300,10 +292,8 @@ async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): P
|
||||
|
||||
const scriptPath = resolveFn("@matrix-org/matrix-sdk-crypto-nodejs/download-lib.js");
|
||||
params.log?.("matrix: bootstrapping native crypto runtime");
|
||||
const runCommand = params.runCommand ?? runFixedCommandWithTimeout;
|
||||
const nodeExecutable = params.nodeExecutable ?? process.execPath;
|
||||
const result = await runCommand({
|
||||
argv: [nodeExecutable, scriptPath],
|
||||
const result = await runFixedCommandWithTimeout({
|
||||
argv: [process.execPath, scriptPath],
|
||||
cwd: path.dirname(scriptPath),
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
|
||||
@@ -23,23 +23,6 @@ describe("matrix thread context", () => {
|
||||
).toBe("Thread starter body");
|
||||
});
|
||||
|
||||
it("truncates long thread starter bodies on code-point boundaries", () => {
|
||||
const summary = summarizeMatrixThreadStarterEvent({
|
||||
event_id: "$root",
|
||||
sender: "@alice:example.org",
|
||||
type: "m.room.message",
|
||||
origin_server_ts: Date.now(),
|
||||
content: {
|
||||
msgtype: "m.text",
|
||||
// 496 "a" + astral emoji (surrogate pair at units 496-497) + tail.
|
||||
// A raw slice(0, 497) would cut the pair and leave a lone high surrogate.
|
||||
body: `${"a".repeat(496)}\u{1F600}bcd`,
|
||||
},
|
||||
} as MatrixRawEvent);
|
||||
expect(summary).toBe(`${"a".repeat(496)}...`);
|
||||
expect(summary && /[\uD800-\uDFFF]/.test(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it("marks media-only thread starter events instead of returning bare filenames", () => {
|
||||
expect(
|
||||
summarizeMatrixThreadStarterEvent({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Matrix plugin module implements thread context behavior.
|
||||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { summarizeMatrixMessageContextEvent, trimMatrixMaybeString } from "./context-summary.js";
|
||||
import type { MatrixRawEvent } from "./types.js";
|
||||
@@ -18,7 +17,7 @@ function truncateThreadStarterBody(value: string): string {
|
||||
if (value.length <= MAX_THREAD_STARTER_BODY_LENGTH) {
|
||||
return value;
|
||||
}
|
||||
return `${sliceUtf16Safe(value, 0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
|
||||
return `${value.slice(0, MAX_THREAD_STARTER_BODY_LENGTH - 3)}...`;
|
||||
}
|
||||
|
||||
export function summarizeMatrixThreadStarterEvent(event: MatrixRawEvent): string | undefined {
|
||||
|
||||
@@ -1680,9 +1680,6 @@
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
"mac:restart": "bash scripts/restart-mac.sh",
|
||||
"mobile:release:preflight": "node --import tsx scripts/mobile-release-ref.ts preflight",
|
||||
"mobile:release:record": "node --import tsx scripts/mobile-release-ref.ts record",
|
||||
"mobile:release:resolve": "node --import tsx scripts/mobile-release-ref.ts resolve",
|
||||
"openclaw": "node scripts/run-node.mjs",
|
||||
"openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||
"perf:issue-78851": "node --import tsx scripts/perf/issue-78851-model-resolution.ts",
|
||||
@@ -1899,7 +1896,6 @@
|
||||
"test:sqlite:perf:large": "node --import tsx scripts/bench-sqlite-state.ts --profile large --output .artifacts/sqlite-perf/large.json",
|
||||
"test:sqlite:perf:smoke": "node --import tsx scripts/bench-sqlite-state.ts --profile smoke --output .artifacts/sqlite-perf/smoke.json",
|
||||
"test:plugins:gateway-gauntlet": "node scripts/check-plugin-gateway-gauntlet.mjs",
|
||||
"test:plugins:init-provider-scaffold": "node --import tsx scripts/validate-plugin-init-provider-scaffold.ts",
|
||||
"test:plugins:kitchen-sink-live": "bash -lc 'if [ -x \"$HOME/.local/bin/openclaw-testbox-env\" ]; then exec \"$HOME/.local/bin/openclaw-testbox-env\" pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai; fi; exec pnpm openclaw qa suite --provider-mode live-frontier --scenario kitchen-sink-live-openai'",
|
||||
"test:plugins:kitchen-sink-rpc": "node --import tsx scripts/e2e/kitchen-sink-rpc-walk.mjs",
|
||||
"test:sectriage": "node scripts/run-with-env.mjs OPENCLAW_GATEWAY_PROJECT_SHARDS=1 -- node scripts/run-vitest.mjs run --config test/vitest/vitest.gateway.config.ts && node scripts/run-vitest.mjs run --config test/vitest/vitest.unit.config.ts --exclude src/process/exec.test.ts",
|
||||
@@ -1951,8 +1947,6 @@
|
||||
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
|
||||
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
|
||||
"ui:i18n:sync": "node --import tsx scripts/control-ui-i18n.ts sync --write",
|
||||
"native:i18n:check": "node --import tsx scripts/native-app-i18n.ts check",
|
||||
"native:i18n:sync": "node --import tsx scripts/native-app-i18n.ts sync --write",
|
||||
"ui:install": "node scripts/ui.js install",
|
||||
"verify": "node scripts/verify.mjs"
|
||||
},
|
||||
|
||||
@@ -595,8 +595,6 @@ function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntr
|
||||
"- The JSON must be an object whose keys exactly match the provided ids.",
|
||||
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English.",
|
||||
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens.",
|
||||
"- Preserve Swift interpolation expressions such as \\(name) exactly, including the backslash and parentheses.",
|
||||
"- Preserve Kotlin interpolation expressions such as $name and ${value} exactly.",
|
||||
"- Preserve punctuation, ellipses, arrows, and casing when they are part of literal UI text.",
|
||||
"- Preserve Markdown, inline code, HTML tags, and slash commands when present.",
|
||||
"- Use fluent, neutral product UI language.",
|
||||
@@ -1486,63 +1484,6 @@ async function translateBatch(
|
||||
throw lastError ?? new Error("translation failed");
|
||||
}
|
||||
|
||||
export type NativeTranslationEntry = {
|
||||
id: string;
|
||||
source: string;
|
||||
sourcePath: string;
|
||||
};
|
||||
|
||||
export async function translateNativeEntries(
|
||||
entries: readonly NativeTranslationEntry[],
|
||||
targetLocale: string,
|
||||
glossary: readonly GlossaryEntry[] = [],
|
||||
): Promise<Map<string, string>> {
|
||||
if (!hasTranslationProvider()) {
|
||||
throw new Error("native app translation requires OPENAI_API_KEY or ANTHROPIC_API_KEY");
|
||||
}
|
||||
const pending = entries.map((entry) => ({
|
||||
cacheKey: cacheKey(entry.id, hashText(entry.source), targetLocale),
|
||||
key: entry.id,
|
||||
text: entry.source,
|
||||
textHash: hashText(entry.source),
|
||||
}));
|
||||
const batches = buildTranslationBatches(pending);
|
||||
let client: TranslationClient | null = null;
|
||||
const clientAccess: ClientAccess = {
|
||||
async getClient() {
|
||||
if (!client) {
|
||||
client = await TranslationClient.create(buildSystemPrompt(targetLocale, glossary));
|
||||
}
|
||||
return client;
|
||||
},
|
||||
async resetClient() {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
await client.close();
|
||||
client = null;
|
||||
},
|
||||
};
|
||||
try {
|
||||
const translated = new Map<string, string>();
|
||||
for (const [batchIndex, batch] of batches.entries()) {
|
||||
const result = await translateBatch(clientAccess, batch, {
|
||||
locale: targetLocale,
|
||||
localeCount: 1,
|
||||
localeIndex: 1,
|
||||
batchCount: batches.length,
|
||||
batchIndex: batchIndex + 1,
|
||||
});
|
||||
for (const [id, value] of result) {
|
||||
translated.set(id, value);
|
||||
}
|
||||
}
|
||||
return translated;
|
||||
} finally {
|
||||
await clientAccess.resetClient();
|
||||
}
|
||||
}
|
||||
|
||||
type SyncOutcome = {
|
||||
changed: boolean;
|
||||
fallbackCount: number;
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
// Tracks uploaded mobile store builds with non-tag Git refs.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
export type MobileReleasePlatform = "ios" | "android";
|
||||
export type MobileReleaseCommand = "preflight" | "record" | "resolve";
|
||||
|
||||
type GitDeps = {
|
||||
execFileSync?: typeof execFileSync;
|
||||
};
|
||||
|
||||
type MobileReleaseOptions = {
|
||||
build: string | null;
|
||||
command: MobileReleaseCommand;
|
||||
platform: MobileReleasePlatform;
|
||||
remote: string;
|
||||
rootDir: string;
|
||||
sha: string;
|
||||
version: string;
|
||||
versionCode: string | null;
|
||||
};
|
||||
|
||||
type RemoteRefState = {
|
||||
ref: string;
|
||||
sha: string;
|
||||
} | null;
|
||||
|
||||
const REF_PREFIX = "refs/openclaw/mobile-releases";
|
||||
const VERSION_RE = /^20\d{2}\.(?:[1-9]\d?)\.(?:[1-9]\d*)$/u;
|
||||
const POSITIVE_INTEGER_RE = /^[1-9]\d*$/u;
|
||||
|
||||
function git(args: string[], rootDir: string, deps: GitDeps = {}): string {
|
||||
const exec = deps.execFileSync ?? execFileSync;
|
||||
return exec("git", args, {
|
||||
cwd: rootDir,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
function errorOutput(value: unknown): string {
|
||||
if (Buffer.isBuffer(value)) {
|
||||
return value.toString("utf8");
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
|
||||
}
|
||||
|
||||
function gitAllowFailure(
|
||||
args: string[],
|
||||
rootDir: string,
|
||||
deps: GitDeps = {},
|
||||
): { ok: boolean; stdout: string; stderr: string } {
|
||||
try {
|
||||
return { ok: true, stdout: git(args, rootDir, deps), stderr: "" };
|
||||
} catch (error) {
|
||||
const e = error as { stdout?: unknown; stderr?: unknown };
|
||||
const stdout = errorOutput(e.stdout);
|
||||
const stderr = errorOutput(e.stderr);
|
||||
return { ok: false, stdout, stderr };
|
||||
}
|
||||
}
|
||||
|
||||
function readOptionValue(argv: string[], index: number, flag: string): string {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error(`Missing value for ${flag}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parsePlatform(raw: string | null): MobileReleasePlatform {
|
||||
if (raw === "ios" || raw === "android") {
|
||||
return raw;
|
||||
}
|
||||
throw new Error("Missing or invalid --platform. Expected ios or android.");
|
||||
}
|
||||
|
||||
function parseCommand(raw: string | undefined): MobileReleaseCommand {
|
||||
if (raw === "-h" || raw === "--help") {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (raw === "preflight" || raw === "record" || raw === "resolve") {
|
||||
return raw;
|
||||
}
|
||||
throw new Error(`Unknown command '${raw ?? ""}'. Expected preflight, record, or resolve.`);
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): MobileReleaseOptions {
|
||||
const command = parseCommand(argv[0]);
|
||||
let build: string | null = null;
|
||||
let platform: string | null = null;
|
||||
let remote = "origin";
|
||||
let rootDir = path.resolve(".");
|
||||
let sha = "HEAD";
|
||||
let version = "";
|
||||
let versionCode: string | null = null;
|
||||
|
||||
for (let index = 1; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
switch (arg) {
|
||||
case "--":
|
||||
break;
|
||||
case "--platform":
|
||||
platform = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--version":
|
||||
version = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--build":
|
||||
build = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--version-code":
|
||||
versionCode = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--sha":
|
||||
sha = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--remote":
|
||||
remote = readOptionValue(argv, index, arg);
|
||||
index += 1;
|
||||
break;
|
||||
case "--root":
|
||||
rootDir = path.resolve(readOptionValue(argv, index, arg));
|
||||
index += 1;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
throw new Error(usage());
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
build,
|
||||
command,
|
||||
platform: parsePlatform(platform),
|
||||
remote,
|
||||
rootDir,
|
||||
sha,
|
||||
version,
|
||||
versionCode,
|
||||
};
|
||||
}
|
||||
|
||||
function validateVersion(version: string): string {
|
||||
const trimmed = version.trim();
|
||||
if (!VERSION_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid mobile release version '${version}'. Expected YYYY.M.D.`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function validatePositiveInteger(label: string, value: string | null): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!POSITIVE_INTEGER_RE.test(trimmed)) {
|
||||
throw new Error(`Invalid ${label} '${value ?? ""}'. Expected a positive integer.`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function androidVersionCodePrefix(version: string): string {
|
||||
const [year, rawMonth, rawPatch] = version.split(".");
|
||||
return `${year}${rawMonth?.padStart(2, "0")}${rawPatch?.padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function validateAndroidVersionCode(version: string, versionCode: string | null): string {
|
||||
const code = validatePositiveInteger("Android versionCode", versionCode);
|
||||
const prefix = androidVersionCodePrefix(version);
|
||||
const suffix = Number.parseInt(code.slice(prefix.length), 10);
|
||||
if (
|
||||
!code.startsWith(prefix) ||
|
||||
code.length !== prefix.length + 2 ||
|
||||
!Number.isInteger(suffix) ||
|
||||
suffix < 1 ||
|
||||
suffix > 99
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid Android versionCode '${code}'. Expected ${prefix}01 through ${prefix}99 for version ${version}.`,
|
||||
);
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function mobileReleaseRefFor(options: {
|
||||
build?: string | null;
|
||||
platform: MobileReleasePlatform;
|
||||
version: string;
|
||||
versionCode?: string | null;
|
||||
}): string {
|
||||
const version = validateVersion(options.version);
|
||||
if (options.platform === "ios") {
|
||||
const build = validatePositiveInteger("iOS build", options.build ?? null);
|
||||
return `${REF_PREFIX}/ios/${version}-${build}`;
|
||||
}
|
||||
|
||||
const versionCode = validateAndroidVersionCode(version, options.versionCode ?? null);
|
||||
return `${REF_PREFIX}/android/${version}-${versionCode}`;
|
||||
}
|
||||
|
||||
function assertRootDir(rootDir: string): void {
|
||||
if (!existsSync(path.join(rootDir, ".git"))) {
|
||||
throw new Error(`Not a Git checkout root: ${rootDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCommitSha(sha: string, rootDir: string, deps: GitDeps = {}): string {
|
||||
return git(["rev-parse", "--verify", `${sha}^{commit}`], rootDir, deps).trim();
|
||||
}
|
||||
|
||||
export function readRemoteRef(
|
||||
remote: string,
|
||||
ref: string,
|
||||
rootDir: string,
|
||||
deps: GitDeps = {},
|
||||
): RemoteRefState {
|
||||
const result = gitAllowFailure(["ls-remote", "--refs", remote, ref], rootDir, deps);
|
||||
if (!result.ok) {
|
||||
const detail = (result.stderr || result.stdout).trim();
|
||||
throw new Error(`Failed to inspect remote release ref ${ref}: ${detail}`);
|
||||
}
|
||||
|
||||
const line = result.stdout.trim();
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [sha, remoteRef] = line.split(/\s+/u);
|
||||
if (!sha || remoteRef !== ref) {
|
||||
throw new Error(`Unexpected remote ref lookup output for ${ref}: ${line}`);
|
||||
}
|
||||
return { ref: remoteRef, sha };
|
||||
}
|
||||
|
||||
function shortSha(sha: string): string {
|
||||
return sha.slice(0, 12);
|
||||
}
|
||||
|
||||
function recoveryCommand(options: { ref: string; remote: string; sha: string }): string {
|
||||
return `git push --force-with-lease=${options.ref}: ${options.remote} ${options.sha}:${options.ref}`;
|
||||
}
|
||||
|
||||
export function preflightMobileReleaseRef(
|
||||
options: MobileReleaseOptions,
|
||||
deps: GitDeps = {},
|
||||
): { ref: string; sha: string; status: "available" | "already-recorded" } {
|
||||
assertRootDir(options.rootDir);
|
||||
const ref = mobileReleaseRefFor(options);
|
||||
const sha = resolveCommitSha(options.sha, options.rootDir, deps);
|
||||
const existing = readRemoteRef(options.remote, ref, options.rootDir, deps);
|
||||
|
||||
if (!existing) {
|
||||
return { ref, sha, status: "available" };
|
||||
}
|
||||
if (existing.sha === sha) {
|
||||
return { ref, sha, status: "already-recorded" };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Mobile release ref ${ref} already points at ${existing.sha}; refusing to record ${sha}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function recordMobileReleaseRef(
|
||||
options: MobileReleaseOptions,
|
||||
deps: GitDeps = {},
|
||||
): { ref: string; sha: string; status: "created" | "already-recorded" } {
|
||||
const preflight = preflightMobileReleaseRef(options, deps);
|
||||
if (preflight.status === "already-recorded") {
|
||||
return { ...preflight, status: "already-recorded" };
|
||||
}
|
||||
|
||||
const pushArgs = [
|
||||
"push",
|
||||
`--force-with-lease=${preflight.ref}:`,
|
||||
options.remote,
|
||||
`${preflight.sha}:${preflight.ref}`,
|
||||
];
|
||||
const result = gitAllowFailure(pushArgs, options.rootDir, deps);
|
||||
if (!result.ok) {
|
||||
const detail = (result.stderr || result.stdout).trim();
|
||||
throw new Error(
|
||||
`Failed to create mobile release ref ${preflight.ref}. Recovery command:\n${recoveryCommand({
|
||||
ref: preflight.ref,
|
||||
remote: options.remote,
|
||||
sha: preflight.sha,
|
||||
})}\n${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
const recorded = readRemoteRef(options.remote, preflight.ref, options.rootDir, deps);
|
||||
if (recorded?.sha !== preflight.sha) {
|
||||
throw new Error(
|
||||
`Mobile release ref ${preflight.ref} was not recorded at ${preflight.sha}; remote has ${recorded?.sha ?? "no ref"}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ref: preflight.ref, sha: preflight.sha, status: "created" };
|
||||
}
|
||||
|
||||
export function resolveMobileReleaseRef(
|
||||
options: MobileReleaseOptions,
|
||||
deps: GitDeps = {},
|
||||
): { ref: string; sha: string } {
|
||||
assertRootDir(options.rootDir);
|
||||
const ref = mobileReleaseRefFor(options);
|
||||
const existing = readRemoteRef(options.remote, ref, options.rootDir, deps);
|
||||
if (!existing) {
|
||||
throw new Error(`Mobile release ref ${ref} does not exist on ${options.remote}.`);
|
||||
}
|
||||
return { ref, sha: existing.sha };
|
||||
}
|
||||
|
||||
function usage(): string {
|
||||
return [
|
||||
"Usage:",
|
||||
" node --import tsx scripts/mobile-release-ref.ts preflight --platform ios --version YYYY.M.D --build N [--sha HEAD] [--remote origin]",
|
||||
" node --import tsx scripts/mobile-release-ref.ts record --platform android --version YYYY.M.D --version-code YYYYMMDDNN [--sha HEAD] [--remote origin]",
|
||||
" node --import tsx scripts/mobile-release-ref.ts resolve --platform ios --version YYYY.M.D --build N [--remote origin]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function main(argv: string[]): Promise<number> {
|
||||
try {
|
||||
const options = parseArgs(argv);
|
||||
if (options.command === "preflight") {
|
||||
const result = preflightMobileReleaseRef(options);
|
||||
const suffix =
|
||||
result.status === "already-recorded"
|
||||
? `already records ${shortSha(result.sha)}`
|
||||
: `available for ${shortSha(result.sha)}`;
|
||||
process.stdout.write(`Mobile release ref ${result.ref} is ${suffix}.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (options.command === "record") {
|
||||
const result = recordMobileReleaseRef(options);
|
||||
const verb = result.status === "already-recorded" ? "already records" : "recorded";
|
||||
process.stdout.write(`Mobile release ref ${result.ref} ${verb} ${result.sha}.\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = resolveMobileReleaseRef(options);
|
||||
process.stdout.write(`${result.sha}\t${result.ref}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.startsWith("Usage:")) {
|
||||
process.stdout.write(`${message}\n`);
|
||||
return 0;
|
||||
}
|
||||
process.stderr.write(`${message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1] ?? "")).href) {
|
||||
const exitCode = await main(process.argv.slice(2));
|
||||
if (exitCode !== 0) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { translateNativeEntries } from "./control-ui-i18n.ts";
|
||||
|
||||
export type NativeI18nSurface = "android" | "apple";
|
||||
|
||||
export const NATIVE_I18N_LOCALES = [
|
||||
"zh-CN",
|
||||
"zh-TW",
|
||||
"pt-BR",
|
||||
"de",
|
||||
"es",
|
||||
"ja-JP",
|
||||
"ko",
|
||||
"fr",
|
||||
"hi",
|
||||
"ar",
|
||||
"it",
|
||||
"tr",
|
||||
"uk",
|
||||
"id",
|
||||
"pl",
|
||||
"th",
|
||||
"vi",
|
||||
"nl",
|
||||
"fa",
|
||||
"ru",
|
||||
] as const;
|
||||
|
||||
export type NativeI18nEntry = {
|
||||
id: string;
|
||||
kind: string;
|
||||
line: number;
|
||||
path: string;
|
||||
source: string;
|
||||
surface: NativeI18nSurface;
|
||||
};
|
||||
|
||||
type Candidate = Omit<NativeI18nEntry, "id">;
|
||||
type NativeTranslationArtifact = {
|
||||
entries: Array<{ id: string; source: string; translated: string }>;
|
||||
locale: string;
|
||||
version: 1;
|
||||
};
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(HERE, "..");
|
||||
const OUTPUT_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
|
||||
const TRANSLATIONS_DIR = path.join(ROOT, "apps", ".i18n", "native");
|
||||
const SOURCE_ROOTS: Record<NativeI18nSurface, string[]> = {
|
||||
android: [path.join(ROOT, "apps", "android", "app", "src", "main")],
|
||||
apple: [
|
||||
path.join(ROOT, "apps", "ios"),
|
||||
path.join(ROOT, "apps", "macos", "Sources"),
|
||||
path.join(ROOT, "apps", "shared", "OpenClawKit", "Sources"),
|
||||
],
|
||||
};
|
||||
|
||||
const ANDROID_EXTENSIONS = new Set([".kt", ".kts"]);
|
||||
const APPLE_EXTENSIONS = new Set([".swift", ".plist"]);
|
||||
const APPLE_UI_CALLS =
|
||||
/(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const APPLE_MODIFIER_CALLS =
|
||||
/\.(?:navigationTitle|accessibilityLabel|accessibilityHint|help|alert|confirmationDialog)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_CALLS =
|
||||
/\b(?:Text|OutlinedTextField|BasicTextField|Button|IconButton|TopAppBar|Snackbar|AlertDialog)\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_PROPERTIES =
|
||||
/\b(?:contentDescription|label|placeholder|title|message|supportingText)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_WRAPPER_ARGS =
|
||||
/\b[A-Z][A-Za-z0-9_]*\s*\([^)\n]{0,160}?\b(?:text|title|label|message|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_TOAST_ARGS =
|
||||
/\b(?:Toast\.makeText|Snackbar\.make)\s*\([^,\n]*,\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_DIALOG_CALLS =
|
||||
/\.(?:setTitle|setMessage|setPositiveButton|setNegativeButton|setNeutralButton)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const ANDROID_STATE_CALLS = /\b(?:MutableStateFlow|StateFlow|flowOf)\s*\(\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const CONDITIONAL_BRANCHES = [
|
||||
/\bif\s*\([^)]*\)\s*"((?:\\.|[^"\\])*)"\s*else\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
/\?\s*"((?:\\.|[^"\\])*)"\s*:\s*"((?:\\.|[^"\\])*)"/gu,
|
||||
];
|
||||
const ANDROID_RESOURCE_STRINGS = /<string\b[^>]*>([\s\S]*?)<\/string>/gu;
|
||||
const APPLE_NAMED_ARGUMENTS =
|
||||
/\b(?:title|subtitle|label|message|text|prompt|description|help)\s*:\s*"((?:\\.|[^"\\])*)"/gu;
|
||||
const APPLE_PLIST_STRINGS = /<string>([\s\S]*?)<\/string>/gu;
|
||||
const GENERATED_PATH_RE = /(?:^|[\\/])(?:build|\.gradle|\.build|DerivedData)(?:$|[\\/])/u;
|
||||
const EXCLUDED_PATH_RE = /(?:^|[\\/])(?:Tests?|UITests?|test|Preview(?:s)?)(?:$|[\\/])/u;
|
||||
const EXCLUDED_FILE_RE = /(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u;
|
||||
const BUILD_SETTING_RE = /\$\([A-Za-z0-9_.-]+\)/gu;
|
||||
const NATIVE_I18N_LOCALE_SET = new Set<string>(NATIVE_I18N_LOCALES);
|
||||
|
||||
function extractSwiftInterpolations(source: string): string[] | null {
|
||||
const values: string[] = [];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== "\\" || source[index + 1] !== "(") continue;
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (index += 2; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = !quoted;
|
||||
else if (!quoted && character === "(") depth += 1;
|
||||
else if (!quoted && character === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
values.push(source.slice(start, index + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function extractKotlinInterpolations(source: string): string[] | null {
|
||||
const values = [...source.matchAll(/\$[A-Za-z_][A-Za-z0-9_]*/gu)].map((match) => match[0]);
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== "$" || source[index + 1] !== "{") continue;
|
||||
const start = index;
|
||||
let depth = 1;
|
||||
for (index += 2; index < source.length; index += 1) {
|
||||
if (source[index] === "{") depth += 1;
|
||||
else if (source[index] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
values.push(source.slice(start, index + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) return null;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function lineNumber(source: string, offset: number): number {
|
||||
return source.slice(0, offset).split("\n").length;
|
||||
}
|
||||
|
||||
function decodeLiteral(raw: string): string {
|
||||
try {
|
||||
return JSON.parse(`"${raw}"`) as string;
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSource(source: string): string {
|
||||
return source;
|
||||
}
|
||||
|
||||
function structuralTokenSignature(source: string): string {
|
||||
const swift = extractSwiftInterpolations(source);
|
||||
const kotlin = extractKotlinInterpolations(source);
|
||||
const buildSettings = source.match(BUILD_SETTING_RE) ?? [];
|
||||
const lineBreaks = (source.match(/\n/gu) ?? []).length;
|
||||
return JSON.stringify({ swift, kotlin, buildSettings, lineBreaks });
|
||||
}
|
||||
|
||||
function isTranslatableCandidate(source: string, kind: string): boolean {
|
||||
if (BUILD_SETTING_RE.test(source)) {
|
||||
BUILD_SETTING_RE.lastIndex = 0;
|
||||
return false;
|
||||
}
|
||||
BUILD_SETTING_RE.lastIndex = 0;
|
||||
if (/^[a-z0-9_.:/$-]+$/u.test(source) || /^[A-Z0-9_.:/$-]+$/u.test(source)) {
|
||||
return false;
|
||||
}
|
||||
if (/[{}[\]]/u.test(source) && !/(?:\\\(|\$\{)/u.test(source)) {
|
||||
return false;
|
||||
}
|
||||
return kind !== "plist-string" || /\s/u.test(source);
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
entries: Candidate[],
|
||||
surface: NativeI18nSurface,
|
||||
repoPath: string,
|
||||
source: string,
|
||||
kind: string,
|
||||
line: number,
|
||||
) {
|
||||
const normalized = normalizeSource(decodeLiteral(source));
|
||||
if (!normalized.trim() || !/\p{L}/u.test(normalized)) {
|
||||
return;
|
||||
}
|
||||
if (!isTranslatableCandidate(normalized, kind)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalized.length > 500 ||
|
||||
extractSwiftInterpolations(normalized) === null ||
|
||||
extractKotlinInterpolations(normalized) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
entries.push({ kind, line, path: repoPath, source: normalized, surface });
|
||||
}
|
||||
|
||||
function extractCandidates(
|
||||
surface: NativeI18nSurface,
|
||||
repoPath: string,
|
||||
source: string,
|
||||
): Candidate[] {
|
||||
const entries: Candidate[] = [];
|
||||
const patterns =
|
||||
surface === "apple"
|
||||
? [
|
||||
[APPLE_UI_CALLS, "ui-call"],
|
||||
[APPLE_MODIFIER_CALLS, "ui-modifier"],
|
||||
[APPLE_NAMED_ARGUMENTS, "ui-named-argument"],
|
||||
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
|
||||
]
|
||||
: [
|
||||
[ANDROID_CALLS, "ui-call"],
|
||||
[ANDROID_PROPERTIES, "ui-property"],
|
||||
[ANDROID_WRAPPER_ARGS, "ui-wrapper-argument"],
|
||||
[ANDROID_TOAST_ARGS, "ui-toast"],
|
||||
[ANDROID_DIALOG_CALLS, "ui-dialog"],
|
||||
[ANDROID_STATE_CALLS, "ui-state"],
|
||||
...CONDITIONAL_BRANCHES.map((pattern) => [pattern, "conditional-branch"] as const),
|
||||
];
|
||||
for (const [pattern, kind] of patterns) {
|
||||
for (const match of source.matchAll(pattern)) {
|
||||
const offset = match.index ?? 0;
|
||||
for (const value of match.slice(1)) {
|
||||
if (value) {
|
||||
addCandidate(entries, surface, repoPath, value, kind, lineNumber(source, offset));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (surface === "android" && repoPath.endsWith("/res/values/strings.xml")) {
|
||||
for (const match of source.matchAll(ANDROID_RESOURCE_STRINGS)) {
|
||||
if (match[1])
|
||||
addCandidate(
|
||||
entries,
|
||||
surface,
|
||||
repoPath,
|
||||
match[1],
|
||||
"resource-string",
|
||||
lineNumber(source, match.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (surface === "apple" && repoPath.endsWith(".plist")) {
|
||||
for (const match of source.matchAll(APPLE_PLIST_STRINGS)) {
|
||||
if (match[1])
|
||||
addCandidate(
|
||||
entries,
|
||||
surface,
|
||||
repoPath,
|
||||
match[1],
|
||||
"plist-string",
|
||||
lineNumber(source, match.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function walkFiles(
|
||||
root: string,
|
||||
surface: NativeI18nSurface,
|
||||
out: string[] = [],
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(root, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (GENERATED_PATH_RE.test(fullPath) || EXCLUDED_PATH_RE.test(fullPath)) {
|
||||
continue;
|
||||
}
|
||||
await walkFiles(fullPath, surface, out);
|
||||
continue;
|
||||
}
|
||||
const extension = path.extname(entry.name);
|
||||
const allowed =
|
||||
surface === "apple"
|
||||
? APPLE_EXTENSIONS
|
||||
: fullPath.endsWith(`${path.sep}res${path.sep}values${path.sep}strings.xml`)
|
||||
? new Set([...ANDROID_EXTENSIONS, ".xml"])
|
||||
: ANDROID_EXTENSIONS;
|
||||
if (entry.isFile() && allowed.has(extension) && !EXCLUDED_FILE_RE.test(entry.name)) {
|
||||
out.push(fullPath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function withIds(entries: Candidate[]): NativeI18nEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const unique = [
|
||||
...new Map(
|
||||
entries.map((entry) => [`${entry.surface}\u0000${entry.path}\u0000${entry.source}`, entry]),
|
||||
).values(),
|
||||
];
|
||||
return unique
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
left.surface.localeCompare(right.surface) ||
|
||||
left.path.localeCompare(right.path) ||
|
||||
left.line - right.line ||
|
||||
left.kind.localeCompare(right.kind) ||
|
||||
left.source.localeCompare(right.source),
|
||||
)
|
||||
.map((entry) => {
|
||||
const digest = createHash("sha256")
|
||||
.update([entry.surface, entry.path, entry.kind, entry.source].join("\u0000"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
let id = `native.${entry.surface}.${digest}`;
|
||||
if (seen.has(id)) {
|
||||
id = `${id}.${entry.line}`;
|
||||
}
|
||||
seen.add(id);
|
||||
return { ...entry, id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function collectNativeI18nEntries(): Promise<NativeI18nEntry[]> {
|
||||
const entries: Candidate[] = [];
|
||||
for (const surface of ["android", "apple"] as const) {
|
||||
for (const sourceRoot of SOURCE_ROOTS[surface]) {
|
||||
const files = await walkFiles(sourceRoot, surface);
|
||||
for (const filePath of files.toSorted()) {
|
||||
const source = await readFile(filePath, "utf8");
|
||||
const repoPath = path.relative(ROOT, filePath).split(path.sep).join("/");
|
||||
entries.push(...extractCandidates(surface, repoPath, source));
|
||||
}
|
||||
}
|
||||
}
|
||||
return withIds(entries);
|
||||
}
|
||||
|
||||
function render(entries: NativeI18nEntry[]): string {
|
||||
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export async function syncNativeI18n(options: { checkOnly: boolean; write: boolean }) {
|
||||
const expected = render(await collectNativeI18nEntries());
|
||||
let current = "";
|
||||
try {
|
||||
current = await readFile(OUTPUT_PATH, "utf8");
|
||||
} catch {
|
||||
// The first sync creates the inventory.
|
||||
}
|
||||
if (current !== expected && options.checkOnly) {
|
||||
throw new Error(
|
||||
"native app i18n inventory drift detected. Run `pnpm native:i18n:sync` and commit apps/.i18n/native-source.json.",
|
||||
);
|
||||
}
|
||||
if (current !== expected && options.write) {
|
||||
await mkdir(path.dirname(OUTPUT_PATH), { recursive: true });
|
||||
await writeFile(OUTPUT_PATH, expected, "utf8");
|
||||
}
|
||||
const count = JSON.parse(expected).entries.length as number;
|
||||
process.stdout.write(`native-app-i18n: entries=${count} changed=${current !== expected}\n`);
|
||||
}
|
||||
|
||||
async function loadGlossary(locale: string): Promise<Array<{ source: string; target: string }>> {
|
||||
try {
|
||||
return JSON.parse(
|
||||
await readFile(
|
||||
path.join(ROOT, "ui", "src", "i18n", ".i18n", `glossary.${locale}.json`),
|
||||
"utf8",
|
||||
),
|
||||
) as Array<{ source: string; target: string }>;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function syncNativeLocale(locale: string, entries: NativeI18nEntry[]) {
|
||||
// Native runtime resources are owned by the Android and Apple slices; these
|
||||
// artifacts keep the shared translation-memory handoff current between them.
|
||||
const artifactPath = path.join(TRANSLATIONS_DIR, `${locale}.json`);
|
||||
let previous: NativeTranslationArtifact = { entries: [], locale, version: 1 };
|
||||
try {
|
||||
previous = JSON.parse(await readFile(artifactPath, "utf8")) as NativeTranslationArtifact;
|
||||
} catch {
|
||||
// The first refresh creates the locale artifact.
|
||||
}
|
||||
const previousById = new Map(previous.entries.map((entry) => [entry.id, entry]));
|
||||
const pending = entries
|
||||
.filter((entry) => {
|
||||
const current = previousById.get(entry.id);
|
||||
return !current || current.source !== entry.source || !current.translated.trim();
|
||||
})
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
source: entry.source,
|
||||
sourcePath: entry.path,
|
||||
}));
|
||||
const translated = pending.length
|
||||
? await translateNativeEntries(pending, locale, await loadGlossary(locale))
|
||||
: new Map<string, string>();
|
||||
const artifact: NativeTranslationArtifact = {
|
||||
version: 1,
|
||||
locale,
|
||||
entries: entries.map((entry) => ({
|
||||
id: entry.id,
|
||||
source: entry.source,
|
||||
translated:
|
||||
translated.get(entry.id) ?? previousById.get(entry.id)?.translated ?? entry.source,
|
||||
})),
|
||||
};
|
||||
for (const entry of artifact.entries) {
|
||||
if (structuralTokenSignature(entry.source) !== structuralTokenSignature(entry.translated)) {
|
||||
throw new Error(
|
||||
`native translation changed placeholders or line breaks for ${locale}:${entry.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await mkdir(TRANSLATIONS_DIR, { recursive: true });
|
||||
await writeFile(artifactPath, `${JSON.stringify(artifact, null, 2)}\n`, "utf8");
|
||||
process.stdout.write(
|
||||
`native-app-i18n: locale=${locale} entries=${entries.length} translated=${translated.size}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (command !== "check" && command !== "sync") {
|
||||
throw new Error(
|
||||
"usage: node --import tsx scripts/native-app-i18n.ts check|sync [--write] [--locale <code>]",
|
||||
);
|
||||
}
|
||||
await syncNativeI18n({
|
||||
checkOnly: command === "check",
|
||||
write: command === "sync" && process.argv.includes("--write"),
|
||||
});
|
||||
const localeFlag = args.indexOf("--locale");
|
||||
const locale = localeFlag >= 0 ? args[localeFlag + 1] : undefined;
|
||||
if (locale) {
|
||||
if (command !== "sync" || !process.argv.includes("--write")) {
|
||||
throw new Error("native locale refresh requires `sync --write --locale <code>`");
|
||||
}
|
||||
if (!NATIVE_I18N_LOCALE_SET.has(locale)) {
|
||||
throw new Error(
|
||||
`unsupported native locale "${locale}". Expected one of: ${NATIVE_I18N_LOCALES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
await syncNativeLocale(locale, await collectNativeI18nEntries());
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
|
||||
await main();
|
||||
}
|
||||
@@ -746,7 +746,6 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["scripts/ci-changed-scope.mjs", ["src/scripts/ci-changed-scope.test.ts"]],
|
||||
["scripts/ci-docker-pull-retry.sh", ["test/scripts/ci-docker-pull-retry.test.ts"]],
|
||||
["scripts/control-ui-i18n.ts", ["test/scripts/control-ui-i18n.test.ts"]],
|
||||
["scripts/native-app-i18n.ts", ["test/scripts/native-app-i18n.test.ts"]],
|
||||
[
|
||||
"scripts/copy-bundled-plugin-metadata.mjs",
|
||||
["src/plugins/copy-bundled-plugin-metadata.test.ts", "src/infra/run-node.test.ts"],
|
||||
@@ -978,11 +977,9 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
"scripts/github/run-openclaw-cross-os-release-checks.sh",
|
||||
["test/scripts/openclaw-cross-os-release-workflow.test.ts"],
|
||||
],
|
||||
["scripts/mobile-release-ref.ts", ["test/scripts/mobile-release-ref.test.ts"]],
|
||||
["scripts/android-release.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
["scripts/android-release-signing.mjs", ["test/scripts/android-release-signing.test.ts"]],
|
||||
["scripts/android-release-upload.sh", ["test/scripts/android-release-wrapper-args.test.ts"]],
|
||||
["apps/android/fastlane/Fastfile", ["test/scripts/android-release-fastlane-gates.test.ts"]],
|
||||
["scripts/ios-release-archive.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
["scripts/ios-release-prepare.sh", ["test/scripts/ios-release-wrapper-args.test.ts"]],
|
||||
["scripts/ios-release-signing.mjs", ["test/scripts/ios-release-signing.test.ts"]],
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { runPluginsInitCommand } from "../src/cli/plugins-authoring-command.js";
|
||||
|
||||
type InspectorReport = {
|
||||
status?: unknown;
|
||||
summary?: {
|
||||
breakageCount?: unknown;
|
||||
warningCount?: unknown;
|
||||
issueCount?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
const artifactRoot = path.resolve(
|
||||
process.env.OPENCLAW_PLUGIN_INIT_VALIDATE_ROOT ?? ".artifacts/plugin-init-provider-scaffold",
|
||||
);
|
||||
const projectDir = path.join(artifactRoot, "plugin-init-test");
|
||||
const reportPath = path.join(projectDir, ".clawhub-validation", "plugin-inspector-report.json");
|
||||
|
||||
function run(command: string, args: string[], cwd: string): void {
|
||||
console.log(`$ ${[command, ...args].join(" ")}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(" ")} exited with status ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readInspectorReport(): InspectorReport {
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
throw new Error(`ClawHub validation report not found: ${reportPath}`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(reportPath, "utf8")) as InspectorReport;
|
||||
}
|
||||
|
||||
function assertCleanInspectorReport(report: InspectorReport): void {
|
||||
const breakageCount = Number(report.summary?.breakageCount ?? Number.NaN);
|
||||
const warningCount = Number(report.summary?.warningCount ?? Number.NaN);
|
||||
const issueCount = Number(report.summary?.issueCount ?? Number.NaN);
|
||||
if (report.status !== "pass" || breakageCount !== 0 || warningCount !== 0 || issueCount !== 0) {
|
||||
throw new Error(
|
||||
`Plugin Inspector was not clean: status=${String(
|
||||
report.status,
|
||||
)}, breakages=${breakageCount}, warnings=${warningCount}, issues=${issueCount}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fs.rmSync(projectDir, { force: true, recursive: true });
|
||||
fs.mkdirSync(artifactRoot, { recursive: true });
|
||||
|
||||
await runPluginsInitCommand("plugin-init-test", {
|
||||
directory: projectDir,
|
||||
name: "Plugin Init Test",
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
run("npm", ["install", "--no-audit", "--fund=false"], projectDir);
|
||||
run("npm", ["run", "build"], projectDir);
|
||||
run("npm", ["test"], projectDir);
|
||||
run("npm", ["run", "validate"], projectDir);
|
||||
assertCleanInspectorReport(readInspectorReport());
|
||||
|
||||
console.log(`Generated provider scaffold passed ClawHub validation: ${projectDir}`);
|
||||
@@ -23,13 +23,12 @@ const REASONS_WITH_RECOVERY: readonly FailoverReason[] = [
|
||||
"auth_permanent",
|
||||
"billing",
|
||||
];
|
||||
const REASONS_WITHOUT_RECOVERY: readonly FailoverReason[] = [
|
||||
const REASONS_TRANSIENT: readonly FailoverReason[] = [
|
||||
"rate_limit",
|
||||
"overloaded",
|
||||
"timeout",
|
||||
"server_error",
|
||||
"model_not_found",
|
||||
"format",
|
||||
];
|
||||
|
||||
describe("formatAuthProfileFailureMessage", () => {
|
||||
@@ -46,7 +45,7 @@ describe("formatAuthProfileFailureMessage", () => {
|
||||
});
|
||||
|
||||
it("omits the login command for transient cooldown reasons", () => {
|
||||
for (const reason of REASONS_WITHOUT_RECOVERY) {
|
||||
for (const reason of REASONS_TRANSIENT) {
|
||||
const message = formatAuthProfileFailureMessage({
|
||||
reason,
|
||||
provider: PROVIDER,
|
||||
@@ -66,11 +65,7 @@ describe("formatAuthProfileFailureMessage", () => {
|
||||
});
|
||||
|
||||
it("always mentions the provider name", () => {
|
||||
for (const reason of [
|
||||
...REASONS_WITH_RECOVERY,
|
||||
...REASONS_WITHOUT_RECOVERY,
|
||||
"unknown",
|
||||
] as const) {
|
||||
for (const reason of [...REASONS_WITH_RECOVERY, ...REASONS_TRANSIENT, "unknown"] as const) {
|
||||
const message = formatAuthProfileFailureMessage({
|
||||
reason,
|
||||
provider: PROVIDER,
|
||||
|
||||
@@ -83,7 +83,6 @@ function shouldIncludeRecoveryHint(reason: FailoverReason): boolean {
|
||||
case "timeout":
|
||||
case "server_error":
|
||||
case "model_not_found":
|
||||
case "format":
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
|
||||
@@ -3373,61 +3373,6 @@ describe("subagent registry seam flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("wakes a sessions_yield-paused parent when pending descendants settle", async () => {
|
||||
mocks.loadSessionStore.mockReturnValue({
|
||||
"agent:main:subagent:parent": {
|
||||
sessionId: "sess-parent",
|
||||
updatedAt: 1,
|
||||
},
|
||||
"agent:main:subagent:child": {
|
||||
sessionId: "sess-child",
|
||||
updatedAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
mod.addSubagentRunForTests({
|
||||
runId: "run-yielded-parent",
|
||||
childSessionKey: "agent:main:subagent:parent",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "yielded parent waiting on descendants",
|
||||
cleanup: "keep",
|
||||
createdAt: Date.parse("2026-06-26T02:17:00Z"),
|
||||
startedAt: Date.parse("2026-06-26T02:18:00Z"),
|
||||
endedAt: Date.parse("2026-06-26T02:19:00Z"),
|
||||
pauseReason: "sessions_yield",
|
||||
wakeOnDescendantSettle: true,
|
||||
cleanupHandled: false,
|
||||
cleanupCompletedAt: undefined,
|
||||
});
|
||||
|
||||
mod.registerSubagentRun({
|
||||
runId: "run-yielded-child-finished",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:subagent:parent",
|
||||
requesterDisplayKey: "parent",
|
||||
task: "descendant settles after yield",
|
||||
cleanup: "keep",
|
||||
});
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "child finished announce"),
|
||||
{ childRunId: "run-yielded-child-finished" },
|
||||
"child finished announce params",
|
||||
);
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentAnnounceFlow, 1, 0, "yielded parent wake announce"),
|
||||
{
|
||||
childRunId: "run-yielded-parent",
|
||||
wakeOnDescendantSettle: true,
|
||||
},
|
||||
"yielded parent wake announce params",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads runtime plugins before emitting killed subagent ended hooks", async () => {
|
||||
const endedHookRunner = {
|
||||
hasHooks: (hookName: string) => hookName === "subagent_ended",
|
||||
|
||||
@@ -626,9 +626,7 @@ function resumeSubagentRun(runId: string) {
|
||||
if (typeof entry.endedAt === "number" && isDeliverySuspended(entry)) {
|
||||
return;
|
||||
}
|
||||
// Yielded runs stay paused until explicitly steered, except orchestrators
|
||||
// waiting on descendants: their settle retry must reach the wake path.
|
||||
if (entry.pauseReason === "sessions_yield" && entry.wakeOnDescendantSettle !== true) {
|
||||
if (entry.pauseReason === "sessions_yield") {
|
||||
return;
|
||||
}
|
||||
// Skip entries that have exhausted their retry budget or expired (#18264).
|
||||
|
||||
@@ -7127,28 +7127,6 @@ describe("runAgentTurnWithFallback", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not suggest re-authentication for typed format failures", async () => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new FailoverError("Format failover exhausted for provider openai", {
|
||||
reason: "format",
|
||||
provider: "openai",
|
||||
authProfileFailure: { allInCooldown: true },
|
||||
cause: new Error("messages must alternate roles"),
|
||||
}),
|
||||
);
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams());
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.text).toContain("Couldn't reach openai");
|
||||
expect(result.payload.text).toContain("messages must alternate roles");
|
||||
expect(result.payload.text).not.toContain("models auth login");
|
||||
expect(result.payload.text).not.toContain("openclaw configure");
|
||||
}
|
||||
});
|
||||
|
||||
it("points stale openai missing-key failures at doctor repair with re-auth fallback", async () => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new Error('No API key found for provider "openai".'),
|
||||
|
||||
@@ -658,29 +658,6 @@ describe("cron cli", () => {
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(["--no-output-timeout-seconds", "--output-max-bytes"])(
|
||||
"cron add %s validation",
|
||||
(flag) => {
|
||||
it.each(["", "0", "-1", "1.5", "1000ms"])("rejects invalid value %j", async (value) => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"Invalid command limit",
|
||||
"--every",
|
||||
"10m",
|
||||
"--command",
|
||||
"echo ok",
|
||||
flag,
|
||||
value,
|
||||
]);
|
||||
|
||||
expectRuntimeErrorContaining(`Invalid ${flag} (must be a positive integer).`);
|
||||
expect(callGatewayFromCli.mock.calls.some((call) => call[0] === "cron.add")).toBe(false);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects cron add with both message and command payloads", async () => {
|
||||
await expectCronCommandExit([
|
||||
"cron",
|
||||
|
||||
@@ -10,7 +10,10 @@ import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parseStrictPositiveIntOrUndefined } from "../program/helpers.js";
|
||||
import {
|
||||
parsePositiveIntOrUndefined,
|
||||
parseStrictPositiveIntOrUndefined,
|
||||
} from "../program/helpers.js";
|
||||
import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
@@ -231,19 +234,8 @@ export function registerCronAddCommand(cron: Command) {
|
||||
? opts.outputTimeoutSeconds
|
||||
: undefined);
|
||||
const noOutputTimeoutSeconds =
|
||||
parseStrictPositiveIntOrUndefined(rawNoOutputTimeoutSeconds);
|
||||
if (
|
||||
rawNoOutputTimeoutSeconds !== undefined &&
|
||||
noOutputTimeoutSeconds === undefined
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid --no-output-timeout-seconds (must be a positive integer).",
|
||||
);
|
||||
}
|
||||
const outputMaxBytes = parseStrictPositiveIntOrUndefined(opts.outputMaxBytes);
|
||||
if (opts.outputMaxBytes !== undefined && outputMaxBytes === undefined) {
|
||||
throw new Error("Invalid --output-max-bytes (must be a positive integer).");
|
||||
}
|
||||
parsePositiveIntOrUndefined(rawNoOutputTimeoutSeconds);
|
||||
const outputMaxBytes = parsePositiveIntOrUndefined(opts.outputMaxBytes);
|
||||
return {
|
||||
kind: "command" as const,
|
||||
argv: commandArgv ?? ["sh", "-lc", commandShell ?? ""],
|
||||
|
||||
@@ -15,7 +15,7 @@ type NodeInvokeCall = {
|
||||
|
||||
let lastNodeInvokeCall: NodeInvokeCall | null = null;
|
||||
|
||||
const callGateway = vi.fn(async (opts: NodeInvokeCall): Promise<unknown> => {
|
||||
const callGateway = vi.fn(async (opts: NodeInvokeCall) => {
|
||||
if (opts.method === "node.list") {
|
||||
return {
|
||||
nodes: [
|
||||
@@ -123,82 +123,6 @@ describe("nodes-cli coverage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("explains unknown nodes approve request ids with the current pending requests", async () => {
|
||||
callGateway.mockResolvedValueOnce({
|
||||
pending: [{ requestId: "current-request", nodeId: "n1", ts: Date.now() }],
|
||||
paired: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sharedProgram.parseAsync(
|
||||
[
|
||||
"nodes",
|
||||
"approve",
|
||||
"stale-request",
|
||||
"--url",
|
||||
"wss://gateway.example.test",
|
||||
"--token",
|
||||
"secret-token",
|
||||
],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const output = runtimeErrors.join("\n");
|
||||
expect(output).toContain("Unknown node pairing requestId: stale-request");
|
||||
expect(output).toContain("Pending requestIds: current-request");
|
||||
expect(output).toContain("openclaw nodes pending");
|
||||
expect(output).toContain("Reuse the same connection options when rerunning: --url, --token.");
|
||||
expect(output).not.toContain("gateway.example.test");
|
||||
expect(output).not.toContain("secret-token");
|
||||
expect(output).not.toContain("nodes approve failed: Error:");
|
||||
expect(output).not.toContain("GatewayClientRequestError: unknown requestId");
|
||||
expect(callGateway.mock.calls.map(([call]) => call.method)).toEqual(["node.pair.list"]);
|
||||
});
|
||||
|
||||
it("explains when a nodes approve request disappears after the preflight", async () => {
|
||||
callGateway
|
||||
.mockResolvedValueOnce({
|
||||
pending: [{ requestId: "expired-request", nodeId: "n1", ts: Date.now() }],
|
||||
paired: [],
|
||||
})
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("unknown requestId"), {
|
||||
name: "GatewayClientRequestError",
|
||||
gatewayCode: "INVALID_REQUEST",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sharedProgram.parseAsync(["nodes", "approve", "expired-request"], { from: "user" }),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
const output = runtimeErrors.join("\n");
|
||||
expect(output).toContain("Unknown node pairing requestId: expired-request");
|
||||
expect(output).not.toContain("No pending node pairing requests are currently visible.");
|
||||
expect(output).not.toContain("Pending requestIds:");
|
||||
expect(output).toContain("openclaw nodes pending");
|
||||
expect(output).not.toContain("GatewayClientRequestError: unknown requestId");
|
||||
expect(callGateway.mock.calls.map(([call]) => call.method)).toEqual([
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still approves when the pairing preflight is unavailable", async () => {
|
||||
callGateway
|
||||
.mockRejectedValueOnce(new Error("pairing list unavailable"))
|
||||
.mockResolvedValueOnce({ approved: true });
|
||||
|
||||
await sharedProgram.parseAsync(["nodes", "approve", "request-1"], { from: "user" });
|
||||
|
||||
expect(callGateway.mock.calls.map(([call]) => call.method)).toEqual([
|
||||
"node.pair.list",
|
||||
"node.pair.approve",
|
||||
]);
|
||||
expect(defaultRuntime.writeJson).toHaveBeenCalledWith({ approved: true });
|
||||
});
|
||||
|
||||
it("blocks system.run on nodes invoke", async () => {
|
||||
await expect(
|
||||
sharedProgram.parseAsync(["nodes", "invoke", "--node", "mac-1", "--command", "system.run"], {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
// Node CLI runtime helpers: terminal theme adaptation and standard error handling.
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import { isRich, theme } from "../../../packages/terminal-core/src/theme.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { unauthorizedHintForMessage } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
|
||||
/** Return color helpers that degrade to plain text in non-rich terminals. */
|
||||
export function getNodesTheme() {
|
||||
@@ -21,20 +18,10 @@ export function getNodesTheme() {
|
||||
};
|
||||
}
|
||||
|
||||
export function formatConnectionFlagReminder(opts: NodesRpcOpts): string | null {
|
||||
const flags = [
|
||||
normalizeOptionalString(opts.url) ? "--url" : null,
|
||||
normalizeOptionalString(opts.token) ? "--token" : null,
|
||||
].filter((flag) => flag !== null);
|
||||
return flags.length > 0
|
||||
? `Reuse the same connection option${flags.length === 1 ? "" : "s"} when rerunning: ${flags.join(", ")}.`
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Run a node CLI action with standard failure text and authorization hints. */
|
||||
export function runNodesCommand(label: string, action: () => Promise<void>) {
|
||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||
const message = formatErrorMessage(err);
|
||||
const message = String(err);
|
||||
const { error, warn } = getNodesTheme();
|
||||
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
|
||||
const hint = unauthorizedHintForMessage(message);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { OperatorScope } from "../../gateway/method-scopes.js";
|
||||
import { resolveNodePairApprovalScopes } from "../../infra/node-pairing-authz.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { parsePairingList } from "./format.js";
|
||||
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
|
||||
import {
|
||||
@@ -44,8 +44,7 @@ function normalizeNodePairApproveScopes(scopes: unknown): OperatorScope[] {
|
||||
async function resolveApproveScopesForRequest(
|
||||
opts: NodesRpcOpts,
|
||||
requestId: string,
|
||||
): Promise<{ scopes: OperatorScope[] }> {
|
||||
let pending: PendingRequest[];
|
||||
): Promise<OperatorScope[]> {
|
||||
try {
|
||||
const result = await callNodePairApprovalGatewayCli(
|
||||
"node.pair.list",
|
||||
@@ -53,58 +52,17 @@ async function resolveApproveScopesForRequest(
|
||||
{},
|
||||
{ scopes: DEFAULT_NODE_PAIR_APPROVE_SCOPES },
|
||||
);
|
||||
pending = parsePairingList(result).pending;
|
||||
} catch {
|
||||
return { scopes: [...DEFAULT_NODE_PAIR_APPROVE_SCOPES] };
|
||||
}
|
||||
const pendingRequestIds = pending
|
||||
.map((request) => request.requestId)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
const request = pending.find((candidate) => candidate.requestId === requestId);
|
||||
if (!request) {
|
||||
throw new Error(buildUnknownNodePairRequestIdMessage(requestId, opts, pendingRequestIds));
|
||||
}
|
||||
const declaredScopes = normalizeNodePairApproveScopes(request.requiredApproveScopes);
|
||||
if (declaredScopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) {
|
||||
return { scopes: declaredScopes };
|
||||
}
|
||||
// Older pending requests only list requested commands; derive approval scopes from them.
|
||||
return {
|
||||
scopes: resolveNodePairApprovalScopes(request.commands) as OperatorScope[],
|
||||
};
|
||||
}
|
||||
|
||||
function isUnknownNodePairRequestIdError(
|
||||
error: unknown,
|
||||
): error is Error & { gatewayCode: "INVALID_REQUEST" } {
|
||||
const requestError = error as (Error & { gatewayCode?: unknown }) | undefined;
|
||||
return (
|
||||
requestError instanceof Error &&
|
||||
requestError.name === "GatewayClientRequestError" &&
|
||||
requestError.gatewayCode === "INVALID_REQUEST" &&
|
||||
requestError.message === "unknown requestId"
|
||||
);
|
||||
}
|
||||
|
||||
function buildUnknownNodePairRequestIdMessage(
|
||||
requestId: string,
|
||||
opts: NodesRpcOpts,
|
||||
pendingRequestIds?: string[],
|
||||
): string {
|
||||
const lines = [`Unknown node pairing requestId: ${requestId}`];
|
||||
if (pendingRequestIds !== undefined) {
|
||||
if (pendingRequestIds.length > 0) {
|
||||
lines.push(`Pending requestIds: ${pendingRequestIds.join(", ")}`);
|
||||
} else {
|
||||
lines.push("No pending node pairing requests are currently visible.");
|
||||
const { pending } = parsePairingList(result);
|
||||
const request = pending.find((candidate: PendingRequest) => candidate.requestId === requestId);
|
||||
const scopes = normalizeNodePairApproveScopes(request?.requiredApproveScopes);
|
||||
if (scopes.length > DEFAULT_NODE_PAIR_APPROVE_SCOPES.length) {
|
||||
return scopes;
|
||||
}
|
||||
// Older pending requests only list requested commands; derive approval scopes from them.
|
||||
return resolveNodePairApprovalScopes(request?.commands) as OperatorScope[];
|
||||
} catch {
|
||||
return [...DEFAULT_NODE_PAIR_APPROVE_SCOPES];
|
||||
}
|
||||
lines.push(`Run ${formatCliCommand("openclaw nodes pending")} to inspect current requests.`);
|
||||
const connectionReminder = formatConnectionFlagReminder(opts);
|
||||
if (connectionReminder) {
|
||||
lines.push(connectionReminder);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Register node pairing management commands. */
|
||||
@@ -148,28 +106,17 @@ export function registerNodesPairingCommands(nodes: Command) {
|
||||
.argument("<requestId>", "Pending request id")
|
||||
.action(async (requestId: string, opts: NodesRpcOpts) => {
|
||||
await runNodesCommand("approve", async () => {
|
||||
const { scopes } = await resolveApproveScopesForRequest(opts, requestId);
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await callNodePairApprovalGatewayCli(
|
||||
"node.pair.approve",
|
||||
opts,
|
||||
{
|
||||
requestId,
|
||||
},
|
||||
{
|
||||
scopes,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isUnknownNodePairRequestIdError(error)) {
|
||||
throw error;
|
||||
}
|
||||
// Reuse the gateway error so generic formatting does not append its raw cause.
|
||||
error.name = "Error";
|
||||
error.message = buildUnknownNodePairRequestIdMessage(requestId, opts);
|
||||
throw error;
|
||||
}
|
||||
const scopes = await resolveApproveScopesForRequest(opts, requestId);
|
||||
const result = await callNodePairApprovalGatewayCli(
|
||||
"node.pair.approve",
|
||||
opts,
|
||||
{
|
||||
requestId,
|
||||
},
|
||||
{
|
||||
scopes,
|
||||
},
|
||||
);
|
||||
defaultRuntime.writeJson(result);
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ import { shortenHomeInString } from "../../utils.js";
|
||||
import { formatCliCommand } from "../command-format.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
import { quoteCliArg } from "../quote-cli-arg.js";
|
||||
import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
|
||||
import { renderPendingPairingRequestsTable } from "./pairing-render.js";
|
||||
import {
|
||||
@@ -146,6 +146,16 @@ function formatPendingApprovalCommand(raw: unknown, opts: NodesRpcOpts): string
|
||||
return formatCliCommand(args.map(quoteCliArg).join(" "));
|
||||
}
|
||||
|
||||
function formatConnectionFlagReminder(opts: NodesRpcOpts): string | null {
|
||||
const flags = [
|
||||
normalizeOptionalString(opts.url) ? "--url" : null,
|
||||
normalizeOptionalString(opts.token) ? "--token" : null,
|
||||
].filter((flag) => flag !== null);
|
||||
return flags.length > 0
|
||||
? `Reuse the same ${flags.join("/")} option${flags.length === 1 ? "" : "s"} when rerunning.`
|
||||
: null;
|
||||
}
|
||||
|
||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
if (raw === undefined || raw === null) {
|
||||
return undefined;
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from "node:path";
|
||||
import { Type } from "typebox";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { defineToolPlugin, getToolPluginMetadata } from "../plugin-sdk/tool-plugin.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
buildToolPluginManifest,
|
||||
buildToolPluginPackageManifest,
|
||||
@@ -343,7 +342,6 @@ describe("plugin authoring commands", () => {
|
||||
scripts: {
|
||||
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
|
||||
"plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
},
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
@@ -364,110 +362,5 @@ describe("plugin authoring commands", () => {
|
||||
expect(fs.readFileSync(path.join(projectDir, "src/index.test.ts"), "utf8")).toContain(
|
||||
"getToolPluginMetadata",
|
||||
);
|
||||
expect(fs.readFileSync(path.join(projectDir, "vitest.config.ts"), "utf8")).toContain(
|
||||
'include: ["src/**/*.test.ts"]',
|
||||
);
|
||||
});
|
||||
|
||||
it("scaffolds a provider plugin project with ClawHub validation and release metadata", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-init-"));
|
||||
const projectDir = path.join(tmpDir, "plugin-init-test");
|
||||
|
||||
await runPluginsInitCommand("plugin-init-test", {
|
||||
directory: projectDir,
|
||||
name: "Plugin Init Test",
|
||||
type: "provider",
|
||||
});
|
||||
|
||||
const packageManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(projectDir, "package.json"), "utf8"),
|
||||
);
|
||||
expect(packageManifest).toMatchObject({
|
||||
name: "openclaw-plugin-plugin-init-test",
|
||||
scripts: {
|
||||
build: "tsc -p tsconfig.json",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
validate: "npm run build && clawhub package validate . --out .clawhub-validation",
|
||||
},
|
||||
peerDependencies: {
|
||||
openclaw: `>=${VERSION}`,
|
||||
},
|
||||
devDependencies: {
|
||||
clawhub: "latest",
|
||||
openclaw: "latest",
|
||||
typescript: "^5.9.0",
|
||||
vitest: "^3.2.0",
|
||||
},
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
install: {
|
||||
clawhubSpec: "clawhub:openclaw-plugin-plugin-init-test",
|
||||
defaultChoice: "clawhub",
|
||||
minHostVersion: `>=${VERSION}`,
|
||||
},
|
||||
compat: {
|
||||
pluginApi: `>=${VERSION}`,
|
||||
},
|
||||
build: {
|
||||
openclawVersion: VERSION,
|
||||
},
|
||||
release: {
|
||||
publishToClawHub: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(packageManifest.scripts).not.toHaveProperty("plugin:build");
|
||||
expect(packageManifest.scripts).not.toHaveProperty("plugin:validate");
|
||||
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(projectDir, "openclaw.plugin.json"), "utf8"),
|
||||
);
|
||||
expect(manifest).toMatchObject({
|
||||
id: "plugin-init-test",
|
||||
name: "Plugin Init Test",
|
||||
version: "0.1.0",
|
||||
providers: ["plugin-init-test"],
|
||||
setup: {
|
||||
providers: [
|
||||
{
|
||||
id: "plugin-init-test",
|
||||
envVars: ["PLUGIN_INIT_TEST_API_KEY"],
|
||||
},
|
||||
],
|
||||
},
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
|
||||
const indexSource = fs.readFileSync(path.join(projectDir, "src/index.ts"), "utf8");
|
||||
expect(indexSource).toContain("definePluginEntry");
|
||||
expect(indexSource).toContain("api.registerProvider");
|
||||
expect(indexSource).toContain("buildSingleProviderApiKeyCatalog");
|
||||
|
||||
expect(fs.readFileSync(path.join(projectDir, "src/index.test.ts"), "utf8")).toContain(
|
||||
"OpenClawPluginApi",
|
||||
);
|
||||
expect(fs.readFileSync(path.join(projectDir, "vitest.config.ts"), "utf8")).toContain(
|
||||
'include: ["src/**/*.test.ts"]',
|
||||
);
|
||||
const readme = fs.readFileSync(path.join(projectDir, "README.md"), "utf8");
|
||||
expect(readme).toContain("npm run validate");
|
||||
expect(readme).toContain("npm exec clawhub -- login");
|
||||
expect(readme).toContain("npm exec clawhub -- package publish .");
|
||||
expect(readme).toContain("npm exec clawhub -- package trusted-publisher set");
|
||||
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(projectDir, ".github/workflows/clawhub-publish.yml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(workflow).not.toContain("release:");
|
||||
expect(workflow).not.toContain("secrets: inherit");
|
||||
expect(workflow).toContain("workflow_dispatch:");
|
||||
expect(workflow).toContain(
|
||||
"openclaw/clawhub/.github/workflows/package-publish.yml@9d49df109d4ad3dc8a6ecf05d26b39f46d294721",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import { buildPluginLoaderAliasMap } from "../plugins/sdk-alias.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { toSafeImportPath } from "../shared/import-specifier.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { VERSION } from "../version.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
@@ -36,22 +35,13 @@ export type PluginsInitOptions = {
|
||||
directory?: string;
|
||||
force?: boolean;
|
||||
name?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type PluginScaffoldType = "tool" | "provider";
|
||||
|
||||
type LoadedToolPlugin = {
|
||||
entry: unknown;
|
||||
metadata: ToolPluginMetadata;
|
||||
};
|
||||
|
||||
const SUPPORTED_PLUGIN_SCAFFOLD_TYPES = [
|
||||
"tool",
|
||||
"provider",
|
||||
] as const satisfies readonly PluginScaffoldType[];
|
||||
const CLAWHUB_PACKAGE_PUBLISH_WORKFLOW_REF = "9d49df109d4ad3dc8a6ecf05d26b39f46d294721";
|
||||
|
||||
const toolPluginEntryModuleLoaders = createPluginModuleLoaderCache();
|
||||
|
||||
function readJsonFile(filePath: string): JsonObject {
|
||||
@@ -346,34 +336,6 @@ function assertCanCreate(filePath: string, force: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveScaffoldType(input: string | undefined): PluginScaffoldType {
|
||||
const type = input ?? "tool";
|
||||
if (SUPPORTED_PLUGIN_SCAFFOLD_TYPES.includes(type as PluginScaffoldType)) {
|
||||
return type as PluginScaffoldType;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported plugin scaffold type "${type}". Supported types: ${SUPPORTED_PLUGIN_SCAFFOLD_TYPES.join(
|
||||
", ",
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDisplayName(input: string): string {
|
||||
const name = input.trim();
|
||||
if (!name) {
|
||||
throw new Error("Plugin display name is required.");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function normalizePluginId(input: string): string {
|
||||
const id = input.trim();
|
||||
if (!id) {
|
||||
throw new Error("Plugin id is required.");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function titleFromId(id: string): string {
|
||||
return id
|
||||
.split(/[-_]/u)
|
||||
@@ -382,61 +344,15 @@ function titleFromId(id: string): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function upperSnakeFromId(id: string): string {
|
||||
return id
|
||||
.replace(/[^a-z0-9]+/giu, "_")
|
||||
.replace(/^_+|_+$/gu, "")
|
||||
.toUpperCase();
|
||||
}
|
||||
export async function runPluginsInitCommand(id: string, opts: PluginsInitOptions): Promise<void> {
|
||||
const rootDir = path.resolve(opts.directory ?? id);
|
||||
const force = opts.force === true;
|
||||
const name = opts.name ?? titleFromId(id);
|
||||
assertCanCreate(rootDir, force);
|
||||
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
|
||||
|
||||
function lowerCamelFromId(id: string): string {
|
||||
const parts = id.split(/-+/u).filter(Boolean);
|
||||
return parts
|
||||
.map((part, index) => (index === 0 ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createConfigSchema() {
|
||||
return {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildScaffoldTsconfig(type: PluginScaffoldType): JsonObject {
|
||||
return {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
strict: true,
|
||||
declaration: type === "tool",
|
||||
outDir: "dist",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
include: type === "provider" ? ["src/index.ts"] : ["src/**/*.ts"],
|
||||
};
|
||||
}
|
||||
|
||||
function writeScaffoldVitestConfig(rootDir: string): void {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "vitest.config.ts"),
|
||||
`import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
function writeToolPluginScaffold(params: { rootDir: string; id: string; name: string }): void {
|
||||
const packageManifest = {
|
||||
name: `openclaw-plugin-${params.id}`,
|
||||
name: `openclaw-plugin-${id}`,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
private: true,
|
||||
@@ -444,7 +360,7 @@ function writeToolPluginScaffold(params: { rootDir: string; id: string; name: st
|
||||
build: "tsc -p tsconfig.json",
|
||||
"plugin:build": "npm run build && openclaw plugins build --entry ./dist/index.js",
|
||||
"plugin:validate": "npm run build && openclaw plugins validate --entry ./dist/index.js",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
test: "vitest run",
|
||||
},
|
||||
files: ["dist", "openclaw.plugin.json", "README.md"],
|
||||
peerDependencies: {
|
||||
@@ -462,10 +378,9 @@ function writeToolPluginScaffold(params: { rootDir: string; id: string; name: st
|
||||
extensions: ["./dist/index.js"],
|
||||
},
|
||||
};
|
||||
const idLiteral = jsStringLiteral(params.id);
|
||||
const nameLiteral = jsStringLiteral(params.name);
|
||||
const description = `Add ${params.name} tools to OpenClaw.`;
|
||||
const descriptionLiteral = jsStringLiteral(description);
|
||||
const idLiteral = jsStringLiteral(id);
|
||||
const nameLiteral = jsStringLiteral(name);
|
||||
const descriptionLiteral = jsStringLiteral(`Add ${name} tools to OpenClaw.`);
|
||||
const indexSource = `import { Type } from "typebox";
|
||||
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
||||
|
||||
@@ -495,7 +410,7 @@ describe(${idLiteral}, () => {
|
||||
});
|
||||
});
|
||||
`;
|
||||
const readmeSource = `# ${params.name}
|
||||
const readmeSource = `# ${name}
|
||||
|
||||
Simple OpenClaw tool plugin.
|
||||
|
||||
@@ -508,305 +423,36 @@ npm run plugin:validate
|
||||
npm test
|
||||
\`\`\`
|
||||
`;
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
strict: true,
|
||||
declaration: true,
|
||||
outDir: "dist",
|
||||
skipLibCheck: true,
|
||||
},
|
||||
include: ["src/**/*.ts"],
|
||||
};
|
||||
|
||||
writeJsonFile(path.join(params.rootDir, "package.json"), packageManifest);
|
||||
fs.writeFileSync(path.join(params.rootDir, "src/index.ts"), indexSource);
|
||||
fs.writeFileSync(path.join(params.rootDir, "src/index.test.ts"), testSource);
|
||||
fs.writeFileSync(path.join(params.rootDir, "README.md"), readmeSource);
|
||||
writeJsonFile(path.join(params.rootDir, PLUGIN_MANIFEST_FILENAME), {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
description,
|
||||
writeJsonFile(path.join(rootDir, "package.json"), packageManifest);
|
||||
fs.writeFileSync(path.join(rootDir, "src/index.ts"), indexSource);
|
||||
fs.writeFileSync(path.join(rootDir, "src/index.test.ts"), testSource);
|
||||
fs.writeFileSync(path.join(rootDir, "README.md"), readmeSource);
|
||||
writeJsonFile(path.join(rootDir, "tsconfig.json"), tsconfig);
|
||||
writeJsonFile(path.join(rootDir, PLUGIN_MANIFEST_FILENAME), {
|
||||
id,
|
||||
name,
|
||||
description: `Add ${name} tools to OpenClaw.`,
|
||||
version: packageManifest.version,
|
||||
configSchema: createConfigSchema(),
|
||||
configSchema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
},
|
||||
activation: { onStartup: true },
|
||||
contracts: { tools: ["echo"] },
|
||||
});
|
||||
}
|
||||
|
||||
function writeProviderPluginScaffold(params: { rootDir: string; id: string; name: string }): void {
|
||||
const packageName = `openclaw-plugin-${params.id}`;
|
||||
const envVar = `${upperSnakeFromId(params.id)}_API_KEY`;
|
||||
const optionKey = `${lowerCamelFromId(params.id)}ApiKey`;
|
||||
const flagName = `--${params.id}-api-key`;
|
||||
const defaultModelId = "example-chat";
|
||||
const defaultModelRef = `${params.id}/${defaultModelId}`;
|
||||
const description = `Add ${params.name} models to OpenClaw.`;
|
||||
const packageManifest = {
|
||||
name: packageName,
|
||||
version: "0.1.0",
|
||||
description: `OpenClaw provider plugin for ${params.name}.`,
|
||||
type: "module",
|
||||
scripts: {
|
||||
build: "tsc -p tsconfig.json",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
validate: "npm run build && clawhub package validate . --out .clawhub-validation",
|
||||
},
|
||||
files: ["dist", "openclaw.plugin.json", "README.md"],
|
||||
peerDependencies: {
|
||||
openclaw: `>=${VERSION}`,
|
||||
},
|
||||
peerDependenciesMeta: {
|
||||
openclaw: {
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
devDependencies: {
|
||||
clawhub: "latest",
|
||||
openclaw: "latest",
|
||||
typescript: "^5.9.0",
|
||||
vitest: "^3.2.0",
|
||||
},
|
||||
openclaw: {
|
||||
extensions: ["./dist/index.js"],
|
||||
install: {
|
||||
clawhubSpec: `clawhub:${packageName}`,
|
||||
defaultChoice: "clawhub",
|
||||
minHostVersion: `>=${VERSION}`,
|
||||
},
|
||||
compat: {
|
||||
pluginApi: `>=${VERSION}`,
|
||||
},
|
||||
build: {
|
||||
openclawVersion: VERSION,
|
||||
},
|
||||
release: {
|
||||
publishToClawHub: true,
|
||||
},
|
||||
},
|
||||
pluginInspector: {
|
||||
version: 1,
|
||||
plugin: {
|
||||
id: params.id,
|
||||
priority: "high",
|
||||
seams: ["plugin-runtime"],
|
||||
sourceRoot: ".",
|
||||
expect: {
|
||||
registrations: ["registerProvider"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const idLiteral = jsStringLiteral(params.id);
|
||||
const nameLiteral = jsStringLiteral(params.name);
|
||||
const envVarLiteral = jsStringLiteral(envVar);
|
||||
const optionKeyLiteral = jsStringLiteral(optionKey);
|
||||
const flagNameLiteral = jsStringLiteral(flagName);
|
||||
const defaultModelIdLiteral = jsStringLiteral(defaultModelId);
|
||||
const defaultModelRefLiteral = jsStringLiteral(defaultModelRef);
|
||||
const descriptionLiteral = jsStringLiteral(description);
|
||||
const apiKeyLabelLiteral = jsStringLiteral(`${params.name} API key`);
|
||||
const promptMessageLiteral = jsStringLiteral(`Enter ${params.name} API key`);
|
||||
const noteMessageLiteral = jsStringLiteral(
|
||||
`Replace https://api.example.com/v1 with your ${params.name} API base URL.`,
|
||||
);
|
||||
const indexSource = `import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const PLUGIN_ID = ${idLiteral};
|
||||
const PROVIDER_ID = PLUGIN_ID;
|
||||
const DEFAULT_MODEL_ID = ${defaultModelIdLiteral};
|
||||
const DEFAULT_MODEL_REF = ${defaultModelRefLiteral};
|
||||
|
||||
function buildProvider(): ModelProviderConfig {
|
||||
return {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.example.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: DEFAULT_MODEL_ID,
|
||||
name: "Example Chat",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PLUGIN_ID,
|
||||
name: ${nameLiteral},
|
||||
description: ${descriptionLiteral},
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: ${nameLiteral},
|
||||
docsPath: "/providers/${params.id}",
|
||||
envVars: [${envVarLiteral}],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: ${apiKeyLabelLiteral},
|
||||
hint: "OpenAI-compatible API endpoint",
|
||||
optionKey: ${optionKeyLiteral},
|
||||
flagName: ${flagNameLiteral},
|
||||
envVar: ${envVarLiteral},
|
||||
promptMessage: ${promptMessageLiteral},
|
||||
defaultModel: DEFAULT_MODEL_REF,
|
||||
expectedProviders: [PROVIDER_ID],
|
||||
noteTitle: ${nameLiteral},
|
||||
noteMessage: ${noteMessageLiteral},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: (ctx) =>
|
||||
buildSingleProviderApiKeyCatalog({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
buildProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
`;
|
||||
const testSource = `import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawPluginApi, ProviderPlugin } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import entry from "./index.js";
|
||||
|
||||
describe(${idLiteral}, () => {
|
||||
it("registers the provider", () => {
|
||||
const providers: ProviderPlugin[] = [];
|
||||
const api = {
|
||||
registerProvider(provider: ProviderPlugin) {
|
||||
providers.push(provider);
|
||||
},
|
||||
} as Partial<OpenClawPluginApi>;
|
||||
|
||||
entry.register(api as OpenClawPluginApi);
|
||||
|
||||
expect(providers.map((provider) => provider.id)).toEqual([${idLiteral}]);
|
||||
expect(providers[0]?.label).toBe(${nameLiteral});
|
||||
expect(providers[0]?.envVars).toEqual([${envVarLiteral}]);
|
||||
});
|
||||
});
|
||||
`;
|
||||
const readmeSource = `# ${params.name}
|
||||
|
||||
OpenClaw provider plugin for ${params.name}.
|
||||
|
||||
## Commands
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm run build
|
||||
npm test
|
||||
npm run validate
|
||||
\`\`\`
|
||||
|
||||
\`npm run validate\` builds the plugin and runs \`clawhub package validate . --out .clawhub-validation\`.
|
||||
|
||||
## Provider Setup
|
||||
|
||||
The generated provider uses an OpenAI-compatible API shape, \`${envVar}\` for API-key auth, and \`https://api.example.com/v1\` as a placeholder base URL. Update \`src/index.ts\` with your provider's real base URL, model list, docs route, and credential copy before publishing.
|
||||
|
||||
## First Publish
|
||||
|
||||
Install dependencies, log in to the ClawHub CLI, then validate and publish manually once:
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm exec clawhub -- login
|
||||
npm run validate
|
||||
npm exec clawhub -- package publish .
|
||||
\`\`\`
|
||||
|
||||
That first publish creates the ClawHub package and establishes the package managers who can configure trusted publishing.
|
||||
|
||||
## Trusted Publishing
|
||||
|
||||
After the first publish, configure GitHub Actions OIDC publishing for future releases:
|
||||
|
||||
\`\`\`bash
|
||||
npm exec clawhub -- package trusted-publisher set ${packageName} \\
|
||||
--repository <owner>/<repo> \\
|
||||
--workflow-filename clawhub-publish.yml
|
||||
\`\`\`
|
||||
|
||||
Future release publishes can run through the manually dispatched \`.github/workflows/clawhub-publish.yml\` action without a long-lived ClawHub token. Run it first with \`dry_run=true\`, then rerun with \`dry_run=false\` after the preview is clean.
|
||||
`;
|
||||
const workflowSource = `name: ClawHub Publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Preview without publishing
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: openclaw/clawhub/.github/workflows/package-publish.yml@${CLAWHUB_PACKAGE_PUBLISH_WORKFLOW_REF}
|
||||
with:
|
||||
dry_run: \${{ inputs.dry_run }}
|
||||
`;
|
||||
|
||||
writeJsonFile(path.join(params.rootDir, "package.json"), packageManifest);
|
||||
fs.writeFileSync(path.join(params.rootDir, "src/index.ts"), indexSource);
|
||||
fs.writeFileSync(path.join(params.rootDir, "src/index.test.ts"), testSource);
|
||||
fs.writeFileSync(path.join(params.rootDir, "README.md"), readmeSource);
|
||||
fs.mkdirSync(path.join(params.rootDir, ".github/workflows"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(params.rootDir, ".github/workflows/clawhub-publish.yml"),
|
||||
workflowSource,
|
||||
);
|
||||
writeJsonFile(path.join(params.rootDir, PLUGIN_MANIFEST_FILENAME), {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
description,
|
||||
version: packageManifest.version,
|
||||
providers: [params.id],
|
||||
setup: {
|
||||
providers: [
|
||||
{
|
||||
id: params.id,
|
||||
envVars: [envVar],
|
||||
},
|
||||
],
|
||||
},
|
||||
configSchema: createConfigSchema(),
|
||||
activation: { onStartup: true, providers: [params.id], capabilities: ["provider"] },
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPluginsInitCommand(
|
||||
idInput: string,
|
||||
opts: PluginsInitOptions,
|
||||
): Promise<void> {
|
||||
const id = normalizePluginId(idInput);
|
||||
const name = opts.name ? normalizeDisplayName(opts.name) : titleFromId(id);
|
||||
const type = resolveScaffoldType(opts.type);
|
||||
const rootDir = path.resolve(opts.directory ?? id);
|
||||
const force = opts.force === true;
|
||||
assertCanCreate(rootDir, force);
|
||||
fs.mkdirSync(path.join(rootDir, "src"), { recursive: true });
|
||||
const tsconfig = buildScaffoldTsconfig(type);
|
||||
|
||||
if (type === "provider") {
|
||||
writeProviderPluginScaffold({ rootDir, id, name });
|
||||
} else {
|
||||
writeToolPluginScaffold({ rootDir, id, name });
|
||||
}
|
||||
writeJsonFile(path.join(rootDir, "tsconfig.json"), tsconfig);
|
||||
writeScaffoldVitestConfig(rootDir);
|
||||
defaultRuntime.log(`Created ${path.relative(process.cwd(), rootDir) || "."}`);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export type PluginAuthoringValidateOptions = {
|
||||
export type PluginAuthoringInitOptions = {
|
||||
directory?: string;
|
||||
force?: boolean;
|
||||
type?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function createModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
|
||||
@@ -261,11 +261,10 @@ export function registerPluginsCli(program: Command) {
|
||||
|
||||
plugins
|
||||
.command("init")
|
||||
.description("Create a plugin project")
|
||||
.description("Create a simple tool plugin project")
|
||||
.argument("<id>", "Plugin id")
|
||||
.option("--directory <path>", "Output directory")
|
||||
.option("--name <name>", "Display name")
|
||||
.option("--type <type>", "Scaffold type (tool or provider)", "tool")
|
||||
.option("--force", "Overwrite an existing output directory", false)
|
||||
.action(async (id: string, opts: PluginAuthoringInitOptions) => {
|
||||
const { runPluginsInitCommand } = await loadPluginsAuthoringCommands();
|
||||
|
||||
@@ -562,7 +562,7 @@ describe("cli program (nodes basics)", () => {
|
||||
|
||||
const output = getRuntimeOutput();
|
||||
expect(output).toContain("openclaw nodes approve request-reapproval --timeout 3000");
|
||||
expect(output).toContain("Reuse the same connection options when rerunning: --url, --token.");
|
||||
expect(output).toContain("Reuse the same --url/--token options when rerunning.");
|
||||
expect(output).not.toContain("gateway-user");
|
||||
expect(output).not.toContain("url-secret");
|
||||
expect(output).not.toContain("gateway.example");
|
||||
|
||||
@@ -13,39 +13,6 @@ type CallPluginToolParams = {
|
||||
arguments?: unknown;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toMcpContentBlock(block: unknown): unknown {
|
||||
if (!isRecord(block)) {
|
||||
return { type: "text", text: coerceChatContentText(block) };
|
||||
}
|
||||
if (block.type !== "image") {
|
||||
return block;
|
||||
}
|
||||
|
||||
if (typeof block.data === "string" && typeof block.mimeType === "string") {
|
||||
return block;
|
||||
}
|
||||
|
||||
const source = block.source;
|
||||
if (
|
||||
isRecord(source) &&
|
||||
source.type === "base64" &&
|
||||
typeof source.data === "string" &&
|
||||
typeof source.media_type === "string"
|
||||
) {
|
||||
return {
|
||||
type: "image",
|
||||
data: source.data,
|
||||
mimeType: source.media_type,
|
||||
};
|
||||
}
|
||||
|
||||
return { type: "text", text: coerceChatContentText(block) };
|
||||
}
|
||||
|
||||
function resolveJsonSchemaForTool(tool: AnyAgentTool): Record<string, unknown> {
|
||||
const params = tool.parameters;
|
||||
if (params && typeof params === "object" && "type" in params) {
|
||||
@@ -92,7 +59,7 @@ export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) {
|
||||
: result;
|
||||
return {
|
||||
content: Array.isArray(rawContent)
|
||||
? rawContent.map(toMcpContentBlock)
|
||||
? rawContent
|
||||
: [{ type: "text", text: coerceChatContentText(rawContent) }],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Plugin MCP serve tests cover serving plugin tools over MCP.
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type HookContext,
|
||||
@@ -183,82 +180,6 @@ describe("plugin tools MCP server", () => {
|
||||
expect(result.content).toEqual([{ type: "text", text: "Stored." }]);
|
||||
});
|
||||
|
||||
it("serializes source-shaped image tool content with pinned MCP image blocks", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: [
|
||||
{ type: "text", text: "browser screenshot" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: "iVBORw0KGgo=",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const tool = {
|
||||
name: "browser_screenshot",
|
||||
description: "Capture a browser screenshot",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute,
|
||||
} as unknown as AnyAgentTool;
|
||||
|
||||
const handlers = createPluginToolsMcpHandlers([tool]);
|
||||
const result = await handlers.callTool({
|
||||
name: "browser_screenshot",
|
||||
arguments: {},
|
||||
});
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "browser screenshot" },
|
||||
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||
]);
|
||||
expect(() => CallToolResultSchema.parse(result)).not.toThrow();
|
||||
});
|
||||
|
||||
it("delivers source-shaped images through a real MCP client", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
content: [
|
||||
{ type: "text", text: "browser screenshot" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: "iVBORw0KGgo=",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const tool = {
|
||||
name: "browser_screenshot",
|
||||
description: "Capture a browser screenshot",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute,
|
||||
} as unknown as AnyAgentTool;
|
||||
const { createToolsMcpServer } =
|
||||
await vi.importActual<typeof import("./tools-stdio-server.js")>("./tools-stdio-server.js");
|
||||
const server = createToolsMcpServer({ name: "plugin-tools-image-test", tools: [tool] });
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client(
|
||||
{ name: "plugin-tools-image-test-client", version: "0.0.0" },
|
||||
{ capabilities: {} },
|
||||
);
|
||||
|
||||
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
||||
try {
|
||||
const result = await client.callTool({ name: "browser_screenshot", arguments: {} });
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "browser screenshot" },
|
||||
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||
]);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes plugin tool results that do not use the MCP content envelope", async () => {
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
provider: "kitchen-sink-search",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// Android Fastlane release gate tests keep Play uploads tied to mobile release refs.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const fastfilePath = path.join(process.cwd(), "apps", "android", "fastlane", "Fastfile");
|
||||
|
||||
function readFastfile(): string {
|
||||
return readFileSync(fastfilePath, "utf8");
|
||||
}
|
||||
|
||||
function functionBody(source: string, name: string): string {
|
||||
const startMarker = `def ${name}`;
|
||||
const start = source.indexOf(startMarker);
|
||||
if (start < 0) {
|
||||
throw new Error(`missing Fastlane helper ${name}`);
|
||||
}
|
||||
|
||||
const rest = source.slice(start + startMarker.length);
|
||||
const nextDef = rest.search(/\n(?:def|load_env_file|platform) /);
|
||||
return nextDef < 0 ? rest : rest.slice(0, nextDef);
|
||||
}
|
||||
|
||||
describe("Android Fastlane release upload gates", () => {
|
||||
it("preflights and records mobile release refs around Play build upload", () => {
|
||||
const fastfile = readFastfile();
|
||||
const uploadBuild = functionBody(fastfile, "upload_play_store_build!");
|
||||
|
||||
expect(fastfile).toContain("def mobile_release_ref_command");
|
||||
expect(fastfile).toContain("def release_git_sha");
|
||||
expect(fastfile).toContain('"--root"');
|
||||
expect(fastfile).toContain('"--sha"');
|
||||
expect(fastfile).toContain("repo_root");
|
||||
expect(uploadBuild).toContain("release_sha = release_git_sha");
|
||||
expect(uploadBuild).toContain("ensure_mobile_release_ref_available!");
|
||||
expect(uploadBuild).toContain("record_mobile_release_ref!");
|
||||
expect(uploadBuild.match(/sha: release_sha/g)).toHaveLength(2);
|
||||
expect(uploadBuild.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
||||
uploadBuild.indexOf("upload_to_play_store("),
|
||||
);
|
||||
expect(uploadBuild.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
|
||||
uploadBuild.indexOf("upload_to_play_store("),
|
||||
);
|
||||
expect(uploadBuild).toContain("unless play_validate_only?");
|
||||
});
|
||||
});
|
||||
@@ -74,27 +74,6 @@ describe("iOS Fastlane release upload gates", () => {
|
||||
expect(uploadCall).toBeGreaterThan(validationCall);
|
||||
});
|
||||
|
||||
it("preflights and records mobile release refs around TestFlight upload", () => {
|
||||
const fastfile = readFastfile();
|
||||
const releaseUpload = laneBody(fastfile, "release_upload");
|
||||
|
||||
expect(fastfile).toContain("def mobile_release_ref_command");
|
||||
expect(fastfile).toContain("def release_git_sha");
|
||||
expect(fastfile).toContain('"--root"');
|
||||
expect(fastfile).toContain('"--sha"');
|
||||
expect(fastfile).toContain("repo_root");
|
||||
expect(releaseUpload).toContain("release_sha = release_git_sha");
|
||||
expect(releaseUpload).toContain("ensure_mobile_release_ref_available!");
|
||||
expect(releaseUpload).toContain("record_mobile_release_ref!");
|
||||
expect(releaseUpload.match(/sha: release_sha/g)).toHaveLength(2);
|
||||
expect(releaseUpload.indexOf("ensure_mobile_release_ref_available!")).toBeLessThan(
|
||||
releaseUpload.indexOf("\n metadata\n"),
|
||||
);
|
||||
expect(releaseUpload.indexOf("record_mobile_release_ref!")).toBeGreaterThan(
|
||||
releaseUpload.indexOf("upload_to_testflight("),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes Watch screenshots as opaque RGB PNGs for App Store upload", () => {
|
||||
const fastfile = readFastfile();
|
||||
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mobileReleaseRefFor,
|
||||
parseArgs,
|
||||
preflightMobileReleaseRef,
|
||||
recordMobileReleaseRef,
|
||||
resolveMobileReleaseRef,
|
||||
} from "../../scripts/mobile-release-ref.ts";
|
||||
|
||||
const SCRIPT_PATH = path.join(process.cwd(), "scripts", "mobile-release-ref.ts");
|
||||
|
||||
function run(command: string, args: string[], cwd: string): string {
|
||||
return execFileSync(command, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
function git(cwd: string, args: string[]): string {
|
||||
return run("git", args, cwd);
|
||||
}
|
||||
|
||||
function createFixtureRepo(): { cleanup: () => void; remote: string; root: string; sha: string } {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw-mobile-release-ref-"));
|
||||
const remote = path.join(root, "remote.git");
|
||||
const checkout = path.join(root, "checkout");
|
||||
|
||||
git(root, ["init", "--bare", remote]);
|
||||
git(root, ["clone", remote, checkout]);
|
||||
git(checkout, ["config", "user.email", "release@example.com"]);
|
||||
git(checkout, ["config", "user.name", "Release Test"]);
|
||||
writeFileSync(path.join(checkout, "README.md"), "release\n", "utf8");
|
||||
git(checkout, ["add", "README.md"]);
|
||||
git(checkout, ["commit", "-m", "initial"]);
|
||||
const sha = git(checkout, ["rev-parse", "HEAD"]).trim();
|
||||
git(checkout, ["push", "origin", "HEAD:main"]);
|
||||
|
||||
return {
|
||||
cleanup: () => rmSync(root, { force: true, recursive: true }),
|
||||
remote: "origin",
|
||||
root: checkout,
|
||||
sha,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mobile-release-ref", () => {
|
||||
it("renders platform release refs from store identities", () => {
|
||||
expect(mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "8" })).toBe(
|
||||
"refs/openclaw/mobile-releases/ios/2026.6.10-8",
|
||||
);
|
||||
expect(
|
||||
mobileReleaseRefFor({
|
||||
platform: "android",
|
||||
version: "2026.6.10",
|
||||
versionCode: "2026061008",
|
||||
}),
|
||||
).toBe("refs/openclaw/mobile-releases/android/2026.6.10-2026061008");
|
||||
});
|
||||
|
||||
it("validates platform-specific numeric identities", () => {
|
||||
expect(() =>
|
||||
mobileReleaseRefFor({ platform: "ios", version: "2026.6.10", build: "0" }),
|
||||
).toThrow("Invalid iOS build");
|
||||
expect(() =>
|
||||
mobileReleaseRefFor({
|
||||
platform: "android",
|
||||
version: "2026.6.10",
|
||||
versionCode: "not-a-code",
|
||||
}),
|
||||
).toThrow("Invalid Android versionCode");
|
||||
expect(() =>
|
||||
mobileReleaseRefFor({
|
||||
platform: "android",
|
||||
version: "2026.6.10",
|
||||
versionCode: "2026061101",
|
||||
}),
|
||||
).toThrow("Expected 2026061001 through 2026061099");
|
||||
expect(() =>
|
||||
mobileReleaseRefFor({ platform: "ios", version: "2026.06.10", build: "8" }),
|
||||
).toThrow("Invalid mobile release version");
|
||||
});
|
||||
|
||||
it("parses CLI commands and rejects missing platform-specific fields", () => {
|
||||
expect(
|
||||
parseArgs([
|
||||
"record",
|
||||
"--",
|
||||
"--platform",
|
||||
"android",
|
||||
"--version",
|
||||
"2026.6.10",
|
||||
"--version-code",
|
||||
"2026061008",
|
||||
"--sha",
|
||||
"HEAD",
|
||||
]),
|
||||
).toMatchObject({
|
||||
command: "record",
|
||||
platform: "android",
|
||||
version: "2026.6.10",
|
||||
versionCode: "2026061008",
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
mobileReleaseRefFor({
|
||||
platform: "android",
|
||||
version: "2026.6.10",
|
||||
}),
|
||||
).toThrow("Invalid Android versionCode");
|
||||
});
|
||||
|
||||
it("creates, resolves, and idempotently accepts an existing same-SHA ref", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
try {
|
||||
const options = {
|
||||
build: "8",
|
||||
command: "record" as const,
|
||||
platform: "ios" as const,
|
||||
remote: fixture.remote,
|
||||
rootDir: fixture.root,
|
||||
sha: "HEAD",
|
||||
version: "2026.6.10",
|
||||
versionCode: null,
|
||||
};
|
||||
|
||||
expect(preflightMobileReleaseRef(options).status).toBe("available");
|
||||
expect(recordMobileReleaseRef(options)).toMatchObject({
|
||||
ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8",
|
||||
sha: fixture.sha,
|
||||
status: "created",
|
||||
});
|
||||
expect(recordMobileReleaseRef(options).status).toBe("already-recorded");
|
||||
expect(resolveMobileReleaseRef(options)).toMatchObject({
|
||||
ref: "refs/openclaw/mobile-releases/ios/2026.6.10-8",
|
||||
sha: fixture.sha,
|
||||
});
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an existing ref at a different SHA", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
try {
|
||||
const first = {
|
||||
build: null,
|
||||
command: "record" as const,
|
||||
platform: "android" as const,
|
||||
remote: fixture.remote,
|
||||
rootDir: fixture.root,
|
||||
sha: "HEAD",
|
||||
version: "2026.6.10",
|
||||
versionCode: "2026061008",
|
||||
};
|
||||
recordMobileReleaseRef(first);
|
||||
|
||||
writeFileSync(path.join(fixture.root, "README.md"), "next\n", "utf8");
|
||||
git(fixture.root, ["add", "README.md"]);
|
||||
git(fixture.root, ["commit", "-m", "next"]);
|
||||
|
||||
expect(() => recordMobileReleaseRef(first)).toThrow("already points at");
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints the resolved SHA from the CLI", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
try {
|
||||
recordMobileReleaseRef({
|
||||
build: "9",
|
||||
command: "record",
|
||||
platform: "ios",
|
||||
remote: fixture.remote,
|
||||
rootDir: fixture.root,
|
||||
sha: "HEAD",
|
||||
version: "2026.6.10",
|
||||
versionCode: null,
|
||||
});
|
||||
|
||||
const stdout = run(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
SCRIPT_PATH,
|
||||
"resolve",
|
||||
"--platform",
|
||||
"ios",
|
||||
"--version",
|
||||
"2026.6.10",
|
||||
"--build",
|
||||
"9",
|
||||
"--root",
|
||||
fixture.root,
|
||||
],
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
expect(stdout).toBe(`${fixture.sha}\trefs/openclaw/mobile-releases/ios/2026.6.10-9\n`);
|
||||
} finally {
|
||||
fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the CLI entrypoint from a path containing spaces", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "openclaw mobile release ref-"));
|
||||
try {
|
||||
const scriptDir = path.join(root, "script dir");
|
||||
const scriptPath = path.join(scriptDir, "mobile-release-ref.ts");
|
||||
mkdirSync(scriptDir, { recursive: true });
|
||||
writeFileSync(path.join(root, "package.json"), '{"type":"module"}\n', "utf8");
|
||||
copyFileSync(SCRIPT_PATH, scriptPath);
|
||||
|
||||
const stdout = run(
|
||||
process.execPath,
|
||||
["--import", "tsx", realpathSync(scriptPath), "--help"],
|
||||
process.cwd(),
|
||||
);
|
||||
|
||||
expect(stdout).toContain("scripts/mobile-release-ref.ts preflight");
|
||||
} finally {
|
||||
rmSync(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectNativeI18nEntries, NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts";
|
||||
|
||||
describe("native app i18n inventory", () => {
|
||||
it("collects stable Android and Apple UI entries", async () => {
|
||||
const entries = await collectNativeI18nEntries();
|
||||
const surfaces = new Set(entries.map((entry) => entry.surface));
|
||||
|
||||
expect(entries.length).toBeGreaterThan(100);
|
||||
expect(surfaces).toEqual(new Set(["android", "apple"]));
|
||||
expect(entries.every((entry) => entry.id.startsWith(`native.${entry.surface}.`))).toBe(true);
|
||||
expect(new Set(entries.map((entry) => entry.id)).size).toBe(entries.length);
|
||||
expect(
|
||||
entries.every(
|
||||
(entry) => !/(?:\/|\\)(?:Tests?|UITests?|test|Preview(?:s)?)(?:\/|\\)/u.test(entry.path),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries.every(
|
||||
(entry) => !/(?:Tests?|UITests?|Previews?|Testing)\.(?:swift|kt|kts)$/u.test(entry.path),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
entries
|
||||
.filter((entry) => entry.surface === "apple")
|
||||
.every((entry) =>
|
||||
/^(?:apps\/ios|apps\/macos\/Sources|apps\/shared\/OpenClawKit\/Sources)\//u.test(
|
||||
entry.path,
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "QR Scanner Unavailable")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Request ID: \\(requestId)")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Open ${row.title}")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "$deviceModel · $appVersion")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Approval command copied")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Save Profile")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Pairing required")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Mute")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Creating...")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Permission required")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Searching…")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Run now")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "Loading chat")).toBe(true);
|
||||
expect(entries.some((entry) => entry.source === "$(PRODUCT_BUNDLE_IDENTIFIER)")).toBe(false);
|
||||
expect(entries.some((entry) => entry.source === "false")).toBe(false);
|
||||
expect(entries.some((entry) => entry.source === "ws")).toBe(false);
|
||||
expect(entries.some((entry) => entry.source === '{"includeSecrets":true}')).toBe(false);
|
||||
expect(entries.some((entry) => entry.source === "State: \\(stateDir)")).toBe(true);
|
||||
expect(entries.some((entry) => entry.path.endsWith("Info.plist"))).toBe(true);
|
||||
expect(NATIVE_I18N_LOCALES).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -396,15 +396,14 @@ function renderTimeSeriesCompact(
|
||||
if (startDate || endDate || (selectedDays && selectedDays.length > 0)) {
|
||||
const startTs = startDate ? new Date(startDate + "T00:00:00").getTime() : 0;
|
||||
const endTs = endDate ? new Date(endDate + "T23:59:59").getTime() : Infinity;
|
||||
const selectedDaySet = selectedDays?.length ? new Set(selectedDays) : undefined;
|
||||
points = timeSeries.points.filter((p) => {
|
||||
if (p.timestamp < startTs || p.timestamp > endTs) {
|
||||
return false;
|
||||
}
|
||||
if (selectedDaySet) {
|
||||
if (selectedDays && selectedDays.length > 0) {
|
||||
const d = new Date(p.timestamp);
|
||||
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return selectedDaySet.has(dateStr);
|
||||
return selectedDays.includes(dateStr);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -398,7 +398,6 @@ function renderDailyChartCompact(
|
||||
// Calculate bar width based on number of days
|
||||
const barMaxWidth = daily.length > 30 ? 12 : daily.length > 20 ? 18 : daily.length > 14 ? 24 : 32;
|
||||
const showTotals = daily.length <= 14;
|
||||
const selectedDaySet = new Set(selectedDays);
|
||||
|
||||
return html`
|
||||
<div class="daily-chart-compact">
|
||||
@@ -425,7 +424,7 @@ function renderDailyChartCompact(
|
||||
<div class="daily-chart-bars" style="--bar-max-width: ${barMaxWidth}px">
|
||||
${daily.map((d, idx) => {
|
||||
const heightPx = barHeights[idx];
|
||||
const isSelected = selectedDaySet.has(d.date);
|
||||
const isSelected = selectedDays.includes(d.date);
|
||||
const label = formatDayLabel(d.date);
|
||||
// Shorter label for many days (just day number)
|
||||
const shortLabel =
|
||||
|
||||
@@ -163,8 +163,6 @@ export function renderUsage(props: UsageProps) {
|
||||
const isTokenMode = display.chartMode === "tokens";
|
||||
const hasQuery = filters.query.trim().length > 0;
|
||||
const hasDraftQuery = filters.queryDraft.trim().length > 0;
|
||||
const selectedDaySet = new Set(filters.selectedDays);
|
||||
const selectedSessionSet = new Set(filters.selectedSessions);
|
||||
|
||||
// Sort sessions by tokens or cost depending on mode
|
||||
const sortedSessions = [...data.sessions].toSorted((a, b) => {
|
||||
@@ -181,17 +179,17 @@ export function renderUsage(props: UsageProps) {
|
||||
|
||||
// Filter sessions by selected days
|
||||
const dayFilteredSessions =
|
||||
selectedDaySet.size > 0
|
||||
filters.selectedDays.length > 0
|
||||
? agentScopedSessions.filter((s) => {
|
||||
if (s.usage?.activityDates?.length) {
|
||||
return s.usage.activityDates.some((d) => selectedDaySet.has(d));
|
||||
return s.usage.activityDates.some((d) => filters.selectedDays.includes(d));
|
||||
}
|
||||
if (!s.updatedAt) {
|
||||
return false;
|
||||
}
|
||||
const d = new Date(s.updatedAt);
|
||||
const sessionDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return selectedDaySet.has(sessionDate);
|
||||
return filters.selectedDays.includes(sessionDate);
|
||||
})
|
||||
: agentScopedSessions;
|
||||
|
||||
@@ -263,8 +261,8 @@ export function renderUsage(props: UsageProps) {
|
||||
};
|
||||
|
||||
// Compute totals from daily data for selected days (more accurate than session totals)
|
||||
const computeDailyTotals = (days: ReadonlySet<string>): UsageTotals => {
|
||||
const matchingDays = data.costDaily.filter((d) => days.has(d.date));
|
||||
const computeDailyTotals = (days: string[]): UsageTotals => {
|
||||
const matchingDays = data.costDaily.filter((d) => days.includes(d.date));
|
||||
return matchingDays.reduce((acc, day) => addUsageTotals(acc, day), createEmptyUsageTotals());
|
||||
};
|
||||
|
||||
@@ -275,12 +273,14 @@ export function renderUsage(props: UsageProps) {
|
||||
|
||||
if (filters.selectedSessions.length > 0) {
|
||||
// Sessions selected - compute totals from selected sessions
|
||||
const selectedSessionEntries = filteredSessions.filter((s) => selectedSessionSet.has(s.key));
|
||||
const selectedSessionEntries = filteredSessions.filter((s) =>
|
||||
filters.selectedSessions.includes(s.key),
|
||||
);
|
||||
displayTotals = computeSessionTotals(selectedSessionEntries);
|
||||
displaySessionCount = selectedSessionEntries.length;
|
||||
} else if (filters.selectedDays.length > 0 && filters.selectedHours.length === 0) {
|
||||
// Days selected - use daily aggregates for accurate per-day totals
|
||||
displayTotals = computeDailyTotals(selectedDaySet);
|
||||
displayTotals = computeDailyTotals(filters.selectedDays);
|
||||
displaySessionCount = filteredSessions.length;
|
||||
} else if (filters.selectedHours.length > 0) {
|
||||
displayTotals = computeSessionTotals(filteredSessions);
|
||||
@@ -299,7 +299,7 @@ export function renderUsage(props: UsageProps) {
|
||||
|
||||
const aggregateSessions =
|
||||
filters.selectedSessions.length > 0
|
||||
? filteredSessions.filter((s) => selectedSessionSet.has(s.key))
|
||||
? filteredSessions.filter((s) => filters.selectedSessions.includes(s.key))
|
||||
: hasQuery || filters.selectedHours.length > 0
|
||||
? filteredSessions
|
||||
: filters.selectedDays.length > 0
|
||||
@@ -326,7 +326,9 @@ export function renderUsage(props: UsageProps) {
|
||||
const filteredDaily =
|
||||
filters.selectedSessions.length > 0
|
||||
? (() => {
|
||||
const selectedEntries = filteredSessions.filter((s) => selectedSessionSet.has(s.key));
|
||||
const selectedEntries = filteredSessions.filter((s) =>
|
||||
filters.selectedSessions.includes(s.key),
|
||||
);
|
||||
const allActivityDates = new Set<string>();
|
||||
for (const entry of selectedEntries) {
|
||||
for (const date of entry.usage?.activityDates ?? []) {
|
||||
|
||||
Reference in New Issue
Block a user