mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
1 Commits
codex/sess
...
docs/codex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f2d1a4d0 |
@@ -7,22 +7,6 @@ description: Review, triage, close, label, comment on, or land OpenClaw PRs/issu
|
||||
|
||||
Use this skill for maintainer-facing GitHub workflow, not for ordinary code changes.
|
||||
|
||||
## Start issue and PR triage with ghcrawl
|
||||
|
||||
- Anytime you inspect OpenClaw issues or PRs, check local `ghcrawl` data first for related threads, duplicate attempts, and already-landed fixes.
|
||||
- Use `ghcrawl` for candidate discovery and clustering; use `gh`, `gh api`, and the current checkout to verify live state before commenting, labeling, closing, or landing.
|
||||
- If `ghcrawl` is missing, stale, lacks the target thread, or has no embeddings for neighbor/search commands, fall back to the GitHub search workflow below.
|
||||
- Do not run expensive/update commands such as `ghcrawl refresh`, `ghcrawl embed`, or `ghcrawl cluster` unless the user asked to update the local store or the stale data is blocking the decision.
|
||||
|
||||
Common read-only path:
|
||||
|
||||
```bash
|
||||
ghcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
|
||||
ghcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
|
||||
ghcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --json
|
||||
ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
|
||||
```
|
||||
|
||||
## Apply close and triage labels correctly
|
||||
|
||||
- If an issue or PR matches an auto-close reason, apply the label and let `.github/workflows/auto-response.yml` handle the comment/close/lock flow.
|
||||
@@ -51,21 +35,6 @@ ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --b
|
||||
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
|
||||
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
|
||||
|
||||
## Close low-signal manual PRs carefully
|
||||
|
||||
- Do not close for red CI alone. Require a clear low-signal category plus stale or failed validation.
|
||||
- Good manual-close categories:
|
||||
- blank or mostly untouched PR template with no concrete OpenClaw problem/fix
|
||||
- random docs-only churn such as root README translations, generic wording tweaks, or community-plugin discoverability docs that should go through ClawHub
|
||||
- test-only coverage without a linked bug, owner request, or behavior change
|
||||
- refactor-only cleanup, variable renames, formatting, or generated/baseline churn without maintainer request
|
||||
- third-party channel/provider/tool/skill/plugin work that belongs on ClawHub instead of core
|
||||
- risky ops/infra drive-bys such as new external CI services, release workflows, host upgrade scripts, Docker base migrations, or apt retry/fix-missing tweaks without owner request and green validation
|
||||
- dirty branches where a narrow stated change includes unrelated docs/generated/runtime/extension files
|
||||
- repeated bot-review spam or copied bot output without author-owned fixes
|
||||
- Keep or escalate plausible focused bug fixes, green PRs, active maintainer discussions, assigned work, recent author follow-up, and unique reproduction details.
|
||||
- For third-party capabilities, prefer the `r: third-party-extension` auto-response label when it applies; it points contributors to publish on ClawHub.
|
||||
|
||||
## Handle GitHub text safely
|
||||
|
||||
- For issue comments and PR comments, use literal multiline strings or `-F - <<'EOF'` for real newlines. Never embed `\n`.
|
||||
@@ -75,9 +44,9 @@ ghcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --b
|
||||
|
||||
## Search broadly before deciding
|
||||
|
||||
- Prefer `ghcrawl` first. Then use targeted GitHub keyword search to verify gaps, live status, comments, and candidates not present in the local store.
|
||||
- Use `--repo openclaw/openclaw` with `--match title,body` first when using `gh search`.
|
||||
- Add `--match comments` when triaging follow-up discussion or closed-as-duplicate chains.
|
||||
- Prefer targeted keyword search before proposing new work or closing something as duplicate.
|
||||
- Use `--repo openclaw/openclaw` with `--match title,body` first.
|
||||
- Add `--match comments` when triaging follow-up discussion.
|
||||
- Do not stop at the first 500 results when the task requires a full search.
|
||||
|
||||
Examples:
|
||||
@@ -99,7 +68,6 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
|
||||
- Keep commit messages concise and action-oriented.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Use `.github/pull_request_template.md` for PR submissions and `.github/ISSUE_TEMPLATE/` for issues.
|
||||
- Do not commit PR-only artifacts such as screenshots under `.github/pr-assets`; attach them to the PR/comment or use an external artifact store instead.
|
||||
|
||||
## Extra safety
|
||||
|
||||
|
||||
@@ -49,79 +49,6 @@ pnpm openclaw qa suite \
|
||||
5. If the user wants to watch the live UI, find the current `openclaw-qa` listen port and report `http://127.0.0.1:<port>`.
|
||||
6. If a scenario fails, fix the product or harness root cause, then rerun the full lane.
|
||||
|
||||
## OTEL smoke
|
||||
|
||||
For local QA-lab OpenTelemetry validation, use:
|
||||
|
||||
```bash
|
||||
pnpm qa:otel:smoke
|
||||
```
|
||||
|
||||
This starts a local OTLP/HTTP trace receiver, runs the `otel-trace-smoke`
|
||||
scenario through qa-channel, decodes the emitted protobuf spans, and verifies
|
||||
the exported trace names and privacy contract. It does not require Opik,
|
||||
Langfuse, or external collector credentials.
|
||||
|
||||
## QA credentials and 1Password
|
||||
|
||||
- Use `op` only inside `tmux` for QA secret lookup in this repo.
|
||||
- Quick auth check inside tmux:
|
||||
|
||||
```bash
|
||||
op account list
|
||||
```
|
||||
|
||||
- Direct Telegram npm live test secrets currently live in 1Password item:
|
||||
- vault: `OpenClaw`
|
||||
- item: `Telegram E2E`
|
||||
- That item is the first place to look for:
|
||||
- `OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN`
|
||||
- `OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN`
|
||||
- `OPENCLAW_QA_PROVIDER_MODE`
|
||||
- `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC`
|
||||
- Convex QA secrets currently live in 1Password items:
|
||||
- vault: `OpenClaw`
|
||||
- item: `OPENCLAW_QA_CONVEX_SITE_URL`
|
||||
- item: `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`
|
||||
- item: `OPENCLAW_QA_CONVEX_SECRET_CI`
|
||||
- Additional related notes/login items seen during QA credential work:
|
||||
- vault: `Private`
|
||||
- items: `OPENCLAW QA`, `Convex`, `Telegram`
|
||||
- If a required value is missing from those notes:
|
||||
- do not guess
|
||||
- ask the maintainer/operator for the current value or the current 1Password item name
|
||||
- for Telegram direct runs, `OPENCLAW_QA_TELEGRAM_GROUP_ID` may be stored separately from `Telegram E2E`
|
||||
- for Convex runs, the leased Telegram credential should provide the Telegram group id and bot tokens together; do not require a separate `OPENCLAW_QA_TELEGRAM_GROUP_ID`
|
||||
- for Convex runs, prefer `OpenClaw/OPENCLAW_QA_CONVEX_SITE_URL`; if that is stale or unclear, ask for the active pool URL before running
|
||||
- Prefer direct Telegram envs for the npm Telegram Docker lane when available:
|
||||
|
||||
```bash
|
||||
OPENCLAW_QA_TELEGRAM_GROUP_ID="..." \
|
||||
OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN="..." \
|
||||
OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN="..." \
|
||||
OPENCLAW_QA_PROVIDER_MODE="mock-openai" \
|
||||
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC="openclaw@beta" \
|
||||
pnpm test:docker:npm-telegram-live
|
||||
```
|
||||
|
||||
- Prefer Convex mode when the goal is stable shared QA infra:
|
||||
- round-robin credential leasing
|
||||
- thinner wrapper for channel-specific setup
|
||||
- CLI/admin flows around the pooled credentials
|
||||
- Live npm Telegram Docker lane note:
|
||||
- `scripts/e2e/npm-telegram-live-runner.ts` reads `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE`
|
||||
- do not assume `OPENCLAW_QA_PROVIDER_MODE` is consumed by that wrapper
|
||||
- if a 1Password note only gives `OPENCLAW_QA_PROVIDER_MODE`, map it explicitly to `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE` before running the Docker lane
|
||||
- Verified live shape:
|
||||
- Convex mode can pass the real Docker lane without direct Telegram env vars
|
||||
- leased Telegram payload includes the group id coupled to the driver/SUT tokens
|
||||
- a real run of `pnpm test:docker:npm-telegram-live` passed with:
|
||||
- `OPENCLAW_QA_CREDENTIAL_SOURCE=convex`
|
||||
- `OPENCLAW_QA_CREDENTIAL_ROLE=maintainer`
|
||||
- `OPENCLAW_QA_CONVEX_SITE_URL`
|
||||
- `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER`
|
||||
- `OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE=mock-openai`
|
||||
|
||||
## Character evals
|
||||
|
||||
Use `qa character-eval` for style/persona/vibe checks across multiple live models.
|
||||
|
||||
@@ -25,36 +25,15 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
- Before release branching, commit any dirty files in coherent groups, push,
|
||||
pull/rebase, then run `/changelog` on `main` and commit/push/pull that
|
||||
changelog rewrite immediately before creating the release branch.
|
||||
- During release planning, inspect both `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` before branching and again
|
||||
before final publish. For every deprecated or removal-pending compatibility
|
||||
record whose `removeAfter` date is on or before the release date, either
|
||||
remove the compatibility path where safe and validate the affected tests, or
|
||||
write down why removal is blocked and get explicit maintainer approval before
|
||||
shipping the expired compatibility path.
|
||||
- When removing deprecated runtime/config compatibility, preserve any doctor
|
||||
migration, repair, or hint that is still needed by supported upgrade paths.
|
||||
Doctor-side compatibility should stay tracked in
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` until maintainers confirm
|
||||
the repair is no longer needed.
|
||||
- Revalidate compatibility replacement text during release planning. The
|
||||
recommended replacement can shift as plugin ownership, externalization, and
|
||||
config footprint move, so do not blindly copy stale replacement annotations
|
||||
into release notes.
|
||||
- Do not delete or rewrite beta tags after they leave the machine. If a
|
||||
published or pushed beta needs a fix, commit the fix on the release branch and
|
||||
increment to the next `-beta.N`.
|
||||
- For a beta release train, run the fast local preflight first, publish the
|
||||
beta to npm `beta`, then run the expensive published-package roster focused
|
||||
on install/update/Docker/Parallels/NPM Telegram. If anything fails, fix it on
|
||||
the release branch, commit/push/pull, increment beta number, and repeat. Run
|
||||
the full expensive roster at least once before stable/latest promotion; for
|
||||
later beta attempts, rerun only lanes whose evidence changed unless the fix
|
||||
touches broad release, install/update, plugin, Docker, Parallels, or live QA
|
||||
behavior. After each beta is published, scan current `main` once for critical
|
||||
fixes that landed after the release branch cut and backport only important
|
||||
low-risk fixes. Operators may authorize up to 4 autonomous beta attempts;
|
||||
after 4 failed beta attempts, stop and report.
|
||||
- For a beta release train, run the full pre-npm test roster before publishing
|
||||
each beta. After a beta is published, run the smaller published-install roster
|
||||
focused on install/update/Docker/Parallels. If anything fails, fix it on the
|
||||
release branch, commit/push/pull, increment beta number, and repeat. Operators
|
||||
may authorize up to 4 autonomous beta attempts; after 4 failed beta attempts,
|
||||
stop and report.
|
||||
- Use `/changelog` before version/tag preparation so the top changelog section
|
||||
is deduped and ordered by user impact.
|
||||
- Do not create beta-specific `CHANGELOG.md` headings. Beta releases use the
|
||||
@@ -96,11 +75,6 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
parallel, publish npm from the successful npm preflight, then start published
|
||||
npm install/update, Docker, and Parallels verification while mac artifacts
|
||||
continue.
|
||||
- After a beta is published, overlap remote/manual release rosters where useful,
|
||||
but avoid piling local Docker, Parallels, and QA-Lab work onto the same host
|
||||
when it would create system-load noise. Use selective reruns after failures or
|
||||
fixes, but keep proof that Docker, Parallels, and QA-Lab each passed at least
|
||||
once before stable/latest promotion.
|
||||
- Mac packaging may be built from a slight release-branch variation of the
|
||||
tagged commit when the delta is mac packaging, signing, workflow, or
|
||||
validation-only release machinery. If mac packaging needs release-branch-only
|
||||
@@ -123,23 +97,11 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
|
||||
section from commit history, not just from existing notes: scan commits since
|
||||
the last reachable release tag, add missed user-facing changes, dedupe
|
||||
overlapping entries, and sort each section from most to least interesting for
|
||||
users.
|
||||
- Changelog entries should be user-facing, not internal release-process notes.
|
||||
- GitHub release and prerelease bodies must use the full matching
|
||||
`CHANGELOG.md` version section, not highlights or an excerpt. When creating
|
||||
or editing a release, extract from `## YYYY.M.D` through the line before the
|
||||
next level-2 heading and use that complete block as the release notes.
|
||||
- When preparing release notes, scan `src/plugins/compat/registry.ts` and
|
||||
`src/commands/doctor/shared/deprecation-compat.ts` for compatibility records
|
||||
with `warningStarts` or `removeAfter` within 7 days after the release date.
|
||||
Add an `Upcoming deprecations` note to the release notes when any exist,
|
||||
including the compatibility code, target date, replacement, and a link to the
|
||||
record's `docsPath` or `/plugins/compatibility` when no more specific
|
||||
deprecation page exists.
|
||||
- When cutting a mac release with a beta GitHub prerelease:
|
||||
- tag `vYYYY.M.D-beta.N` from the release commit
|
||||
- create a prerelease titled `openclaw YYYY.M.D-beta.N`
|
||||
@@ -235,16 +197,10 @@ Before tagging or publishing, run:
|
||||
pnpm check:architecture
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm qa:otel:smoke
|
||||
pnpm release:check
|
||||
pnpm test:install:smoke
|
||||
```
|
||||
|
||||
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
|
||||
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
|
||||
`otel-trace-smoke`, and checks span names plus content/identifier redaction
|
||||
without external Opik or Langfuse credentials.
|
||||
|
||||
For a non-root smoke path:
|
||||
|
||||
```bash
|
||||
@@ -323,22 +279,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- `node --import tsx scripts/openclaw-npm-postpublish-verify.ts <beta-version>`
|
||||
- install/update smoke against the published beta channel
|
||||
- Docker install/update coverage that exercises the published beta package
|
||||
- published npm Telegram proof: dispatch Actions > `NPM Telegram Beta E2E`
|
||||
from `main` with `package_spec=openclaw@<beta-version>` and
|
||||
`provider_mode=mock-openai`, and require success. This workflow is
|
||||
maintainer-dispatched and intentionally has no `npm-release` approval gate;
|
||||
`qa-live-shared` only supplies the shared QA secrets. This is the default
|
||||
button path for installed-package onboarding, Telegram setup, and real
|
||||
Telegram E2E against the published npm package.
|
||||
Use the local `pnpm test:docker:npm-telegram-live` lane with the matching
|
||||
`OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC` and Convex CI env only as a fallback
|
||||
or debugging path.
|
||||
- Parallels published beta install/update coverage with both OpenAI and
|
||||
Anthropic provider keys available
|
||||
- Parallels install/update proof must keep plugin installs enabled unless the
|
||||
operator explicitly scopes a harness-only isolation check; a lane that
|
||||
disables bundled plugin installs is not valid plugin/dependency release
|
||||
evidence.
|
||||
- targeted QA reruns only for areas touched by fixes after the full pre-npm
|
||||
roster, unless the operator requests the full QA roster again. If the fix
|
||||
touches live channel QA, credential plumbing, Matrix, Telegram, or the QA
|
||||
@@ -387,17 +329,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
`openclaw/releases-private/.github/workflows/openclaw-npm-dist-tags.yml`
|
||||
workflow because `npm dist-tag` management needs `NPM_TOKEN`, while the
|
||||
public npm release workflow stays OIDC-only.
|
||||
- Prefer fixing the private workflow token path over any local 1Password
|
||||
fallback. The desired setup is a granular npm token stored as the private
|
||||
repo's `NPM_TOKEN` secret, scoped to the `openclaw` package with read/write
|
||||
and 2FA bypass for automation.
|
||||
- If the private dist-tag workflow cannot promote because `NPM_TOKEN` is absent
|
||||
or stale, use the local tmux + 1Password fallback:
|
||||
- Start or reuse a tmux session so interactive `npm login` and OTP prompts
|
||||
are observable and recoverable.
|
||||
- Hard rule: never run `op` directly in the main agent shell during release
|
||||
work. Any 1Password CLI use must happen inside that tmux session so prompts
|
||||
and alerts are contained and observable.
|
||||
- Use the 1Password item `op://Private/Npmjs` for npm credentials and OTP.
|
||||
Do not print passwords, tokens, or OTPs to the transcript; send them through
|
||||
tmux buffers, env vars scoped to the tmux command, or `expect` with
|
||||
@@ -526,10 +461,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
6. Create `release/YYYY.M.D` from that post-changelog `main` commit.
|
||||
7. Make every repo version location match the beta tag before creating it.
|
||||
8. Commit release preparation changes on the release branch and push the branch.
|
||||
9. Run the fast local beta preflight from the release branch before any npm
|
||||
preflight or publish. Keep expensive Docker, Parallels, and published-package
|
||||
install/update lanes for after the beta is live unless the operator asks to
|
||||
run them before beta publication.
|
||||
9. Run the local build, Docker, and Parallels parts of the full pre-npm beta
|
||||
test roster from the release branch before any npm preflight or publish.
|
||||
10. For beta releases, skip mac app build/sign/notarize unless beta scope or a
|
||||
release blocker specifically requires it. For stable releases, include the
|
||||
mac app, signing, notarization, and appcast path.
|
||||
@@ -566,20 +499,12 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
21. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
22. Run postpublish verification:
|
||||
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
|
||||
23. Run the post-published beta verification roster. First scan current `main`
|
||||
for critical fixes that landed after the release branch cut; backport only
|
||||
important low-risk fixes before starting expensive lanes, or increment to
|
||||
the next beta if the fix must change the already-published package. If any
|
||||
lane fails after the beta tag/package is pushed or published, fix,
|
||||
commit/push/pull, increment to the next beta tag, and rerun the affected
|
||||
beta evidence. Once the beta is live, start remote/manual rosters where they
|
||||
can overlap safely, but keep local Docker and Parallels load controlled.
|
||||
Ensure the full expensive roster has passed at least once before
|
||||
stable/latest promotion. The roster includes the manual Actions >
|
||||
`NPM Telegram Beta E2E` workflow against the exact published beta package.
|
||||
If a pre-npm lane fails before any tag/package leaves the machine, fix and
|
||||
rerun the same intended beta attempt. Repeat up to the operator's
|
||||
authorized beta-attempt limit, normally 4.
|
||||
23. Run the post-published beta verification roster. If any lane fails after
|
||||
the beta tag/package is pushed or published, fix, commit/push/pull,
|
||||
increment to the next beta tag, and restart at the full pre-npm beta test
|
||||
roster for the new beta. If a pre-npm lane fails before any tag/package
|
||||
leaves the machine, fix and rerun the same intended beta attempt. Repeat up
|
||||
to the operator's authorized beta-attempt limit, normally 4.
|
||||
24. Announce the beta/stable release on Discord best-effort using Peter's bot
|
||||
token from `.profile`.
|
||||
25. If the operator requested beta only, stop after beta verification and the
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
---
|
||||
name: openclaw-testing
|
||||
description: Choose, run, rerun, or debug OpenClaw tests, CI checks, Docker E2E lanes, release validation, and the cheapest safe verification path.
|
||||
---
|
||||
|
||||
# OpenClaw Testing
|
||||
|
||||
Use this skill when deciding what to test, debugging failures, rerunning CI,
|
||||
or validating a change without wasting hours.
|
||||
|
||||
## Read First
|
||||
|
||||
- `docs/reference/test.md` for local test commands.
|
||||
- `docs/ci.md` for CI scope, release checks, Docker chunks, and runner behavior.
|
||||
- Scoped `AGENTS.md` files before editing code under a subtree.
|
||||
|
||||
## Default Rule
|
||||
|
||||
Prove the touched surface first. Do not reflexively run the whole suite.
|
||||
|
||||
1. Inspect the diff and classify the touched surface:
|
||||
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
|
||||
- tests only: `pnpm test:changed`
|
||||
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
|
||||
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
|
||||
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
|
||||
2. Reproduce narrowly before fixing.
|
||||
3. Fix root cause.
|
||||
4. Rerun the same narrow proof.
|
||||
5. Broaden only when the touched contract demands it.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not kill unrelated processes or tests. If something is running elsewhere, treat it as owned by the user or another agent.
|
||||
- Do not run expensive local Docker, full release checks, full `pnpm test`, or full `pnpm check` unless the user asks or the change genuinely requires it.
|
||||
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
|
||||
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
|
||||
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
|
||||
|
||||
## Local Test Shortcuts
|
||||
|
||||
```bash
|
||||
pnpm changed:lanes --json
|
||||
pnpm check:changed # changed typecheck/lint/guards; no Vitest
|
||||
pnpm test:changed # cheap smart changed Vitest targets
|
||||
OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed
|
||||
pnpm test <path-or-filter> -- --reporter=verbose
|
||||
OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
|
||||
```
|
||||
|
||||
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
|
||||
`pnpm test` wrapper so project routing, workers, and setup stay correct.
|
||||
|
||||
## Command Semantics
|
||||
|
||||
- `pnpm check` and `pnpm check:changed` do not run Vitest tests. They are for
|
||||
typecheck, lint, and guard proof.
|
||||
- `pnpm test` and `pnpm test:changed` run Vitest tests.
|
||||
- `pnpm test:changed` is intentionally cheap by default: direct test edits,
|
||||
sibling tests, explicit source mappings, and import-graph dependents.
|
||||
- `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` is the explicit broad
|
||||
fallback for harness/config/package edits that genuinely need it.
|
||||
- Do not run extension sweeps just because core changed. If a core edit is for a
|
||||
specific plugin bug, run that plugin's tests explicitly. If a public SDK or
|
||||
contract change needs consumer proof, choose the smallest representative
|
||||
plugin/contract tests first, then broaden only when the risk justifies it.
|
||||
- The test wrapper prints a short `[test] passed|failed|skipped ... in ...`
|
||||
line. Vitest's own duration is still the per-shard detail.
|
||||
|
||||
## Routing Model
|
||||
|
||||
- `pnpm changed:lanes --json` answers "which check lanes does this diff touch?"
|
||||
It is used by `pnpm check:changed` for typecheck/lint/guard selection.
|
||||
- `pnpm test:changed` answers "which Vitest targets are worth running now?" It
|
||||
uses the same changed path list, but applies a cheaper test-target resolver.
|
||||
- Direct test edits run themselves. Source edits prefer explicit mappings,
|
||||
sibling `*.test.ts`, then import-graph dependents. Shared harness/config/root
|
||||
edits are skipped by default unless they have precise mapped tests.
|
||||
- Public SDK or contract edits do not automatically run every plugin test.
|
||||
`check:changed` proves extension type contracts; the agent chooses the
|
||||
smallest plugin/contract Vitest proof that matches the actual risk.
|
||||
- Use `OPENCLAW_TEST_CHANGED_BROAD=1 pnpm test:changed` only when a harness,
|
||||
config, package, or unknown-root edit really needs the broad Vitest fallback.
|
||||
|
||||
## CI Debugging
|
||||
|
||||
Start with current run state, not logs for everything:
|
||||
|
||||
```bash
|
||||
gh run list --branch main --limit 10
|
||||
gh run view <run-id> --json status,conclusion,headSha,url,jobs
|
||||
gh run view <run-id> --job <job-id> --log
|
||||
```
|
||||
|
||||
- Check exact SHA. Ignore newer unrelated `main` unless asked.
|
||||
- For cancelled same-branch runs, confirm whether a newer run superseded it.
|
||||
- Fetch full logs only for failed or relevant jobs.
|
||||
|
||||
## Docker
|
||||
|
||||
Docker is expensive. First inspect the scheduler without running Docker:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DOCKER_ALL_DRY_RUN=1 pnpm test:docker:all
|
||||
OPENCLAW_DOCKER_ALL_DRY_RUN=1 OPENCLAW_DOCKER_ALL_LANES=install-e2e pnpm test:docker:all
|
||||
OPENCLAW_DOCKER_ALL_LANES=install-e2e node scripts/test-docker-all.mjs --plan-json
|
||||
```
|
||||
|
||||
Run one failed lane locally only when explicitly asked or when GitHub is not
|
||||
usable:
|
||||
|
||||
```bash
|
||||
OPENCLAW_DOCKER_ALL_LANES=<lane> \
|
||||
OPENCLAW_DOCKER_ALL_BUILD=0 \
|
||||
OPENCLAW_DOCKER_ALL_PREFLIGHT=0 \
|
||||
OPENCLAW_SKIP_DOCKER_BUILD=1 \
|
||||
OPENCLAW_DOCKER_E2E_BARE_IMAGE='<prepared-bare-image>' \
|
||||
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE='<prepared-functional-image>' \
|
||||
pnpm test:docker:all
|
||||
```
|
||||
|
||||
For release validation, prefer the reusable GitHub workflow input:
|
||||
|
||||
```yaml
|
||||
docker_lanes: install-e2e
|
||||
```
|
||||
|
||||
Multiple lanes are allowed:
|
||||
|
||||
```yaml
|
||||
docker_lanes: install-e2e bundled-channel-update-acpx
|
||||
```
|
||||
|
||||
That skips the three chunk matrix and runs one targeted Docker job against the
|
||||
prepared GHCR images and a fresh OpenClaw npm tarball for the selected ref.
|
||||
Reruns usually need that new tarball because the fix being tested changed the
|
||||
package contents even if the SHA-tagged GHCR Docker image can be reused.
|
||||
Live-only targeted reruns skip the E2E images and build only the live-test
|
||||
image. Release-path normal mode remains max three Docker chunk jobs:
|
||||
|
||||
- `core`
|
||||
- `package-update`
|
||||
- `plugins-integrations`
|
||||
|
||||
Docker E2E images never copy repo sources as the app under test: the bare image
|
||||
is a Node/Git runner, and the functional image installs the same prebuilt npm
|
||||
tarball that bare lanes mount. `scripts/package-openclaw-for-docker.mjs` is the
|
||||
single packer for local scripts and CI and validates the tarball inventory
|
||||
before Docker consumes it. `scripts/test-docker-all.mjs --plan-json` is the
|
||||
scheduler-owned CI plan for image kind, package, live image, lane, and
|
||||
credential needs. Docker lane definitions live in the single scenario catalog
|
||||
`scripts/lib/docker-e2e-scenarios.mjs`; planner logic lives in
|
||||
`scripts/lib/docker-e2e-plan.mjs`. `scripts/docker-e2e.mjs` converts plan and
|
||||
summary JSON into GitHub outputs and step summaries. Every scheduler run writes
|
||||
`.artifacts/docker-tests/**/summary.json` plus `failures.json`. Read those
|
||||
before rerunning. Lane entries include `command`, `rerunCommand`, status,
|
||||
timing, timeout state, image kind, and log file path. The summary also includes
|
||||
top-level phase timings for preflight, image build, package prep, lane pools,
|
||||
and cleanup. Use `pnpm test:docker:timings <summary.json>` to rank slow lanes
|
||||
and phases before deciding whether a broader rerun is justified.
|
||||
|
||||
## Cheap Docker Reruns
|
||||
|
||||
First derive the smallest rerun command from artifacts:
|
||||
|
||||
```bash
|
||||
pnpm test:docker:rerun <github-run-id>
|
||||
pnpm test:docker:rerun .artifacts/docker-tests/<run>/failures.json
|
||||
```
|
||||
|
||||
The script downloads Docker E2E artifacts for a GitHub run, reads
|
||||
`summary.json`/`failures.json`, and prints a combined targeted workflow command
|
||||
plus per-lane commands. Prefer the combined targeted command when several lanes
|
||||
failed for the same patch:
|
||||
|
||||
```bash
|
||||
gh workflow run openclaw-live-and-e2e-checks-reusable.yml \
|
||||
-f ref=<sha> \
|
||||
-f include_repo_e2e=false \
|
||||
-f include_release_path_suites=false \
|
||||
-f include_openwebui=false \
|
||||
-f docker_lanes='install-e2e bundled-channel-update-acpx' \
|
||||
-f include_live_suites=false \
|
||||
-f live_models_only=false
|
||||
```
|
||||
|
||||
That path still runs the prepare job, so it creates a new tarball for `<sha>`.
|
||||
If the SHA-tagged GHCR bare/functional image already exists, CI skips rebuilding
|
||||
that image and only uploads the fresh package artifact before the targeted lane
|
||||
job. Do not rerun the full three-chunk release path unless the failed lane list
|
||||
or touched surface really requires it.
|
||||
|
||||
## Docker Expected Timings
|
||||
|
||||
Treat these as ballpark. Blacksmith queue time, GHCR pull speed, provider
|
||||
latency, npm cache state, and Docker daemon health can dominate.
|
||||
|
||||
Current local timing artifact (`.artifacts/docker-tests/lane-timings.json`) has
|
||||
these rough bands:
|
||||
|
||||
- Tiny lanes, seconds to under 1 minute:
|
||||
`agents-delete-shared-workspace` ~3s, `plugin-update` ~7s,
|
||||
`config-reload` ~14s, `pi-bundle-mcp-tools` ~15s, `onboard` ~18s,
|
||||
`session-runtime-context` ~20s, `gateway-network` ~34s, `qr` ~44s.
|
||||
- Medium deterministic lanes, ~1-5 minutes:
|
||||
`npm-onboard-channel-agent` ~96s, `openai-image-auth` ~99s,
|
||||
bundled channel/update lanes usually ~90-300s, `openwebui` ~225s,
|
||||
`mcp-channels` ~274s.
|
||||
- Heavy deterministic lanes, ~6-10 minutes:
|
||||
`bundled-channel-root-owned` ~429s,
|
||||
`bundled-channel-setup-entry` ~420s,
|
||||
`bundled-channel-load-failure` ~383s,
|
||||
`cron-mcp-cleanup` ~567s.
|
||||
- Live provider lanes, often ~15-20 minutes:
|
||||
`live-gateway` ~958s, `live-models` ~1054s.
|
||||
- Installer/release lanes:
|
||||
`install-e2e` and package-update paths can vary widely with npm, provider,
|
||||
and package registry behavior. Budget tens of minutes; prefer GitHub targeted
|
||||
reruns over local repeats.
|
||||
|
||||
Default fallback lane timeout is 120 minutes. A timeout usually means debug the
|
||||
lane log/artifacts first, not “run the whole thing again.”
|
||||
|
||||
## Failure Workflow
|
||||
|
||||
1. Identify exact failing job, SHA, lane, and artifact path.
|
||||
2. Read `failures.json`, `summary.json`, and the failed lane log tail.
|
||||
3. Use `pnpm test:docker:rerun <run-id|failures.json>` to generate targeted
|
||||
GitHub rerun commands.
|
||||
4. If the lane has `rerunCommand`, use that only as a local starting point.
|
||||
5. For Docker release failures, dispatch targeted `docker_lanes=<failed-lane>`
|
||||
on GitHub before considering local Docker.
|
||||
6. Patch narrowly, then rerun the failed file/lane only.
|
||||
7. Broaden to `pnpm check:changed` or CI only after the isolated proof passes.
|
||||
|
||||
## When To Escalate
|
||||
|
||||
- Public SDK/plugin contract changes: run changed gate plus relevant extension
|
||||
validation.
|
||||
- Build output, lazy imports, package boundaries, or published surfaces:
|
||||
include `pnpm build`.
|
||||
- Workflow edits: run `pnpm check:workflows`.
|
||||
- Release branch or tag validation: use release docs and GitHub workflows; avoid
|
||||
local Docker unless Peter explicitly asks.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "OpenClaw Testing"
|
||||
short_description: "Choose cheap, targeted OpenClaw validation"
|
||||
default_prompt: "Use $openclaw-testing to choose the cheapest safe test or CI verification path, inspect failures, and rerun only the relevant OpenClaw lane."
|
||||
@@ -8,14 +8,6 @@
|
||||
|
||||
.bun-cache
|
||||
.bun
|
||||
.artifacts
|
||||
**/.artifacts
|
||||
.local
|
||||
**/.local
|
||||
.pi
|
||||
**/.pi
|
||||
__openclaw_vitest__
|
||||
**/__openclaw_vitest__
|
||||
.tmp
|
||||
**/.tmp
|
||||
.DS_Store
|
||||
@@ -46,9 +38,6 @@ docs/.generated
|
||||
*.log
|
||||
tmp
|
||||
**/tmp
|
||||
dist-runtime
|
||||
**/dist-runtime
|
||||
openclaw-path-alias-*
|
||||
|
||||
# build artifacts
|
||||
dist
|
||||
|
||||
@@ -82,5 +82,4 @@ OPENCLAW_GATEWAY_TOKEN=
|
||||
|
||||
# ELEVENLABS_API_KEY=...
|
||||
# XI_API_KEY=... # alias for ElevenLabs
|
||||
# INWORLD_API_KEY=...
|
||||
# DEEPGRAM_API_KEY=...
|
||||
|
||||
145
.github/actions/docker-e2e-plan/action.yml
vendored
145
.github/actions/docker-e2e-plan/action.yml
vendored
@@ -1,145 +0,0 @@
|
||||
name: Docker E2E plan and hydrate
|
||||
description: >
|
||||
Create a Docker E2E lane plan, expose GitHub outputs, and optionally hydrate
|
||||
the prebuilt package artifact plus shared Docker images needed by the plan.
|
||||
inputs:
|
||||
mode:
|
||||
description: prepare, chunk, or targeted.
|
||||
required: true
|
||||
chunk:
|
||||
description: Release-path chunk for mode=chunk.
|
||||
required: false
|
||||
default: ""
|
||||
lanes:
|
||||
description: Comma/space separated lane names for targeted or prepare mode.
|
||||
required: false
|
||||
default: ""
|
||||
include-openwebui:
|
||||
description: Whether Open WebUI is included when planning release/prepare coverage.
|
||||
required: false
|
||||
default: "true"
|
||||
include-release-path-suites:
|
||||
description: Whether prepare mode should plan all release-path suites.
|
||||
required: false
|
||||
default: "false"
|
||||
hydrate-artifacts:
|
||||
description: Whether to download/pull artifacts required by the plan.
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
credentials:
|
||||
description: Comma-separated credential groups required by selected lanes.
|
||||
value: ${{ steps.plan.outputs.credentials }}
|
||||
needs_bare_image:
|
||||
description: "1 when selected lanes require the bare Docker E2E image."
|
||||
value: ${{ steps.plan.outputs.needs_bare_image }}
|
||||
needs_e2e_image:
|
||||
description: "1 when selected lanes require any Docker E2E image."
|
||||
value: ${{ steps.plan.outputs.needs_e2e_image }}
|
||||
needs_functional_image:
|
||||
description: "1 when selected lanes require the functional Docker E2E image."
|
||||
value: ${{ steps.plan.outputs.needs_functional_image }}
|
||||
needs_live_image:
|
||||
description: "1 when selected lanes require building the live Docker image."
|
||||
value: ${{ steps.plan.outputs.needs_live_image }}
|
||||
needs_package:
|
||||
description: "1 when selected lanes require the OpenClaw package tarball."
|
||||
value: ${{ steps.plan.outputs.needs_package }}
|
||||
plan_json:
|
||||
description: Path to the generated plan JSON.
|
||||
value: ${{ steps.plan.outputs.plan_json }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Plan Docker E2E lanes
|
||||
id: plan
|
||||
shell: bash
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
CHUNK: ${{ inputs.chunk }}
|
||||
LANES: ${{ inputs.lanes }}
|
||||
INCLUDE_OPENWEBUI: ${{ inputs.include-openwebui }}
|
||||
INCLUDE_RELEASE_PATH_SUITES: ${{ inputs.include-release-path-suites }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .artifacts/docker-tests
|
||||
|
||||
case "$MODE" in
|
||||
prepare)
|
||||
plan_path=".artifacts/docker-tests/plan.json"
|
||||
if [[ "$INCLUDE_RELEASE_PATH_SUITES" == "true" ]]; then
|
||||
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
|
||||
export OPENCLAW_DOCKER_ALL_PLAN_RELEASE_ALL=1
|
||||
elif [[ -n "$LANES" ]]; then
|
||||
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
|
||||
elif [[ "$INCLUDE_OPENWEBUI" == "true" ]]; then
|
||||
export OPENCLAW_DOCKER_ALL_LANES=openwebui
|
||||
fi
|
||||
;;
|
||||
chunk)
|
||||
if [[ -z "$CHUNK" ]]; then
|
||||
echo "chunk input is required for Docker E2E chunk planning." >&2
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
|
||||
export OPENCLAW_DOCKER_ALL_CHUNK="$CHUNK"
|
||||
plan_path=".artifacts/docker-tests/release-${CHUNK}-plan.json"
|
||||
;;
|
||||
targeted)
|
||||
if [[ -z "$LANES" ]]; then
|
||||
echo "lanes input is required for Docker E2E targeted planning." >&2
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_DOCKER_ALL_LANES="$LANES"
|
||||
plan_path=".artifacts/docker-tests/targeted-plan.json"
|
||||
;;
|
||||
*)
|
||||
echo "mode must be prepare, chunk, or targeted. Got: $MODE" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="$INCLUDE_OPENWEBUI"
|
||||
node scripts/test-docker-all.mjs --plan-json > "$plan_path"
|
||||
node scripts/docker-e2e.mjs github-outputs "$plan_path" >> "$GITHUB_OUTPUT"
|
||||
echo "plan_json=$plan_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download OpenClaw Docker E2E package
|
||||
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_package == '1'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: docker-e2e-package
|
||||
path: .artifacts/docker-e2e-package
|
||||
|
||||
- name: Pull shared bare Docker E2E image
|
||||
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_bare_image == '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${OPENCLAW_DOCKER_E2E_BARE_IMAGE}"
|
||||
|
||||
- name: Pull shared functional Docker E2E image
|
||||
if: inputs.hydrate-artifacts == 'true' && steps.plan.outputs.needs_functional_image == '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull "${OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE}"
|
||||
|
||||
- name: Validate Docker E2E credentials
|
||||
if: inputs.hydrate-artifacts == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CREDENTIALS: ${{ steps.plan.outputs.credentials }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
credentials=",$CREDENTIALS,"
|
||||
if [[ "$credentials" == *",openai,"* ]]; then
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || {
|
||||
echo "OPENAI_API_KEY is required for selected Docker E2E lanes." >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
if [[ "$credentials" == *",anthropic,"* && -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for selected Docker E2E lanes." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -12,9 +12,6 @@ paths-ignore:
|
||||
- docs
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*-runtime.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
|
||||
39
.github/labeler.yml
vendored
39
.github/labeler.yml
vendored
@@ -3,12 +3,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/bluebubbles/**"
|
||||
- "docs/channels/bluebubbles.md"
|
||||
"plugin: azure-speech":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/azure-speech/**"
|
||||
- "docs/providers/azure-speech.md"
|
||||
- "docs/tools/tts.md"
|
||||
"channel: discord":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -35,11 +29,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-meet/**"
|
||||
- "docs/plugins/google-meet.md"
|
||||
"plugin: bonjour":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/bonjour/**"
|
||||
- "docs/gateway/bonjour.md"
|
||||
"channel: imessage":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -233,10 +222,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-otel/**"
|
||||
"extensions: diagnostics-prometheus":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-prometheus/**"
|
||||
"extensions: llm-task":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -317,11 +302,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/huggingface/**"
|
||||
"extensions: inworld":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/inworld/**"
|
||||
- "docs/providers/inworld.md"
|
||||
"extensions: kilocode":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -330,11 +310,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/lmstudio/**"
|
||||
"extensions: litellm":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/litellm/**"
|
||||
- "docs/providers/litellm.md"
|
||||
"extensions: openai":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -371,11 +346,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qianfan/**"
|
||||
"extensions: senseaudio":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/senseaudio/**"
|
||||
- "docs/providers/senseaudio.md"
|
||||
"extensions: synthetic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -392,11 +362,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/together/**"
|
||||
"extensions: tts-local-cli":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tts-local-cli/**"
|
||||
- "docs/tools/tts.md"
|
||||
"extensions: venice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -417,7 +382,3 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
"extensions: gradium":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/gradium/**"
|
||||
|
||||
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
BIN
.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
507
.github/workflows/auto-response.yml
vendored
507
.github/workflows/auto-response.yml
vendored
@@ -5,8 +5,8 @@ on:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; trusted base checkout only, no untrusted PR code execution
|
||||
types: [opened, edited, synchronize, reopened, labeled]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned label automation; no untrusted checkout or code execution
|
||||
types: [labeled]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -20,15 +20,10 @@ permissions: {}
|
||||
jobs:
|
||||
auto-response:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
@@ -41,15 +36,499 @@ jobs:
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Run Barnacle auto-response
|
||||
- name: Handle labeled items
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
script: |
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const moduleUrl = pathToFileURL(
|
||||
`${process.env.GITHUB_WORKSPACE}/scripts/github/barnacle-auto-response.mjs`,
|
||||
);
|
||||
const { runBarnacleAutoResponse } = await import(moduleUrl.href);
|
||||
// Labels prefixed with "r:" are auto-response triggers.
|
||||
const activePrLimit = 10;
|
||||
const rules = [
|
||||
{
|
||||
label: "r: skill",
|
||||
close: true,
|
||||
message:
|
||||
"Thanks for the contribution! New skills should be published to [Clawhub](https://clawhub.ai) for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
|
||||
},
|
||||
{
|
||||
label: "r: support",
|
||||
close: true,
|
||||
message:
|
||||
"Please use [our support server](https://discord.gg/clawd) and ask in #help or #users-helping-users to resolve this, or follow the stuck FAQ at https://docs.openclaw.ai/help/faq#im-stuck-whats-the-fastest-way-to-get-unstuck.",
|
||||
},
|
||||
{
|
||||
label: "r: no-ci-pr",
|
||||
close: true,
|
||||
message:
|
||||
"Please don't make PRs for test failures on main.\n\n" +
|
||||
"The team is aware of those and will handle them directly on the codebase, not only fixing the tests but also investigating what the root cause is. Having to sift through test-fix-PRs (including some that have been out of date for weeks...) on top of that doesn't help. There are already way too many PRs for humans to manage; please don't make the flood worse.\n\n" +
|
||||
"Thank you.",
|
||||
},
|
||||
{
|
||||
label: "r: too-many-prs",
|
||||
close: true,
|
||||
message:
|
||||
`Closing this PR because the author has more than ${activePrLimit} active PRs in this repo. ` +
|
||||
"Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.",
|
||||
},
|
||||
{
|
||||
label: "r: testflight",
|
||||
close: true,
|
||||
commentTriggers: ["testflight"],
|
||||
message: "Not available, build from source.",
|
||||
},
|
||||
{
|
||||
label: "r: third-party-extension",
|
||||
close: true,
|
||||
message:
|
||||
"Please make this as a third-party plugin that you maintain yourself in your own repo. Docs: https://docs.openclaw.ai/plugin. Feel free to open a PR after to add it to our community plugins page: https://docs.openclaw.ai/plugins/community",
|
||||
},
|
||||
{
|
||||
label: "r: moltbook",
|
||||
close: true,
|
||||
lock: true,
|
||||
lockReason: "off-topic",
|
||||
commentTriggers: ["moltbook"],
|
||||
message:
|
||||
"OpenClaw is not affiliated with Moltbook, and issues related to Moltbook should not be submitted here.",
|
||||
},
|
||||
];
|
||||
|
||||
await runBarnacleAutoResponse({ github, context, core });
|
||||
const maintainerTeam = "maintainer";
|
||||
const pingWarningMessage =
|
||||
"Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd";
|
||||
const mentionRegex = /@([A-Za-z0-9-]+)/g;
|
||||
const maintainerCache = new Map();
|
||||
const normalizeLogin = (login) => login.toLowerCase();
|
||||
const bugSubtypeLabelSpecs = {
|
||||
regression: {
|
||||
color: "D93F0B",
|
||||
description: "Behavior that previously worked and now fails",
|
||||
},
|
||||
"bug:crash": {
|
||||
color: "B60205",
|
||||
description: "Process/app exits unexpectedly or hangs",
|
||||
},
|
||||
"bug:behavior": {
|
||||
color: "D73A4A",
|
||||
description: "Incorrect behavior without a crash",
|
||||
},
|
||||
};
|
||||
const bugTypeToLabel = {
|
||||
"Regression (worked before, now fails)": "regression",
|
||||
"Crash (process/app exits or hangs)": "bug:crash",
|
||||
"Behavior bug (incorrect output/state without crash)": "bug:behavior",
|
||||
};
|
||||
const bugSubtypeLabels = Object.keys(bugSubtypeLabelSpecs);
|
||||
|
||||
const extractIssueFormValue = (body, field) => {
|
||||
if (!body) {
|
||||
return "";
|
||||
}
|
||||
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const regex = new RegExp(
|
||||
`(?:^|\\n)###\\s+${escapedField}\\s*\\n([\\s\\S]*?)(?=\\n###\\s+|$)`,
|
||||
"i",
|
||||
);
|
||||
const match = body.match(regex);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
for (const line of match[1].split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const ensureLabelExists = async (name, color, description) => {
|
||||
try {
|
||||
await github.rest.issues.getLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
await github.rest.issues.createLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
name,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const syncBugSubtypeLabel = async (issue, labelSet) => {
|
||||
if (!labelSet.has("bug")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBugType = extractIssueFormValue(issue.body ?? "", "Bug type");
|
||||
const targetLabel = bugTypeToLabel[selectedBugType];
|
||||
if (!targetLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSpec = bugSubtypeLabelSpecs[targetLabel];
|
||||
await ensureLabelExists(targetLabel, targetSpec.color, targetSpec.description);
|
||||
|
||||
for (const subtypeLabel of bugSubtypeLabels) {
|
||||
if (subtypeLabel === targetLabel) {
|
||||
continue;
|
||||
}
|
||||
if (!labelSet.has(subtypeLabel)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
name: subtypeLabel,
|
||||
});
|
||||
labelSet.delete(subtypeLabel);
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!labelSet.has(targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: [targetLabel],
|
||||
});
|
||||
labelSet.add(targetLabel);
|
||||
}
|
||||
};
|
||||
|
||||
const isMaintainer = async (login) => {
|
||||
if (!login) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeLogin(login);
|
||||
if (maintainerCache.has(normalized)) {
|
||||
return maintainerCache.get(normalized);
|
||||
}
|
||||
let isMember = false;
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: context.repo.owner,
|
||||
team_slug: maintainerTeam,
|
||||
username: normalized,
|
||||
});
|
||||
isMember = membership?.data?.state === "active";
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
maintainerCache.set(normalized, isMember);
|
||||
return isMember;
|
||||
};
|
||||
|
||||
const countMaintainerMentions = async (body, authorLogin) => {
|
||||
if (!body) {
|
||||
return 0;
|
||||
}
|
||||
const normalizedAuthor = authorLogin ? normalizeLogin(authorLogin) : "";
|
||||
if (normalizedAuthor && (await isMaintainer(normalizedAuthor))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const haystack = body.toLowerCase();
|
||||
const teamMention = `@${context.repo.owner.toLowerCase()}/${maintainerTeam}`;
|
||||
if (haystack.includes(teamMention)) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
const mentions = new Set();
|
||||
for (const match of body.matchAll(mentionRegex)) {
|
||||
mentions.add(normalizeLogin(match[1]));
|
||||
}
|
||||
if (normalizedAuthor) {
|
||||
mentions.delete(normalizedAuthor);
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const login of mentions) {
|
||||
if (await isMaintainer(login)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const triggerLabel = "trigger-response";
|
||||
const activePrLimitLabel = "r: too-many-prs";
|
||||
const activePrLimitOverrideLabel = "r: too-many-prs-override";
|
||||
const target = context.payload.issue ?? context.payload.pull_request;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelSet = new Set(
|
||||
(target.labels ?? [])
|
||||
.map((label) => (typeof label === "string" ? label : label?.name))
|
||||
.filter((name) => typeof name === "string"),
|
||||
);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const comment = context.payload.comment;
|
||||
if (comment) {
|
||||
const authorLogin = comment.user?.login ?? "";
|
||||
if (comment.user?.type === "Bot" || authorLogin.endsWith("[bot]")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentBody = comment.body ?? "";
|
||||
const responses = [];
|
||||
const mentionCount = await countMaintainerMentions(commentBody, authorLogin);
|
||||
if (mentionCount >= 3) {
|
||||
responses.push(pingWarningMessage);
|
||||
}
|
||||
|
||||
const commentHaystack = commentBody.toLowerCase();
|
||||
const commentRule = rules.find((item) =>
|
||||
(item.commentTriggers ?? []).some((trigger) =>
|
||||
commentHaystack.includes(trigger),
|
||||
),
|
||||
);
|
||||
if (commentRule) {
|
||||
responses.push(commentRule.message);
|
||||
}
|
||||
|
||||
if (responses.length > 0) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
body: responses.join("\n\n"),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const action = context.payload.action;
|
||||
if (action === "opened" || action === "edited") {
|
||||
const issueText = `${issue.title ?? ""}\n${issue.body ?? ""}`.trim();
|
||||
const authorLogin = issue.user?.login ?? "";
|
||||
const mentionCount = await countMaintainerMentions(
|
||||
issueText,
|
||||
authorLogin,
|
||||
);
|
||||
if (mentionCount >= 3) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: pingWarningMessage,
|
||||
});
|
||||
}
|
||||
|
||||
await syncBugSubtypeLabel(issue, labelSet);
|
||||
}
|
||||
}
|
||||
|
||||
const hasTriggerLabel = labelSet.has(triggerLabel);
|
||||
if (hasTriggerLabel) {
|
||||
labelSet.delete(triggerLabel);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: target.number,
|
||||
name: triggerLabel,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLabelEvent = context.payload.action === "labeled";
|
||||
if (!hasTriggerLabel && !isLabelEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue) {
|
||||
const title = issue.title ?? "";
|
||||
const body = issue.body ?? "";
|
||||
const haystack = `${title}\n${body}`.toLowerCase();
|
||||
const hasMoltbookLabel = labelSet.has("r: moltbook");
|
||||
const hasTestflightLabel = labelSet.has("r: testflight");
|
||||
const hasSecurityLabel = labelSet.has("security");
|
||||
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["security"],
|
||||
});
|
||||
labelSet.add("security");
|
||||
}
|
||||
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: testflight"],
|
||||
});
|
||||
labelSet.add("r: testflight");
|
||||
}
|
||||
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ["r: moltbook"],
|
||||
});
|
||||
labelSet.add("r: moltbook");
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
const spamLabel = "r: spam";
|
||||
const dirtyLabel = "dirty";
|
||||
const badBarnacleLabel = "bad-barnacle";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(badBarnacleLabel)) {
|
||||
core.info(`Skipping PR auto-response checks for #${pullRequest.number} because ${badBarnacleLabel} is present.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const labelCount = labelSet.size;
|
||||
if (labelCount > 20) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
body: noisyPrMessage,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(spamLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "spam",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pullRequest && labelSet.has(activePrLimitOverrideLabel)) {
|
||||
labelSet.delete(activePrLimitLabel);
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
if (!rule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueNumber = target.number;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body: rule.message,
|
||||
});
|
||||
|
||||
if (rule.close) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
|
||||
if (rule.lock) {
|
||||
await github.rest.issues.lock({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
lock_reason: rule.lockReason ?? "resolved",
|
||||
});
|
||||
}
|
||||
|
||||
152
.github/workflows/ci.yml
vendored
152
.github/workflows/ci.yml
vendored
@@ -1,12 +1,8 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
|
||||
@@ -14,8 +10,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
|
||||
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -41,7 +37,6 @@ jobs:
|
||||
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
|
||||
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
|
||||
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
|
||||
run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }}
|
||||
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
|
||||
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
|
||||
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
||||
@@ -76,7 +71,6 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Ensure preflight base commit
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
@@ -84,12 +78,11 @@ jobs:
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: docs_scope
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
- name: Detect changed scopes
|
||||
id: changed_scope
|
||||
if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true'
|
||||
if: steps.docs_scope.outputs.docs_only != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -104,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Detect changed extensions
|
||||
id: changed_extensions
|
||||
if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
|
||||
if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
@@ -128,19 +121,16 @@ jobs:
|
||||
- name: Build CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}
|
||||
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
|
||||
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
|
||||
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ github.event_name == 'workflow_dispatch' && '{"include":[]}' || steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
|
||||
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
|
||||
OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
|
||||
OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
|
||||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||||
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
|
||||
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
|
||||
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
node --input-type=module <<'EOF'
|
||||
@@ -180,23 +170,12 @@ jobs:
|
||||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||||
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
||||
const runNodeFastOnly =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||||
const runNodeFull = runNode && !runNodeFastOnly;
|
||||
const runNodeFastPluginContracts =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS);
|
||||
const runNodeFastCiRouting =
|
||||
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING);
|
||||
const runChecksFastCore = runNodeFull || runNodeFastPluginContracts || runNodeFastCiRouting;
|
||||
const runMacos =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
||||
const runAndroid =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
||||
const runWindows =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) &&
|
||||
!docsOnly &&
|
||||
!runNodeFastOnly &&
|
||||
isCanonicalRepository;
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && isCanonicalRepository;
|
||||
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
|
||||
const runControlUiI18n =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
|
||||
@@ -209,49 +188,18 @@ jobs:
|
||||
? DEFAULT_EXTENSION_TEST_SHARD_COUNT
|
||||
: Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36);
|
||||
const extensionShardMatrix = createMatrix(
|
||||
runNodeFull
|
||||
runNode
|
||||
? createExtensionTestShards({
|
||||
shardCount: extensionTestShardCount,
|
||||
}).map((shard) => ({
|
||||
check_name: shard.checkName,
|
||||
extensions_csv: shard.extensionIds.join(","),
|
||||
runner: isCanonicalRepository && [0, 3, 4].includes(shard.index)
|
||||
? "blacksmith-8vcpu-ubuntu-2404"
|
||||
: isCanonicalRepository
|
||||
? "blacksmith-4vcpu-ubuntu-2404"
|
||||
: "ubuntu-24.04",
|
||||
shard_index: shard.index + 1,
|
||||
task: "extensions-batch",
|
||||
}))
|
||||
: [],
|
||||
);
|
||||
const checksFastCoreTasks = [];
|
||||
if (runNodeFull) {
|
||||
checksFastCoreTasks.push(
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: "contracts-plugins",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (runNodeFastPluginContracts) {
|
||||
checksFastCoreTasks.push({
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: runNodeFastCiRouting ? "contracts-plugins-ci-routing" : "contracts-plugins",
|
||||
});
|
||||
} else if (runNodeFastCiRouting) {
|
||||
checksFastCoreTasks.push({
|
||||
check_name: "checks-fast-ci-routing",
|
||||
runtime: "node",
|
||||
task: "ci-routing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nodeTestShards = runNodeFull
|
||||
const nodeTestShards = runNode
|
||||
? createNodeTestShards().map((shard) => ({
|
||||
check_name: shard.checkName,
|
||||
runtime: "node",
|
||||
@@ -260,7 +208,6 @@ jobs:
|
||||
configs: shard.configs,
|
||||
includePatterns: shard.includePatterns,
|
||||
requires_dist: shard.requiresDist,
|
||||
runner: shard.runner,
|
||||
}))
|
||||
: [];
|
||||
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
|
||||
@@ -276,17 +223,25 @@ jobs:
|
||||
run_windows: runWindows,
|
||||
has_changed_extensions: hasChangedExtensions,
|
||||
changed_extensions_matrix: changedExtensionsMatrix,
|
||||
run_build_artifacts: runNodeFull,
|
||||
run_checks_fast_core: runChecksFastCore,
|
||||
run_checks_fast: runNodeFull,
|
||||
checks_fast_core_matrix: createMatrix(checksFastCoreTasks),
|
||||
channel_contracts_matrix: createMatrix(
|
||||
runNodeFull ? createChannelContractTestShards() : [],
|
||||
run_build_artifacts: runNode,
|
||||
run_checks_fast: runNode,
|
||||
checks_fast_core_matrix: createMatrix(
|
||||
runNode
|
||||
? [
|
||||
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
|
||||
{
|
||||
check_name: "checks-fast-contracts-plugins",
|
||||
runtime: "node",
|
||||
task: "contracts-plugins",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []),
|
||||
checks_node_extensions_matrix: extensionShardMatrix,
|
||||
run_checks: runNodeFull,
|
||||
run_checks: runNode,
|
||||
checks_matrix: createMatrix(
|
||||
runNodeFull
|
||||
runNode
|
||||
? [
|
||||
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
|
||||
]
|
||||
@@ -305,9 +260,9 @@ jobs:
|
||||
}))
|
||||
: [],
|
||||
),
|
||||
run_check: runNodeFull,
|
||||
run_check_additional: runNodeFull,
|
||||
run_build_smoke: runNodeFull,
|
||||
run_check: runNode,
|
||||
run_check_additional: runNode,
|
||||
run_build_smoke: runNode,
|
||||
run_check_docs: docsChanged,
|
||||
run_control_ui_i18n: runControlUiI18n,
|
||||
run_skills_python_job: runSkillsPython,
|
||||
@@ -698,8 +653,8 @@ jobs:
|
||||
contents: read
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -775,13 +730,6 @@ jobs:
|
||||
contracts-plugins)
|
||||
pnpm test:contracts:plugins
|
||||
;;
|
||||
contracts-plugins-ci-routing)
|
||||
pnpm test:contracts:plugins
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
ci-routing)
|
||||
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/test-projects.test.ts
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported checks-fast task: $TASK" >&2
|
||||
exit 1
|
||||
@@ -965,7 +913,7 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_fast == 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1027,7 +975,7 @@ jobs:
|
||||
- name: Run extension shard
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
|
||||
OPENCLAW_EXTENSION_BATCH_PARALLEL: 1
|
||||
OPENCLAW_VITEST_MAX_WORKERS: 1
|
||||
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
|
||||
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
|
||||
@@ -1087,8 +1035,8 @@ jobs:
|
||||
contents: read
|
||||
name: checks-node-compat-node22
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'push'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -1165,7 +1113,10 @@ jobs:
|
||||
name: ${{ matrix.check_name }}
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
||||
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04' }}
|
||||
# Keep core shards on GitHub-hosted runners. The Blacksmith pool is already
|
||||
# occupied by build and extension shards; queueing these shards there hides
|
||||
# actual test-speed improvements behind runner wait time.
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1234,7 +1185,6 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -1422,16 +1372,16 @@ jobs:
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-lint
|
||||
task: lint
|
||||
runner: blacksmith-16vcpu-ubuntu-2404
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-policy-guards
|
||||
task: policy-guards
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-test-types
|
||||
task: test-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
runner: ubuntu-24.04
|
||||
- check_name: check-strict-smoke
|
||||
task: strict-smoke
|
||||
runner: ubuntu-24.04
|
||||
@@ -1506,7 +1456,7 @@ jobs:
|
||||
pnpm tsgo:prod
|
||||
;;
|
||||
lint)
|
||||
pnpm lint --threads=8
|
||||
pnpm lint
|
||||
;;
|
||||
policy-guards)
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
@@ -1631,7 +1581,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
runs_on: blacksmith-32vcpu-ubuntu-2404
|
||||
runs_on: blacksmith-16vcpu-ubuntu-2404
|
||||
needs_node: true
|
||||
needs_python: false
|
||||
needs_java: false
|
||||
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
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_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
OPENCLAW_CONTROL_UI_I18N_MODEL: gpt-5.4
|
||||
OPENCLAW_CONTROL_UI_I18N_THINKING: low
|
||||
LOCALE: ${{ matrix.locale }}
|
||||
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write
|
||||
|
||||
171
.github/workflows/docker-release.yml
vendored
171
.github/workflows/docker-release.yml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
|
||||
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
|
||||
# Build amd64 image. Default and slim tags point to the same slim runtime.
|
||||
# Build amd64 images (default + slim share the build stage cache)
|
||||
build-amd64:
|
||||
needs: [approve_manual_backfill]
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -116,7 +117,12 @@ jobs:
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -153,15 +159,28 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 image. Default and slim tags point to the same slim runtime.
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
needs: [approve_manual_backfill]
|
||||
if: ${{ always() && (github.event_name != 'workflow_dispatch' || needs.approve_manual_backfill.result == 'success') }}
|
||||
@@ -172,6 +191,7 @@ jobs:
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
slim-digest: ${{ steps.build-slim.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
@@ -214,7 +234,12 @@ jobs:
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -251,12 +276,25 @@ jobs:
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_EXTENSIONS=diagnostics-otel
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
# WARNING: KEEP THE OFFICIAL DOCKER ACTION HERE; DO NOT SWITCH THIS BACK TO BLACKSMITH BLINDLY.
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifests
|
||||
@@ -313,11 +351,16 @@ jobs:
|
||||
fi
|
||||
{
|
||||
echo "value<<EOF"
|
||||
printf "%s\n" "${tags[@]}" "${slim_tags[@]}"
|
||||
printf "%s\n" "${tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "slim<<EOF"
|
||||
printf "%s\n" "${slim_tags[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create and push manifest
|
||||
- name: Create and push default manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.value }}
|
||||
@@ -335,94 +378,20 @@ jobs:
|
||||
"${AMD64_DIGEST}" \
|
||||
"${ARM64_DIGEST}"
|
||||
|
||||
verify-attestations:
|
||||
needs: [create-manifest]
|
||||
if: ${{ always() && needs.create-manifest.result == 'success' }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Docker Builder
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image refs
|
||||
id: refs
|
||||
- name: Create and push slim manifest
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
IS_MANUAL_BACKFILL: ${{ github.event_name == 'workflow_dispatch' && '1' || '0' }}
|
||||
SLIM_TAGS: ${{ steps.tags.outputs.slim }}
|
||||
AMD64_SLIM_DIGEST: ${{ needs.build-amd64.outputs.slim-digest }}
|
||||
ARM64_SLIM_DIGEST: ${{ needs.build-arm64.outputs.slim-digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
multi_refs=()
|
||||
slim_multi_refs=()
|
||||
amd64_refs=()
|
||||
arm64_refs=()
|
||||
if [[ "${SOURCE_REF}" == "refs/heads/main" ]]; then
|
||||
multi_refs+=("${IMAGE}:main")
|
||||
slim_multi_refs+=("${IMAGE}:main-slim")
|
||||
amd64_refs+=("${IMAGE}:main-amd64" "${IMAGE}:main-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:main-arm64" "${IMAGE}:main-slim-arm64")
|
||||
fi
|
||||
if [[ "${SOURCE_REF}" == refs/tags/v* ]]; then
|
||||
version="${SOURCE_REF#refs/tags/v}"
|
||||
multi_refs+=("${IMAGE}:${version}")
|
||||
slim_multi_refs+=("${IMAGE}:${version}-slim")
|
||||
amd64_refs+=("${IMAGE}:${version}-amd64" "${IMAGE}:${version}-slim-amd64")
|
||||
arm64_refs+=("${IMAGE}:${version}-arm64" "${IMAGE}:${version}-slim-arm64")
|
||||
if [[ "${IS_MANUAL_BACKFILL}" != "1" && "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
|
||||
multi_refs+=("${IMAGE}:latest")
|
||||
slim_multi_refs+=("${IMAGE}:slim")
|
||||
fi
|
||||
fi
|
||||
if [[ ${#multi_refs[@]} -eq 0 || ${#amd64_refs[@]} -eq 0 || ${#arm64_refs[@]} -eq 0 ]]; then
|
||||
echo "::error::No Docker image refs resolved for ref ${SOURCE_REF}"
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "multi<<EOF"
|
||||
printf "%s\n" "${multi_refs[@]}" "${slim_multi_refs[@]}"
|
||||
echo "EOF"
|
||||
echo "amd64<<EOF"
|
||||
printf "%s\n" "${amd64_refs[@]}"
|
||||
echo "EOF"
|
||||
echo "arm64<<EOF"
|
||||
printf "%s\n" "${arm64_refs[@]}"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify Docker attestations
|
||||
shell: bash
|
||||
env:
|
||||
MULTI_REFS: ${{ steps.refs.outputs.multi }}
|
||||
AMD64_REFS: ${{ steps.refs.outputs.amd64 }}
|
||||
ARM64_REFS: ${{ steps.refs.outputs.arm64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t multi_refs <<< "${MULTI_REFS}"
|
||||
mapfile -t amd64_refs <<< "${AMD64_REFS}"
|
||||
mapfile -t arm64_refs <<< "${ARM64_REFS}"
|
||||
|
||||
node scripts/verify-docker-attestations.mjs \
|
||||
--platform linux/amd64 \
|
||||
--platform linux/arm64 \
|
||||
"${multi_refs[@]}"
|
||||
node scripts/verify-docker-attestations.mjs \
|
||||
--platform linux/amd64 \
|
||||
"${amd64_refs[@]}"
|
||||
node scripts/verify-docker-attestations.mjs \
|
||||
--platform linux/arm64 \
|
||||
"${arm64_refs[@]}"
|
||||
mapfile -t tags <<< "${SLIM_TAGS}"
|
||||
args=()
|
||||
for tag in "${tags[@]}"; do
|
||||
[ -z "$tag" ] && continue
|
||||
args+=("-t" "$tag")
|
||||
done
|
||||
docker buildx imagetools create "${args[@]}" \
|
||||
"${AMD64_SLIM_DIGEST}" \
|
||||
"${ARM64_SLIM_DIGEST}"
|
||||
|
||||
5
.github/workflows/docs-agent.yml
vendored
5
.github/workflows/docs-agent.yml
vendored
@@ -156,7 +156,7 @@ jobs:
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/docs-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
model: gpt-5.4
|
||||
effort: medium
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
@@ -197,8 +197,7 @@ jobs:
|
||||
|
||||
- name: Restore Node 24 path
|
||||
if: steps.gate.outputs.run_agent == 'true'
|
||||
run:
|
||||
| # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
|
||||
run: | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
|
||||
set -euo pipefail
|
||||
export PATH="${NODE_BIN}:${PATH}"
|
||||
echo "${NODE_BIN}" >> "$GITHUB_PATH"
|
||||
|
||||
29
.github/workflows/docs-sync-publish.yml
vendored
29
.github/workflows/docs-sync-publish.yml
vendored
@@ -63,43 +63,18 @@ jobs:
|
||||
working-directory: publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
remote_source_sha() {
|
||||
git show refs/remotes/origin/main:.openclaw-sync/source.json 2>/dev/null \
|
||||
| node -e 'const fs = require("node:fs"); try { const data = JSON.parse(fs.readFileSync(0, "utf8")); if (data.sha) process.stdout.write(data.sha); } catch {}' \
|
||||
|| true
|
||||
}
|
||||
|
||||
skip_stale_source() {
|
||||
current_source_sha="$(remote_source_sha)"
|
||||
if [ -z "$current_source_sha" ] || [ "$current_source_sha" = "$GITHUB_SHA" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if git -C "$GITHUB_WORKSPACE" merge-base --is-ancestor "$GITHUB_SHA" "$current_source_sha"; then
|
||||
echo "Skipping stale publish sync for $GITHUB_SHA; origin/main already mirrors $current_source_sha."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
if git diff --quiet -- docs .openclaw-sync; then
|
||||
echo "No publish-repo changes."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git fetch origin main:refs/remotes/origin/main; then
|
||||
skip_stale_source
|
||||
fi
|
||||
|
||||
git config user.name "openclaw-docs-sync[bot]"
|
||||
git config user.email "openclaw-docs-sync[bot]@users.noreply.github.com"
|
||||
git add docs .openclaw-sync
|
||||
git commit -m "chore(sync): mirror docs from $GITHUB_REPOSITORY@$GITHUB_SHA"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git fetch origin main:refs/remotes/origin/main; then
|
||||
skip_stale_source
|
||||
if git rebase -X theirs origin/main && git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
if git fetch origin main && git rebase origin/main && git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
git rebase --abort >/dev/null 2>&1 || true
|
||||
echo "Publish sync attempt ${attempt} failed; retrying."
|
||||
|
||||
39
.github/workflows/docs.yml
vendored
39
.github/workflows/docs.yml
vendored
@@ -1,39 +0,0 @@
|
||||
name: Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
docs:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
fetch-tags: false
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Check docs
|
||||
run: pnpm check:docs
|
||||
113
.github/workflows/install-smoke.yml
vendored
113
.github/workflows/install-smoke.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: Install Smoke
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
@@ -10,11 +14,6 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
update_baseline_version:
|
||||
description: Baseline openclaw version or dist-tag for installer update smoke
|
||||
required: false
|
||||
default: latest
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
@@ -26,17 +25,12 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
update_baseline_version:
|
||||
description: Baseline openclaw version or dist-tag for installer update smoke
|
||||
required: false
|
||||
default: latest
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-{1}', github.workflow, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-{1}', github.workflow, github.run_id) || github.event_name == 'pull_request' && format('{0}-{1}', github.workflow, github.event.pull_request.number) || format('{0}-{1}', github.workflow, github.ref) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -44,6 +38,7 @@ env:
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
||||
@@ -61,19 +56,67 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: false
|
||||
|
||||
- name: Ensure preflight base commit
|
||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' && github.event_name != 'workflow_call'
|
||||
uses: ./.github/actions/ensure-base-commit
|
||||
with:
|
||||
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: docs_scope
|
||||
uses: ./.github/actions/detect-docs-changes
|
||||
|
||||
- name: Detect changed smoke scope
|
||||
id: changed_scope
|
||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' && github.event_name != 'workflow_call' && steps.docs_scope.outputs.docs_only != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
|
||||
- name: Build install-smoke CI manifest
|
||||
id: manifest
|
||||
env:
|
||||
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
|
||||
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
||||
OPENCLAW_CI_FORCE_FULL_INSTALL_SMOKE: ${{ (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_call') && 'true' || 'false' }}
|
||||
OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE: ${{ inputs.run_bun_global_install_smoke || 'false' }}
|
||||
OPENCLAW_CI_RUN_FAST_INSTALL_SMOKE: ${{ steps.changed_scope.outputs.run_fast_install_smoke || steps.changed_scope.outputs.run_changed_smoke || 'false' }}
|
||||
OPENCLAW_CI_RUN_FULL_INSTALL_SMOKE: ${{ steps.changed_scope.outputs.run_full_install_smoke || 'false' }}
|
||||
run: |
|
||||
docs_only="${OPENCLAW_CI_DOCS_ONLY:-false}"
|
||||
event_name="${OPENCLAW_CI_EVENT_NAME:-}"
|
||||
force_full_install_smoke="${OPENCLAW_CI_FORCE_FULL_INSTALL_SMOKE:-false}"
|
||||
workflow_bun_global_install_smoke="${OPENCLAW_CI_WORKFLOW_BUN_GLOBAL_INSTALL_SMOKE:-false}"
|
||||
docs_only=false
|
||||
run_fast_install_smoke=true
|
||||
run_full_install_smoke=true
|
||||
run_changed_fast_install_smoke="${OPENCLAW_CI_RUN_FAST_INSTALL_SMOKE:-false}"
|
||||
run_changed_full_install_smoke="${OPENCLAW_CI_RUN_FULL_INSTALL_SMOKE:-false}"
|
||||
run_fast_install_smoke=false
|
||||
run_full_install_smoke=false
|
||||
run_bun_global_install_smoke=false
|
||||
run_install_smoke=true
|
||||
run_install_smoke=false
|
||||
if [ "$force_full_install_smoke" = "true" ]; then
|
||||
run_fast_install_smoke=true
|
||||
run_full_install_smoke=true
|
||||
run_install_smoke=true
|
||||
elif [ "$docs_only" != "true" ] && [ "$event_name" != "push" ] && [ "$run_changed_full_install_smoke" = "true" ]; then
|
||||
run_fast_install_smoke=true
|
||||
run_full_install_smoke=true
|
||||
run_install_smoke=true
|
||||
elif [ "$docs_only" != "true" ] && [ "$run_changed_full_install_smoke" = "true" ]; then
|
||||
run_fast_install_smoke=true
|
||||
run_install_smoke=true
|
||||
elif [ "$docs_only" != "true" ] && [ "$run_changed_fast_install_smoke" = "true" ]; then
|
||||
run_fast_install_smoke=true
|
||||
run_install_smoke=true
|
||||
fi
|
||||
if [ "$event_name" = "schedule" ]; then
|
||||
run_bun_global_install_smoke=true
|
||||
elif [ "$event_name" = "workflow_dispatch" ] || [ "$event_name" = "workflow_call" ]; then
|
||||
@@ -113,6 +156,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_DOCKER_APT_UPGRADE=0
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
tags: |
|
||||
openclaw-dockerfile-smoke:local
|
||||
@@ -123,27 +167,7 @@ jobs:
|
||||
|
||||
- name: Run root Dockerfile CLI smoke
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc '
|
||||
which openclaw &&
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const fs = require(\"node:fs\");
|
||||
const path = require(\"node:path\");
|
||||
const pkg = require(\"/app/package.json\");
|
||||
for (const [dep, rel] of Object.entries(pkg.pnpm?.patchedDependencies ?? {})) {
|
||||
const absolute = path.join(\"/app\", rel);
|
||||
if (!fs.existsSync(absolute)) {
|
||||
throw new Error(`missing patch for ${dep}: ${rel}`);
|
||||
}
|
||||
}
|
||||
"
|
||||
'
|
||||
|
||||
- name: Run agents delete shared workspace Docker CLI smoke
|
||||
env:
|
||||
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: openclaw-dockerfile-smoke:local
|
||||
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
|
||||
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Run Docker gateway network e2e
|
||||
env:
|
||||
@@ -227,6 +251,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
OPENCLAW_DOCKER_APT_UPGRADE=0
|
||||
OPENCLAW_EXTENSIONS=matrix
|
||||
tags: |
|
||||
openclaw-dockerfile-smoke:local
|
||||
@@ -239,12 +264,6 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
- name: Run agents delete shared workspace Docker CLI smoke
|
||||
env:
|
||||
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_IMAGE: openclaw-dockerfile-smoke:local
|
||||
OPENCLAW_AGENTS_DELETE_SHARED_WORKSPACE_E2E_SKIP_BUILD: "1"
|
||||
run: bash scripts/e2e/agents-delete-shared-workspace-docker.sh
|
||||
|
||||
- name: Run Docker gateway network e2e
|
||||
env:
|
||||
OPENCLAW_GATEWAY_NETWORK_E2E_IMAGE: openclaw-dockerfile-smoke:local
|
||||
@@ -307,6 +326,7 @@ jobs:
|
||||
provenance: false
|
||||
|
||||
- name: Build installer non-root image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: ./scripts/docker
|
||||
@@ -336,11 +356,11 @@ jobs:
|
||||
OPENCLAW_NO_ONBOARD: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_IMAGE_BUILD: "1"
|
||||
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: "0"
|
||||
OPENCLAW_INSTALL_NONROOT_SKIP_IMAGE_BUILD: ${{ github.event_name == 'pull_request' && '0' || '1' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NPM_GLOBAL: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: ${{ inputs.update_baseline_version || 'latest' }}
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_BASELINE: latest
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_DIST_IMAGE: openclaw-dockerfile-smoke:local
|
||||
OPENCLAW_INSTALL_SMOKE_UPDATE_SKIP_LOCAL_BUILD: "1"
|
||||
run: bash scripts/test-install-sh-docker.sh
|
||||
@@ -371,5 +391,4 @@ jobs:
|
||||
- name: Run fast bundled plugin Docker E2E
|
||||
env:
|
||||
OPENCLAW_BUNDLED_CHANNEL_DEPS_E2E_IMAGE: openclaw-bundled-channel-fast:local
|
||||
OPENCLAW_BUNDLED_CHANNEL_DOCKER_RUN_TIMEOUT: 90s
|
||||
run: timeout 240s pnpm test:docker:bundled-channel-deps:fast
|
||||
run: timeout 120s pnpm test:docker:bundled-channel-deps:fast
|
||||
|
||||
144
.github/workflows/npm-telegram-beta-e2e.yml
vendored
144
.github/workflows/npm-telegram-beta-e2e.yml
vendored
@@ -1,144 +0,0 @@
|
||||
name: NPM Telegram Beta E2E
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package_spec:
|
||||
description: Published OpenClaw package spec to test
|
||||
required: true
|
||||
default: openclaw@beta
|
||||
type: string
|
||||
provider_mode:
|
||||
description: QA provider mode
|
||||
required: true
|
||||
default: mock-openai
|
||||
type: choice
|
||||
options:
|
||||
- mock-openai
|
||||
- live-frontier
|
||||
scenario:
|
||||
description: Optional comma-separated Telegram scenario ids
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: npm-telegram-beta-e2e-${{ github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.33.0"
|
||||
|
||||
jobs:
|
||||
run_npm_telegram_beta_e2e:
|
||||
name: Run published npm Telegram E2E
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 60
|
||||
environment: qa-live-shared
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
steps:
|
||||
- name: Checkout dispatch ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Blacksmith Docker Builder
|
||||
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
|
||||
with:
|
||||
max-cache-size-mb: 800000
|
||||
|
||||
- name: Build Docker E2E image
|
||||
uses: useblacksmith/build-push-action@cbd1f60d194a98cb3be5523b15134501eaf0fbf3 # v2
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: build
|
||||
platforms: linux/amd64
|
||||
tags: openclaw-docker-e2e:local
|
||||
load: true
|
||||
push: false
|
||||
provenance: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Validate inputs and secrets
|
||||
env:
|
||||
PACKAGE_SPEC: ${{ inputs.package_spec }}
|
||||
PROVIDER_MODE: ${{ inputs.provider_mode }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "${PACKAGE_SPEC}" =~ ^openclaw@(beta|latest|[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-[1-9][0-9]*|-beta\.[1-9][0-9]*)?)$ ]]; then
|
||||
echo "package_spec must be openclaw@beta, openclaw@latest, or an exact OpenClaw release version; got: ${PACKAGE_SPEC}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_var() {
|
||||
local key="$1"
|
||||
if [[ -z "${!key:-}" ]]; then
|
||||
echo "Missing required ${key}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_var OPENCLAW_QA_CONVEX_SITE_URL
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
if [[ "${PROVIDER_MODE}" == "live-frontier" ]]; then
|
||||
require_var OPENAI_API_KEY
|
||||
fi
|
||||
|
||||
- name: Run npm Telegram beta E2E
|
||||
id: run_lane
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENCLAW_SKIP_DOCKER_BUILD: "1"
|
||||
OPENCLAW_DOCKER_E2E_IMAGE: openclaw-docker-e2e:local
|
||||
OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC: ${{ inputs.package_spec }}
|
||||
OPENCLAW_NPM_TELEGRAM_PROVIDER_MODE: ${{ inputs.provider_mode }}
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE: convex
|
||||
OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE: ci
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_TELEGRAM_CAPTURE_CONTENT: "1"
|
||||
INPUT_SCENARIO: ${{ inputs.scenario }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
output_dir=".artifacts/qa-e2e/npm-telegram-beta-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
|
||||
export OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR="${output_dir}"
|
||||
|
||||
if [[ -n "${INPUT_SCENARIO// }" ]]; then
|
||||
export OPENCLAW_NPM_TELEGRAM_SCENARIOS="${INPUT_SCENARIO}"
|
||||
fi
|
||||
|
||||
pnpm test:docker:npm-telegram-live
|
||||
|
||||
- name: Upload npm Telegram E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_lane.outputs.output_dir }}
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
@@ -432,35 +432,24 @@ jobs:
|
||||
OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }}
|
||||
OPENCLAW_RELEASE_CHECK_OS: ${{ matrix.os_id }}
|
||||
OPENCLAW_RELEASE_CHECK_RUNNER: ${{ matrix.runner }}
|
||||
CANDIDATE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}
|
||||
CANDIDATE_VERSION: ${{ needs.prepare.outputs.candidate_version }}
|
||||
SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }}
|
||||
BASELINE_SPEC: ${{ needs.prepare.outputs.baseline_spec }}
|
||||
PREVIOUS_VERSION: ${{ inputs.previous_version }}
|
||||
BASELINE_TGZ: ${{ runner.temp }}/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}
|
||||
PROVIDER: ${{ inputs.provider }}
|
||||
MODE: ${{ matrix.lane }}
|
||||
SUITE: ${{ matrix.suite }}
|
||||
REF: ${{ inputs.ref }}
|
||||
OUTPUT_DIR: ${{ runner.temp }}/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}
|
||||
run: |
|
||||
DISCORD_ARGS=()
|
||||
if [[ -n "${OPENCLAW_DISCORD_SMOKE_BOT_TOKEN}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_GUILD_ID}" ]] && [[ -n "${OPENCLAW_DISCORD_SMOKE_CHANNEL_ID}" ]]; then
|
||||
DISCORD_ARGS+=(--run-discord-roundtrip true)
|
||||
fi
|
||||
pnpm dlx "tsx@${TSX_VERSION}" workflow/scripts/openclaw-cross-os-release-checks.ts \
|
||||
--candidate-tgz "${CANDIDATE_TGZ}" \
|
||||
--candidate-version "${CANDIDATE_VERSION}" \
|
||||
--source-sha "${SOURCE_SHA}" \
|
||||
--baseline-spec "${BASELINE_SPEC}" \
|
||||
--previous-version "${PREVIOUS_VERSION}" \
|
||||
--baseline-tgz "${BASELINE_TGZ}" \
|
||||
--provider "${PROVIDER}" \
|
||||
--mode "${MODE}" \
|
||||
--suite "${SUITE}" \
|
||||
--ref "${REF}" \
|
||||
--candidate-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/candidate/${{ needs.prepare.outputs.candidate_file_name }}" \
|
||||
--candidate-version "${{ needs.prepare.outputs.candidate_version }}" \
|
||||
--source-sha "${{ needs.prepare.outputs.source_sha }}" \
|
||||
--baseline-spec "${{ needs.prepare.outputs.baseline_spec }}" \
|
||||
--previous-version "${{ inputs.previous_version }}" \
|
||||
--baseline-tgz "$RUNNER_TEMP/openclaw-cross-os-release-checks/baseline/${{ needs.prepare.outputs.baseline_file_name }}" \
|
||||
--provider "${{ inputs.provider }}" \
|
||||
--mode "${{ matrix.lane }}" \
|
||||
--suite "${{ matrix.suite }}" \
|
||||
--ref "${{ inputs.ref }}" \
|
||||
"${DISCORD_ARGS[@]}" \
|
||||
--output-dir "${OUTPUT_DIR}"
|
||||
--output-dir "$RUNNER_TEMP/openclaw-cross-os-release-checks/${{ matrix.artifact_name }}-${{ matrix.suite }}"
|
||||
|
||||
- name: Summarize release checks
|
||||
if: always()
|
||||
|
||||
@@ -23,11 +23,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
docker_lanes:
|
||||
description: Comma/space separated Docker scheduler lane names to run against the prepared image
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -59,11 +54,6 @@ on:
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
docker_lanes:
|
||||
description: Comma/space separated Docker scheduler lane names to run against the prepared image
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
include_live_suites:
|
||||
description: Whether to run live-provider coverage
|
||||
required: false
|
||||
@@ -192,7 +182,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
WORKFLOW_REF_NAME: ${{ github.ref_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -200,15 +189,9 @@ jobs:
|
||||
trusted_reason=""
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
if [[ "${WORKFLOW_REF_NAME}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
|
||||
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then
|
||||
trusted_reason="main-ancestor"
|
||||
elif [[ "${WORKFLOW_REF_NAME}" =~ ^release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] &&
|
||||
[[ "$selected_sha" == "$(git rev-parse "refs/remotes/origin/${WORKFLOW_REF_NAME}")" ]]; then
|
||||
trusted_reason="release-branch-head"
|
||||
elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then
|
||||
trusted_reason="release-tag"
|
||||
else
|
||||
@@ -225,7 +208,7 @@ jobs:
|
||||
|
||||
if [[ -z "$trusted_reason" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2
|
||||
echo "Allowed refs must be on main, match the current release branch head, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
|
||||
echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -320,7 +303,7 @@ jobs:
|
||||
requires_live_suites: false
|
||||
- suite_id: openai-ws-stream-live-e2e
|
||||
label: OpenAI WebSocket live E2E
|
||||
command: pnpm test:e2e src/agents/openai-ws-stream.e2e.test.ts
|
||||
command: pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts
|
||||
timeout_minutes: 90
|
||||
requires_repo_e2e: false
|
||||
requires_live_suites: true
|
||||
@@ -380,23 +363,83 @@ jobs:
|
||||
|
||||
validate_docker_e2e:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
name: Docker E2E (${{ matrix.label }})
|
||||
if: inputs.include_release_path_suites
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: ${{ matrix.timeout_minutes }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- chunk_id: core
|
||||
label: core
|
||||
- suite_id: docker-onboard
|
||||
label: Onboarding Docker E2E
|
||||
command: pnpm test:docker:onboard
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-npm-onboard-channel-agent
|
||||
label: Npm Onboard Channel Agent Docker E2E
|
||||
command: pnpm test:docker:npm-onboard-channel-agent
|
||||
timeout_minutes: 90
|
||||
release_path: true
|
||||
- suite_id: docker-gateway-network
|
||||
label: Gateway Network Docker E2E
|
||||
command: pnpm test:docker:gateway-network
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-openai-web-search-minimal
|
||||
label: OpenAI Web Search Minimal Docker E2E
|
||||
command: pnpm test:docker:openai-web-search-minimal
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-mcp-channels
|
||||
label: MCP Channels Docker E2E
|
||||
command: pnpm test:docker:mcp-channels
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-pi-bundle-mcp-tools
|
||||
label: Pi Bundle MCP Tools Docker E2E
|
||||
command: pnpm test:docker:pi-bundle-mcp-tools
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-cron-mcp-cleanup
|
||||
label: Cron MCP Cleanup Docker E2E
|
||||
command: pnpm test:docker:cron-mcp-cleanup
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-plugins
|
||||
label: Plugins Docker E2E
|
||||
command: pnpm test:docker:plugins
|
||||
timeout_minutes: 75
|
||||
release_path: true
|
||||
- suite_id: docker-plugin-update
|
||||
label: Plugin Update Docker E2E
|
||||
command: pnpm test:docker:plugin-update
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-config-reload
|
||||
label: Config Reload Docker E2E
|
||||
command: pnpm test:docker:config-reload
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-bundled-channel-deps
|
||||
label: Bundled Channel Runtime Deps Docker E2E
|
||||
command: pnpm test:docker:bundled-channel-deps
|
||||
timeout_minutes: 75
|
||||
release_path: true
|
||||
- suite_id: docker-doctor-switch
|
||||
label: Doctor Install Switch Docker E2E
|
||||
command: pnpm test:docker:doctor-switch
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-qr
|
||||
label: QR Import Docker E2E
|
||||
command: pnpm test:docker:qr
|
||||
timeout_minutes: 60
|
||||
release_path: true
|
||||
- suite_id: docker-install-e2e
|
||||
label: Installer Docker E2E
|
||||
command: pnpm test:install:e2e
|
||||
timeout_minutes: 120
|
||||
- chunk_id: package-update
|
||||
label: package/update
|
||||
timeout_minutes: 180
|
||||
- chunk_id: plugins-integrations
|
||||
label: plugins/integrations
|
||||
timeout_minutes: 180
|
||||
release_path: true
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
@@ -443,12 +486,7 @@ jobs:
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
|
||||
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
|
||||
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_SKIP_DOCKER_BUILD: "1"
|
||||
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
|
||||
DOCKER_E2E_CHUNK: ${{ matrix.chunk_id }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
@@ -473,188 +511,45 @@ jobs:
|
||||
- name: Hydrate live auth/profile inputs
|
||||
run: bash scripts/ci-hydrate-live-auth.sh
|
||||
|
||||
- name: Plan and hydrate Docker E2E chunk
|
||||
id: plan
|
||||
uses: ./.github/actions/docker-e2e-plan
|
||||
with:
|
||||
mode: chunk
|
||||
chunk: ${{ matrix.chunk_id }}
|
||||
include-openwebui: ${{ inputs.include_openwebui }}
|
||||
|
||||
- name: Run Docker E2E chunk
|
||||
- name: Configure suite-specific env
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OPENCLAW_DOCKER_ALL_PROFILE=release-path
|
||||
export OPENCLAW_DOCKER_ALL_CHUNK="${DOCKER_E2E_CHUNK}"
|
||||
export OPENCLAW_DOCKER_ALL_BUILD=0
|
||||
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
|
||||
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
|
||||
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
|
||||
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}"
|
||||
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}-timings.json"
|
||||
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
|
||||
case "${{ matrix.suite_id }}" in
|
||||
docker-install-e2e)
|
||||
echo "OPENCLAW_E2E_MODELS=both" >> "$GITHUB_ENV"
|
||||
;;
|
||||
esac
|
||||
|
||||
pnpm test:docker:all
|
||||
|
||||
- name: Summarize Docker E2E chunk
|
||||
if: always()
|
||||
- name: Validate suite credentials
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
summary=".artifacts/docker-tests/release-${DOCKER_E2E_CHUNK}/summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "Docker chunk summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E chunk: ${DOCKER_E2E_CHUNK:-unknown}" >> "$GITHUB_STEP_SUMMARY"
|
||||
case "${{ matrix.suite_id }}" in
|
||||
docker-install-e2e)
|
||||
[[ -n "${OPENAI_API_KEY:-}" ]] || {
|
||||
echo "OPENAI_API_KEY is required for installer Docker E2E." >&2
|
||||
exit 1
|
||||
}
|
||||
if [[ -z "${ANTHROPIC_API_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "ANTHROPIC_API_TOKEN or ANTHROPIC_API_KEY is required for installer Docker E2E." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Upload Docker E2E chunk artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docker-e2e-${{ matrix.chunk_id }}
|
||||
path: .artifacts/docker-tests/
|
||||
if-no-files-found: ignore
|
||||
|
||||
validate_docker_lanes:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.docker_lanes != ''
|
||||
name: Docker E2E targeted lanes
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
|
||||
OPENCLAW_DOCKER_E2E_BARE_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.bare_image }}
|
||||
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
|
||||
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
OPENCLAW_SKIP_DOCKER_BUILD: "1"
|
||||
INCLUDE_OPENWEBUI: ${{ inputs.include_openwebui }}
|
||||
DOCKER_E2E_LANES: ${{ inputs.docker_lanes }}
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Log in to GHCR for shared Docker E2E image
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Hydrate live auth/profile inputs
|
||||
run: bash scripts/ci-hydrate-live-auth.sh
|
||||
|
||||
- name: Plan and hydrate targeted Docker E2E lanes
|
||||
id: plan
|
||||
uses: ./.github/actions/docker-e2e-plan
|
||||
with:
|
||||
mode: targeted
|
||||
lanes: ${{ inputs.docker_lanes }}
|
||||
include-openwebui: ${{ inputs.include_openwebui }}
|
||||
|
||||
- name: Run targeted Docker E2E lanes
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export OPENCLAW_DOCKER_ALL_LANES="${DOCKER_E2E_LANES}"
|
||||
export OPENCLAW_DOCKER_ALL_PREFLIGHT=0
|
||||
export OPENCLAW_DOCKER_ALL_FAIL_FAST=0
|
||||
export OPENCLAW_DOCKER_ALL_INCLUDE_OPENWEBUI="${INCLUDE_OPENWEBUI}"
|
||||
export OPENCLAW_DOCKER_ALL_LOG_DIR=".artifacts/docker-tests/targeted"
|
||||
export OPENCLAW_DOCKER_ALL_TIMINGS_FILE=".artifacts/docker-tests/targeted-timings.json"
|
||||
export OPENCLAW_DOCKER_ALL_PNPM_COMMAND="$(command -v pnpm)"
|
||||
if [[ "${{ steps.plan.outputs.needs_live_image }}" == "1" ]]; then
|
||||
pnpm test:docker:live-build
|
||||
fi
|
||||
export OPENCLAW_DOCKER_ALL_BUILD=0
|
||||
|
||||
pnpm test:docker:all
|
||||
|
||||
- name: Summarize targeted Docker E2E lanes
|
||||
if: always()
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
summary=".artifacts/docker-tests/targeted/summary.json"
|
||||
if [[ ! -f "$summary" ]]; then
|
||||
echo "Docker targeted summary missing: \`$summary\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
node scripts/docker-e2e.mjs summary "$summary" "Docker E2E targeted lanes" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload targeted Docker E2E artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docker-e2e-targeted
|
||||
path: .artifacts/docker-tests/
|
||||
if-no-files-found: ignore
|
||||
- name: Run ${{ matrix.label }}
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
validate_docker_openwebui:
|
||||
needs: [validate_selected_ref, prepare_docker_e2e_image]
|
||||
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
|
||||
if: inputs.include_openwebui
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENCLAW_DOCKER_E2E_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.image }}
|
||||
OPENCLAW_DOCKER_E2E_FUNCTIONAL_IMAGE: ${{ needs.prepare_docker_e2e_image.outputs.functional_image }}
|
||||
OPENCLAW_SKIP_DOCKER_BUILD: "1"
|
||||
steps:
|
||||
- name: Checkout selected ref
|
||||
@@ -691,7 +586,7 @@ jobs:
|
||||
|
||||
prepare_docker_e2e_image:
|
||||
needs: validate_selected_ref
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
|
||||
if: inputs.include_release_path_suites || inputs.include_openwebui
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
@@ -699,13 +594,6 @@ jobs:
|
||||
packages: write
|
||||
outputs:
|
||||
image: ${{ steps.image.outputs.image }}
|
||||
bare_image: ${{ steps.image.outputs.bare_image }}
|
||||
functional_image: ${{ steps.image.outputs.functional_image }}
|
||||
needs_bare_image: ${{ steps.plan.outputs.needs_bare_image }}
|
||||
needs_e2e_image: ${{ steps.plan.outputs.needs_e2e_image }}
|
||||
needs_functional_image: ${{ steps.plan.outputs.needs_functional_image }}
|
||||
needs_live_image: ${{ steps.plan.outputs.needs_live_image }}
|
||||
needs_package: ${{ steps.plan.outputs.needs_package }}
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: "false"
|
||||
DOCKER_BUILD_RECORD_UPLOAD: "false"
|
||||
@@ -716,7 +604,7 @@ jobs:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_sha }}
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Resolve shared Docker E2E image tags
|
||||
- name: Resolve shared Docker E2E image tag
|
||||
id: image
|
||||
shell: bash
|
||||
env:
|
||||
@@ -724,127 +612,28 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repository="${GITHUB_REPOSITORY,,}"
|
||||
bare_image="ghcr.io/${repository}-docker-e2e-bare:${SELECTED_SHA}"
|
||||
functional_image="ghcr.io/${repository}-docker-e2e-functional:${SELECTED_SHA}"
|
||||
image="$functional_image"
|
||||
image="ghcr.io/${repository}-docker-e2e:${SELECTED_SHA}"
|
||||
echo "image=$image" >> "$GITHUB_OUTPUT"
|
||||
echo "bare_image=$bare_image" >> "$GITHUB_OUTPUT"
|
||||
echo "functional_image=$functional_image" >> "$GITHUB_OUTPUT"
|
||||
echo "Shared Docker E2E bare image: \`$bare_image\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Shared Docker E2E functional image: \`$functional_image\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Plan Docker E2E images
|
||||
id: plan
|
||||
uses: ./.github/actions/docker-e2e-plan
|
||||
with:
|
||||
mode: prepare
|
||||
lanes: ${{ inputs.docker_lanes }}
|
||||
include-release-path-suites: ${{ inputs.include_release_path_suites }}
|
||||
include-openwebui: ${{ inputs.include_openwebui }}
|
||||
hydrate-artifacts: "false"
|
||||
|
||||
- name: Setup Node environment
|
||||
if: steps.plan.outputs.needs_package == '1'
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Pack OpenClaw package for Docker E2E
|
||||
if: steps.plan.outputs.needs_package == '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p .artifacts/docker-e2e-package
|
||||
node scripts/package-openclaw-for-docker.mjs \
|
||||
--output-dir .artifacts/docker-e2e-package \
|
||||
--output-name openclaw-current.tgz
|
||||
|
||||
- name: Upload OpenClaw Docker E2E package
|
||||
if: steps.plan.outputs.needs_package == '1'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docker-e2e-package
|
||||
path: .artifacts/docker-e2e-package/openclaw-current.tgz
|
||||
if-no-files-found: error
|
||||
echo "Shared Docker E2E image: \`$image\`" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: steps.plan.outputs.needs_e2e_image == '1'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Check existing shared Docker E2E images
|
||||
id: image_exists
|
||||
if: steps.plan.outputs.needs_e2e_image == '1'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bare_exists=0
|
||||
functional_exists=0
|
||||
needs_build=0
|
||||
|
||||
if [[ "${{ steps.plan.outputs.needs_bare_image }}" == "1" ]]; then
|
||||
if docker manifest inspect "${{ steps.image.outputs.bare_image }}" >/dev/null 2>&1; then
|
||||
bare_exists=1
|
||||
echo "Shared Docker E2E bare image already exists: ${{ steps.image.outputs.bare_image }}"
|
||||
else
|
||||
needs_build=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${{ steps.plan.outputs.needs_functional_image }}" == "1" ]]; then
|
||||
if docker manifest inspect "${{ steps.image.outputs.functional_image }}" >/dev/null 2>&1; then
|
||||
functional_exists=1
|
||||
echo "Shared Docker E2E functional image already exists: ${{ steps.image.outputs.functional_image }}"
|
||||
else
|
||||
needs_build=1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "bare_exists=$bare_exists" >> "$GITHUB_OUTPUT"
|
||||
echo "functional_exists=$functional_exists" >> "$GITHUB_OUTPUT"
|
||||
echo "needs_build=$needs_build" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Docker builder
|
||||
if: steps.image_exists.outputs.needs_build == '1'
|
||||
uses: useblacksmith/setup-docker-builder@ac083cc84672d01c60d5e8561d0a939b697de542 # v1
|
||||
|
||||
- name: Build and push bare Docker E2E image
|
||||
if: steps.plan.outputs.needs_bare_image == '1' && steps.image_exists.outputs.bare_exists != '1'
|
||||
- name: Build and push shared Docker E2E image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: bare
|
||||
target: build
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-e2e-bare
|
||||
cache-to: type=gha,mode=max,scope=docker-e2e-bare
|
||||
tags: ${{ steps.image.outputs.bare_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
push: true
|
||||
|
||||
- name: Build and push functional Docker E2E image
|
||||
if: steps.plan.outputs.needs_functional_image == '1' && steps.image_exists.outputs.functional_exists != '1'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: ./scripts/e2e/Dockerfile
|
||||
target: functional
|
||||
build-contexts: |
|
||||
openclaw_package=.artifacts/docker-e2e-package
|
||||
platforms: linux/amd64
|
||||
cache-from: |
|
||||
type=gha,scope=docker-e2e-bare
|
||||
type=gha,scope=docker-e2e-functional
|
||||
cache-to: type=gha,mode=max,scope=docker-e2e-functional
|
||||
tags: ${{ steps.image.outputs.functional_image }}
|
||||
sbom: true
|
||||
provenance: mode=max
|
||||
cache-from: type=gha,scope=docker-e2e
|
||||
cache-to: type=gha,mode=max,scope=docker-e2e
|
||||
tags: ${{ steps.image.outputs.image }}
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
validate_live_models_docker:
|
||||
|
||||
15
.github/workflows/openclaw-release-checks.yml
vendored
15
.github/workflows/openclaw-release-checks.yml
vendored
@@ -34,7 +34,6 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.33.0"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
|
||||
|
||||
jobs:
|
||||
resolve_target:
|
||||
@@ -246,13 +245,13 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
- name: Run GPT-5.4 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4-alt \
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
|
||||
@@ -272,7 +271,7 @@ jobs:
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--candidate-label openai/gpt-5.4 \
|
||||
--baseline-label anthropic/claude-opus-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
@@ -342,8 +341,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
@@ -424,8 +423,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci
|
||||
|
||||
9
.github/workflows/parity-gate.yml
vendored
9
.github/workflows/parity-gate.yml
vendored
@@ -24,7 +24,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
parity-gate:
|
||||
name: Run the OpenAI / Opus 4.6 parity gate against the qa-lab mock
|
||||
name: Run the GPT-5.4 / Opus 4.6 parity gate against the qa-lab mock
|
||||
if: ${{ github.event.pull_request.draft != true }}
|
||||
runs-on: blacksmith-32vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
@@ -42,7 +42,6 @@ jobs:
|
||||
# followthrough gate that expects a fast post-approval read within a 30s
|
||||
# agent.wait timeout.
|
||||
QA_PARITY_CONCURRENCY: "1"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
|
||||
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
|
||||
OPENAI_API_KEY: ""
|
||||
ANTHROPIC_API_KEY: ""
|
||||
@@ -76,13 +75,13 @@ jobs:
|
||||
# The approval-turn sentinel still runs inside the full parity pack below.
|
||||
# Keep the exact mock read-plan contract in deterministic unit tests instead
|
||||
# of paying for a separate full-runtime preflight that has been flaky in CI.
|
||||
- name: Run OpenAI candidate lane
|
||||
- name: Run GPT-5.4 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4-alt \
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
|
||||
@@ -102,7 +101,7 @@ jobs:
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--candidate-label openai/gpt-5.4 \
|
||||
--baseline-label anthropic/claude-opus-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
|
||||
15
.github/workflows/qa-live-transports-convex.yml
vendored
15
.github/workflows/qa-live-transports-convex.yml
vendored
@@ -31,7 +31,6 @@ env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.33.0"
|
||||
OPENCLAW_CI_OPENAI_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL }}
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
@@ -157,13 +156,13 @@ jobs:
|
||||
- name: Build private QA runtime
|
||||
run: pnpm build
|
||||
|
||||
- name: Run OpenAI candidate lane
|
||||
- name: Run GPT-5.4 lane
|
||||
run: |
|
||||
pnpm openclaw qa suite \
|
||||
--provider-mode mock-openai \
|
||||
--parity-pack agentic \
|
||||
--concurrency "${QA_PARITY_CONCURRENCY}" \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4-alt \
|
||||
--output-dir .artifacts/qa-e2e/gpt54
|
||||
|
||||
@@ -183,7 +182,7 @@ jobs:
|
||||
--repo-root . \
|
||||
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
|
||||
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
|
||||
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--candidate-label openai/gpt-5.4 \
|
||||
--baseline-label anthropic/claude-opus-4-6 \
|
||||
--output-dir .artifacts/qa-e2e/parity
|
||||
|
||||
@@ -247,8 +246,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast
|
||||
|
||||
- name: Upload Matrix QA artifacts
|
||||
@@ -336,8 +335,8 @@ jobs:
|
||||
--repo-root . \
|
||||
--output-dir "${output_dir}" \
|
||||
--provider-mode live-frontier \
|
||||
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--fast \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
|
||||
102
.github/workflows/stale.yml
vendored
102
.github/workflows/stale.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
- name: Mark stale unassigned issues and pull requests (primary)
|
||||
- name: Mark stale issues and pull requests (primary)
|
||||
id: stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v10
|
||||
@@ -56,60 +56,12 @@ jobs:
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
- name: Mark stale assigned issues (primary)
|
||||
id: assigned-issue-stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 10
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This assigned issue has been automatically marked as stale after 30 days of inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
|
||||
close-issue-reason: not_planned
|
||||
- name: Mark stale assigned pull requests (primary)
|
||||
id: assigned-stale-primary
|
||||
continue-on-error: true
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 27
|
||||
days-before-pr-close: 3
|
||||
stale-pr-label: stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
ignore-pr-updates: true
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-message: |
|
||||
This assigned pull request has been automatically marked as stale after being open for 27 days.
|
||||
Please add updates or it will be closed.
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
- name: Check stale state cache
|
||||
id: stale-state
|
||||
if: always()
|
||||
@@ -134,7 +86,7 @@ jobs:
|
||||
core.warning(`Failed to check stale state cache: ${message}`);
|
||||
core.setOutput("has_state", "false");
|
||||
}
|
||||
- name: Mark stale unassigned issues and pull requests (fallback)
|
||||
- name: Mark stale issues and pull requests (fallback)
|
||||
if: (steps.stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
@@ -160,58 +112,12 @@ jobs:
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh repro steps.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
- name: Mark stale assigned issues (fallback)
|
||||
if: (steps.assigned-issue-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 10
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This assigned issue has been automatically marked as stale after 30 days of inactivity.
|
||||
Please add updates or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this is still an issue, please retry on the latest OpenClaw release and share updated details.
|
||||
If you are absolutely sure it still happens on the latest release, open a new issue with fresh steps to reproduce.
|
||||
close-issue-reason: not_planned
|
||||
- name: Mark stale assigned pull requests (fallback)
|
||||
if: (steps.assigned-stale-primary.outcome == 'failure' || steps.stale-state.outputs.has_state == 'true') && steps.app-token-fallback.outputs.token != ''
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ steps.app-token-fallback.outputs.token }}
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 27
|
||||
days-before-pr-close: 3
|
||||
stale-pr-label: stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
ignore-pr-updates: true
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-message: |
|
||||
This assigned pull request has been automatically marked as stale after being open for 27 days.
|
||||
Please add updates or it will be closed.
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If you believe this PR should be revived, post in #pr-thunderdome-dangerzone on Discord to talk to a maintainer.
|
||||
That channel is the escape hatch for high-quality PRs that get auto-closed.
|
||||
|
||||
lock-closed-issues:
|
||||
permissions:
|
||||
|
||||
5
.github/workflows/test-performance-agent.yml
vendored
5
.github/workflows/test-performance-agent.yml
vendored
@@ -133,7 +133,7 @@ jobs:
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/test-performance-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
model: gpt-5.4
|
||||
effort: high
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
@@ -181,8 +181,7 @@ jobs:
|
||||
|
||||
- name: Restore Node 24 path
|
||||
if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true'
|
||||
run:
|
||||
| # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
|
||||
run: | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
|
||||
set -euo pipefail
|
||||
export PATH="${NODE_BIN}:${PATH}"
|
||||
echo "${NODE_BIN}" >> "$GITHUB_PATH"
|
||||
|
||||
38
.gitignore
vendored
38
.gitignore
vendored
@@ -97,38 +97,6 @@ USER.md
|
||||
# local tooling
|
||||
.serena/
|
||||
|
||||
# Local project-agent skill installs. Only repo-owned skills are visible by
|
||||
# default; promoting a new repo skill should require an intentional `git add -f`.
|
||||
.agents/skills/*
|
||||
!.agents/skills/blacksmith-testbox/
|
||||
!.agents/skills/blacksmith-testbox/**
|
||||
!.agents/skills/openclaw-ghsa-maintainer/
|
||||
!.agents/skills/openclaw-ghsa-maintainer/**
|
||||
!.agents/skills/openclaw-parallels-smoke/
|
||||
!.agents/skills/openclaw-parallels-smoke/**
|
||||
!.agents/skills/openclaw-pr-maintainer/
|
||||
!.agents/skills/openclaw-pr-maintainer/**
|
||||
!.agents/skills/openclaw-qa-testing/
|
||||
!.agents/skills/openclaw-qa-testing/**
|
||||
!.agents/skills/openclaw-release-maintainer/
|
||||
!.agents/skills/openclaw-release-maintainer/**
|
||||
!.agents/skills/openclaw-secret-scanning-maintainer/
|
||||
!.agents/skills/openclaw-secret-scanning-maintainer/**
|
||||
!.agents/skills/openclaw-test-heap-leaks/
|
||||
!.agents/skills/openclaw-test-heap-leaks/**
|
||||
!.agents/skills/openclaw-test-performance/
|
||||
!.agents/skills/openclaw-test-performance/**
|
||||
!.agents/skills/openclaw-testing/
|
||||
!.agents/skills/openclaw-testing/**
|
||||
!.agents/skills/optimizetests/
|
||||
!.agents/skills/optimizetests/**
|
||||
!.agents/skills/parallels-discord-roundtrip/
|
||||
!.agents/skills/parallels-discord-roundtrip/**
|
||||
!.agents/skills/security-triage/
|
||||
!.agents/skills/security-triage/**
|
||||
!.agents/skills/tag-duplicate-prs-issues/
|
||||
!.agents/skills/tag-duplicate-prs-issues/**
|
||||
|
||||
# Agent credentials and memory (NEVER COMMIT)
|
||||
/memory/
|
||||
.agent/*.json
|
||||
@@ -160,14 +128,15 @@ dist/protocol.schema.json
|
||||
# Synthing
|
||||
**/.stfolder/
|
||||
.dev-state
|
||||
docs/superpowers
|
||||
.superpowers/
|
||||
docs/superpowers/plans/2026-03-10-collapsed-side-nav.md
|
||||
docs/superpowers/specs/2026-03-10-collapsed-side-nav-design.md
|
||||
.gitignore
|
||||
test/config-form.analyze.telegram.test.ts
|
||||
ui/src/ui/theme-variants.browser.test.ts
|
||||
ui/src/ui/__screenshots__
|
||||
ui/src/ui/views/__screenshots__
|
||||
ui/.vitest-attachments
|
||||
docs/superpowers
|
||||
|
||||
# Generated docs baseline artifacts (locally generated, only hashes tracked)
|
||||
docs/.generated/*.json
|
||||
@@ -178,7 +147,6 @@ changelog/fragments/
|
||||
|
||||
# Local scratch workspace
|
||||
.tmp/
|
||||
.vmux*
|
||||
.artifacts/
|
||||
test/fixtures/openclaw-vitest-unit-report.json
|
||||
analysis/
|
||||
|
||||
303
AGENTS.md
303
AGENTS.md
@@ -1,185 +1,214 @@
|
||||
# AGENTS.MD
|
||||
|
||||
Telegraph style. Root rules only. Read scoped `AGENTS.md` before subtree work.
|
||||
Telegraph style. Root rules only. Read scoped `AGENTS.md` before touching a subtree.
|
||||
|
||||
## Start
|
||||
|
||||
- Repo: `https://github.com/openclaw/openclaw`
|
||||
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
|
||||
- Run docs list first: `pnpm docs:list` if available; read relevant docs only.
|
||||
- High-confidence answers only when fixing/triaging: verify source, tests, shipped/current behavior, and dependency contracts before deciding.
|
||||
- Dependency-backed behavior: read upstream dependency docs/source/types first. Do not assume APIs, defaults, errors, timing, or runtime behavior.
|
||||
- Live-verify when feasible. Check env/`~/.profile` for keys before assuming live tests are blocked; keep secret output redacted.
|
||||
- Missing deps: `pnpm install`, retry once, then report first actionable error.
|
||||
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
|
||||
- Wording: product/docs/UI/changelog say "plugin/plugins"; `extensions/` is internal.
|
||||
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
|
||||
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink.
|
||||
- Replies: repo-root file refs only, e.g. `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
|
||||
- CODEOWNERS: maintenance/refactors/tests are ok. For larger behavior, product, security, or ownership-sensitive changes, get a listed owner request/review first.
|
||||
- First pass: run docs list (`pnpm docs:list`; ignore if unavailable), then read only relevant docs/guides.
|
||||
- Missing deps: run `pnpm install`, rerun once, then report first actionable error.
|
||||
- Use "plugin/plugins" in docs/UI/changelog. `extensions/` remains internal workspace layout.
|
||||
- Add channel/plugin/app/doc surface: update `.github/labeler.yml` and matching GitHub labels.
|
||||
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink to it.
|
||||
|
||||
## Map
|
||||
## Repo Map
|
||||
|
||||
- Core TS: `src/`, `ui/`, `packages/`; plugins: `extensions/`; SDK: `src/plugin-sdk/*`; channels: `src/channels/*`; loader: `src/plugins/*`; protocol: `src/gateway/protocol/*`; docs/apps: `docs/`, `apps/`, `Swabble/`.
|
||||
- Installers: sibling `../openclaw.ai`.
|
||||
- Scoped guides exist in: `extensions/`, `src/{plugin-sdk,channels,plugins,gateway,gateway/protocol,agents}/`, `test/helpers*/`, `docs/`, `ui/`, `scripts/`.
|
||||
- Core TS: `src/`, `ui/`, `packages/`
|
||||
- Bundled plugins: `extensions/`
|
||||
- Plugin SDK/public contract: `src/plugin-sdk/*`
|
||||
- Core channel internals: `src/channels/*`
|
||||
- Plugin loader/registry/contracts: `src/plugins/*`
|
||||
- Gateway protocol: `src/gateway/protocol/*`
|
||||
- Docs: `docs/`
|
||||
- Apps: `apps/`, `Swabble/`
|
||||
- Installers served from `openclaw.ai`: sibling `../openclaw.ai`
|
||||
|
||||
Scoped guides:
|
||||
|
||||
- `extensions/AGENTS.md`: bundled plugin rules
|
||||
- `src/plugin-sdk/AGENTS.md`: public SDK rules
|
||||
- `src/channels/AGENTS.md`: channel core rules
|
||||
- `src/plugins/AGENTS.md`: plugin loader/registry rules
|
||||
- `src/gateway/AGENTS.md`, `src/gateway/protocol/AGENTS.md`: gateway/protocol rules
|
||||
- `src/agents/AGENTS.md`: agent import/test perf rules
|
||||
- `test/helpers/AGENTS.md`, `test/helpers/channels/AGENTS.md`: shared test helpers
|
||||
- `docs/AGENTS.md`, `ui/AGENTS.md`, `scripts/AGENTS.md`: docs/UI/scripts
|
||||
|
||||
## Architecture
|
||||
|
||||
- Core stays extension-agnostic. No bundled ids in core when manifest/registry/capability contracts work.
|
||||
- Extensions cross into core only via `openclaw/plugin-sdk/*`, manifest metadata, injected runtime helpers, documented barrels (`api.ts`, `runtime-api.ts`).
|
||||
- Extension prod code: no core `src/**`, `src/plugin-sdk-internal/**`, other extension `src/**`, or relative outside package.
|
||||
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use `api.ts`, SDK facade, generic contracts.
|
||||
- Extension-owned behavior stays extension-owned: repair, detection, onboarding, auth/provider defaults, provider tools/settings.
|
||||
- Owner boundary: fix owner-specific behavior in the owner module. Shared/core gets generic seams only; no owner ids, dependency strings, defaults, migrations, or recovery policy. If a bug names an extension or its dependency, start in that extension and add a generic core seam only when multiple owners need it.
|
||||
- Legacy config repair: doctor/fix paths, not startup/load-time core migrations.
|
||||
- Core test asserting extension-specific behavior: move to owner extension or generic contract test.
|
||||
- Core must stay extension-agnostic. No core special cases for bundled plugin/provider/channel ids when manifest/registry/capability contracts can express it.
|
||||
- Extensions cross into core only via `openclaw/plugin-sdk/*`, manifest metadata, injected runtime helpers, and documented local barrels (`api.ts`, `runtime-api.ts`).
|
||||
- Extension production code must not import core `src/**`, `src/plugin-sdk-internal/**`, another extension's `src/**`, or relative paths outside its package.
|
||||
- Core code/tests must not deep-import plugin internals (`extensions/*/src/**`, `onboard.js`). Use plugin `api.ts` / public SDK facade / generic contract.
|
||||
- Extension-owned behavior stays in the extension: legacy repair, detection, onboarding, auth/provider defaults, provider tools/settings.
|
||||
- Legacy config repair: prefer doctor/fix paths over startup/load-time core migrations.
|
||||
- If a core test asserts extension-specific behavior, move it to the owning extension or a generic contract test.
|
||||
- New seams: backwards-compatible, documented, versioned. Third-party plugins exist.
|
||||
- Channels: `src/channels/**` is implementation; plugin authors get SDK seams.
|
||||
- Providers: core owns generic loop; provider plugins own auth/catalog/runtime hooks.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor.
|
||||
- Direction: manifest-first control plane; targeted runtime loaders; no hidden contract bypasses; broad mutable registries transitional.
|
||||
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
|
||||
- Channels: `src/channels/**` is implementation. Plugin authors get SDK seams, not channel internals.
|
||||
- Providers: core owns generic inference loop; provider plugins own provider-specific auth/catalog/runtime hooks.
|
||||
- Gateway protocol changes are contract changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Config contract: keep exported types, schema/help, generated metadata, baselines, docs aligned. Retired public keys stay retired; compatibility belongs in raw migration/doctor paths.
|
||||
- Plugin architecture direction: manifest-first control plane; targeted runtime loaders; no hidden paths around declared contracts; broad mutable registries are transitional.
|
||||
- Prompt-cache rule: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
|
||||
|
||||
## Commands
|
||||
|
||||
- Runtime: Node 22+. Keep Node + Bun paths working.
|
||||
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
|
||||
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
|
||||
- Smart gate: `pnpm check:changed`; explain `pnpm changed:lanes --json`; staged preview `pnpm check:changed --staged`.
|
||||
- Sparse worktrees: `pnpm check:changed` is sparse-safe and may skip sparse-missing typecheck projects; do not expand sparse checkout just to satisfy changed-gate tsgo. Direct `pnpm tsgo*` remains strict; use a fuller worktree when you need direct typecheck proof.
|
||||
- Prod sweep: `pnpm check`; tests: `pnpm test`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`.
|
||||
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
|
||||
- Targeted tests: `pnpm test <path-or-filter> [vitest args...]`; never raw `vitest`.
|
||||
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); do not add `tsc --noEmit`, `typecheck`, `check:types`.
|
||||
- Formatting: use `oxfmt`, not Prettier. Prefer `pnpm format:check` / `pnpm format`; for targeted files use `pnpm exec oxfmt --check --threads=1 <files...>` or `pnpm exec oxfmt --write --threads=1 <files...>`.
|
||||
- Linting: use repo wrappers (`pnpm lint:*`, `scripts/run-oxlint.mjs`); do not invoke generic JS formatters/lints unless a repo script uses them.
|
||||
- Heavy checks: `OPENCLAW_LOCAL_CHECK=1`, mode `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`; CI/shared use `OPENCLAW_LOCAL_CHECK=0`.
|
||||
- Local first. Use repo `pnpm` lanes before Blacksmith/Testbox. Remote only for parity-only failures, secrets/services, or explicit ask.
|
||||
- Runtime: Node 22+. Keep Node and Bun paths working.
|
||||
- Install: `pnpm install` (Bun supported; keep lockfiles/patches aligned if touched).
|
||||
- Dev CLI: `pnpm openclaw ...` or `pnpm dev`.
|
||||
- Build: `pnpm build`
|
||||
- Smart local gate: `pnpm check:changed` (scoped typecheck/lint/guards + relevant tests)
|
||||
- Explain smart gate: `pnpm changed:lanes --json`
|
||||
- Staged gate preview: `pnpm check:changed --staged`
|
||||
- Normal full prod sweep: `pnpm check` (prod typecheck/lint/guards, no tests)
|
||||
- Full tests: `pnpm test`
|
||||
- Changed tests only: `pnpm test:changed`
|
||||
- Local serial loop: `pnpm test:serial`
|
||||
- Extension tests: `pnpm test:extensions` or `pnpm test extensions` = all extension shards; `pnpm test extensions/<id>` = one extension lane. Heavy channels/OpenAI have dedicated shards.
|
||||
- Shard timing artifact: `.artifacts/vitest-shard-timings.json`; auto-used for balanced shard ordering. Disable with `OPENCLAW_TEST_PROJECTS_TIMINGS=0`.
|
||||
- Targeted tests: `pnpm test <path-or-filter> [vitest args...]`; do not call raw `vitest`.
|
||||
- Coverage: `pnpm test:coverage`
|
||||
- Format check/fix: `pnpm format:check` / `pnpm format`
|
||||
- Typecheck:
|
||||
- `pnpm tsgo`: fastest core prod graph
|
||||
- `pnpm tsgo:prod`: core + extensions prod graphs; used by `pnpm check`
|
||||
- `pnpm check:test-types` / `pnpm tsgo:test`: all test graphs
|
||||
- `pnpm tsgo:all`: all prod + test project refs
|
||||
- Debug slices exist; do not present as normal user flow.
|
||||
- Profile: `pnpm tsgo:profile [core-test|extensions-test|--all]`
|
||||
- Type policy: use `tsgo`; do not add `tsc --noEmit`, `typecheck`, or `check:types` lanes. `tsc` only for declaration/package-boundary emit gaps.
|
||||
- Lint:
|
||||
- `pnpm lint`: core/extensions/scripts shards
|
||||
- `pnpm lint:core`, `pnpm lint:extensions`, `pnpm lint:scripts`
|
||||
- `pnpm lint:apps`: Swift/app surface, separate from TS lint
|
||||
- `pnpm lint:all`: legacy comparison lane
|
||||
- Local heavy-check behavior: `OPENCLAW_LOCAL_CHECK=1` default; `OPENCLAW_LOCAL_CHECK_MODE=throttled|full`; `OPENCLAW_LOCAL_CHECK=0` for CI/shared runs.
|
||||
- Local validation is local-first. Do not default to Blacksmith/Testbox for routine OpenClaw iteration; it burns warm caches and startup time. Use repo `pnpm` lanes first, then reach for remote CI/Testbox only for parity-only failures, secrets/services, or when explicitly requested.
|
||||
|
||||
## GitHub / CI
|
||||
## GitHub API
|
||||
|
||||
- Triage: list first, hydrate few. Use bounded `gh --json --jq`; avoid repeated full comment scans.
|
||||
- Automatic PR/issue discovery: skip maintainer-owned items unless directly relevant. Do not comment, close, label, retitle, rebase, fix up, or land them without Peter asking.
|
||||
- PR scan/triage: no unsolicited PR comments/reviews. Report in chat only unless explicitly asked, or a close/duplicate action needs a reason comment.
|
||||
- Search/dedupe: prefer `gh search issues 'repo:openclaw/openclaw is:open <terms>' --json number,title,state,updatedAt --limit 20`.
|
||||
- GitHub search boolean text is fussy. If `OR` queries return empty, split exact terms and search title/body/comments separately before concluding no hits.
|
||||
- PR shortlist: `gh pr list ...`; then `gh pr view <n> --json number,title,body,closingIssuesReferences,files,statusCheckRollup,reviewDecision`.
|
||||
- After landing PR: search duplicate open issues/PRs. Before closing: comment why + canonical link.
|
||||
- GH comments with markdown backticks, `$`, or shell snippets: avoid inline double-quoted `--body`; use single quotes or `--body-file`.
|
||||
- PR execution artifacts/screenshots: attach them to the PR, comment, or an external artifact store. Do not add `.github/pr-assets` or other PR-only assets to the repo.
|
||||
- PR review answer must explicitly cover: what bug/behavior we are trying to fix; PR/issue URL(s) and affected endpoint/surface; whether this is the best possible fix, with high-certainty evidence from code, tests, CI, and shipped/current behavior.
|
||||
- CI polling: exact SHA, needed fields only. Example: `gh api repos/<owner>/<repo>/actions/runs/<id> --jq '{status,conclusion,head_sha,updated_at,name,path}'`.
|
||||
- Post-land wait: minimal. Exact landed SHA only. If superseded on `main`, same-branch `cancel-in-progress` cancellations are expected; stop once local touched-surface proof exists. Never wait for newer unrelated `main` unless asked.
|
||||
- Wait matrix:
|
||||
- never: `Auto response`, `Labeler`, `Docs Sync Publish Repo`, `Docs Agent`, `Test Performance Agent`, `Stale`.
|
||||
- conditional: `CI` exact SHA only; `Docs` only docs task/no local docs proof; `Workflow Sanity` only workflow/composite/CI-policy edits; `Plugin NPM Release` only plugin package/release metadata.
|
||||
- release/manual only: `Docker Release`, `OpenClaw NPM Release`, `macOS Release`, `OpenClaw Release Checks`, `Cross-OS Release Checks`, `NPM Telegram Beta E2E`.
|
||||
- explicit/surface only: `QA-Lab - All Lanes`, `Scheduled Live And E2E`, `Install Smoke`, `CodeQL`, `Sandbox Common Smoke`, `Parity gate`, `Blacksmith Testbox`, `Control UI Locale Refresh`.
|
||||
- `/landpr`: do not idle on `auto-response` or `check-docs`. Treat docs as local proof unless `check-docs` already failed with actionable relevant error.
|
||||
- Poll 30-60s. Fetch jobs/logs/artifacts only after failure/completion or concrete need.
|
||||
- Issue/PR triage: list first, hydrate few. Use bounded fields + `--jq`, e.g. `gh issue list --state open --limit 80 --json number,title,labels,updatedAt,comments --jq '.[]|[.number,.updatedAt,.comments,.title]|@tsv'`; then `gh issue view <n> --json title,body,comments,labels,createdAt,updatedAt,url --jq '{title,labels,createdAt,updatedAt,url,body,comments:[.comments[]|{author:.author.login,createdAt,body}]}'` only for shortlisted items.
|
||||
- Search/dedupe: prefer `gh search issues 'repo:openclaw/openclaw is:open <terms>' --json number,title,state,updatedAt --limit 20 --jq '.[]|[.number,.updatedAt,.title]|@tsv'`; avoid repeated full `--comments` scans.
|
||||
- After landing a PR: search for duplicate open issues/PRs that can be closed.
|
||||
- Before closing an issue/PR: add a comment explaining why, usually duplicate/invalid, with the canonical issue/PR when relevant.
|
||||
- PR links: `gh pr list --state open --search '<issue-or-terms>' --json number,title,updatedAt,headRefName --limit 20`; use `gh pr view <n> --json number,title,body,closingIssuesReferences,files,statusCheckRollup,reviewDecision` only after shortlist.
|
||||
- CI polling: keep full `gh` capability, but request only needed fields. Known run status: `gh api repos/<owner>/<repo>/actions/runs/<id> --jq '{status,conclusion,head_sha,updated_at,name,path}'`.
|
||||
- Non-blocking background workflows: `Auto response`, `Docs Sync Publish Repo`, `Docs Agent`, and `Test Performance Agent` are service/agent work. Do not wait on, rerun, or fix them during normal push/PR verification unless the user explicitly asks or the task is about those workflows. Report them as background if mentioned.
|
||||
- `/landpr` CI wait scope: do not idle on pending `auto-response`/`Auto response` or `check-docs`. Treat docs as local proof unless `check-docs` already failed with a relevant, actionable error. If required product/code gates and touched-surface local gates are green, proceed without waiting for docs-only or auto-response automation.
|
||||
- Waiting: poll lightly, usually 30-60s backoff. Fetch jobs/logs/artifacts only after completion/failure or when job detail is needed; avoid repeated workflow + run + jobs loops.
|
||||
|
||||
## Gates
|
||||
|
||||
- Pre-commit hook: staged formatting only. Validation explicit.
|
||||
- Pre-commit hook: staged formatting only. It does not run lint, typecheck, or tests.
|
||||
- Changed lanes:
|
||||
- core prod: core prod typecheck + core tests
|
||||
- core tests: core test typecheck/tests
|
||||
- extension prod: extension prod typecheck + extension tests
|
||||
- extension tests: extension test typecheck/tests
|
||||
- public SDK/plugin contract: extension prod/test too
|
||||
- unknown root/config: all lanes
|
||||
- Before handoff/push for code/test/runtime/config changes: `pnpm check:changed`. Tests-only: `pnpm test:changed`. Full prod sweep: `pnpm check`.
|
||||
- Docs/changelog-only and CI/workflow metadata-only changes are not changed-gate work by default. Use `git diff --check` plus the relevant formatter/docs/workflow sanity check; escalate to `pnpm check:changed` only when scripts, test config, generated docs/API, package metadata, or runtime/build behavior changed.
|
||||
- Rebase sanity: after a green `pnpm check:changed`, a clean rebase onto current
|
||||
`origin/main` does not require rerunning the full changed gate when the rebase
|
||||
has no conflicts and the branch diff is materially unchanged. Do a quick
|
||||
`git status`, `git diff --check`, and diff/stat sanity check; rerun targeted or
|
||||
full checks only if conflict resolution, upstream overlap, generated drift,
|
||||
dependency/config changes, or touched-file content changes make the prior
|
||||
result stale.
|
||||
- Landing on `main`: verify touched surface near landing. Default feasible bar: `pnpm check` + `pnpm test`.
|
||||
- Hard build gate: `pnpm build` before push if build output, packaging, lazy/module boundaries, or published surfaces can change.
|
||||
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
|
||||
- Generated/API drift: `pnpm check:architecture`, `pnpm config:docs:gen/check`, `pnpm plugin-sdk:api:gen/check`. Track `docs/.generated/*.sha256`; full JSON ignored.
|
||||
- core prod => core prod typecheck + core tests
|
||||
- core tests => core test typecheck/tests only
|
||||
- extension prod => extension prod typecheck + extension tests
|
||||
- extension tests => extension test typecheck/tests only
|
||||
- public SDK/plugin contract => extension prod/test validation too
|
||||
- unknown root/config => all lanes
|
||||
- Local loop: run `pnpm check:changed` explicitly before handoff/push; use `pnpm test:changed` for tests only; use `pnpm check` for full prod TS/lint sweep without tests.
|
||||
- Landing on `main`: verify touched surface near landing; default bar is `pnpm check` + `pnpm test` when feasible.
|
||||
- Hard build gate: run/pass `pnpm build` before push if build output, packaging, lazy/module boundaries, or published surfaces can change.
|
||||
- Do not land related failing format/lint/type/build/tests. If failures are unrelated on latest `origin/main`, say so and give scoped proof.
|
||||
- Commit helper is formatting-only; validation gates are explicit commands, not commit side effects.
|
||||
- CI architecture gate: `check-additional`; local equivalent `pnpm check:architecture`.
|
||||
- Config docs drift: `pnpm config:docs:gen/check`
|
||||
- Plugin SDK API drift: `pnpm plugin-sdk:api:gen/check`
|
||||
- Generated docs baselines: tracked `docs/.generated/*.sha256`; full JSON ignored.
|
||||
|
||||
## Code
|
||||
## Code Style
|
||||
|
||||
- TS ESM, strict. Avoid `any`; prefer real types, `unknown`, narrow adapters.
|
||||
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
|
||||
- TypeScript ESM. Strict types. Avoid `any`; prefer real types/`unknown`/narrow adapters.
|
||||
- No `@ts-nocheck`. No lint suppressions unless intentional and explained.
|
||||
- External boundaries: prefer `zod` or existing schema helpers.
|
||||
- Runtime branching: discriminated unions/closed codes over freeform strings.
|
||||
- Avoid semantic sentinels: `?? 0`, empty object/string, etc.
|
||||
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
|
||||
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
|
||||
- Comments: brief, only non-obvious logic.
|
||||
- Split files around ~700 LOC when clarity/testability improves.
|
||||
- Naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
|
||||
- English: American spelling.
|
||||
- Runtime branching: prefer discriminated unions / closed codes over freeform strings.
|
||||
- Avoid magic sentinels like `?? 0`, empty object/string when semantics change.
|
||||
- Dynamic import: do not mix static and dynamic import for same module in prod path. Use dedicated `*.runtime.ts` lazy boundary. After lazy-boundary edits, run `pnpm build` and check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
|
||||
- Cycles: keep `pnpm check:import-cycles` and architecture/madge cycle checks green.
|
||||
- Classes: no prototype mixins/mutations. Use explicit inheritance/composition. Tests prefer per-instance stubs.
|
||||
- Comments: brief only for non-obvious logic.
|
||||
- File size: split around ~700 LOC when it improves clarity/testability.
|
||||
- Product naming: **OpenClaw** product/docs; `openclaw` CLI/package/path/config.
|
||||
- Written English: American spelling.
|
||||
|
||||
## Tests
|
||||
|
||||
- Vitest. Colocated `*.test.ts`; e2e `*.e2e.test.ts`; example models `sonnet-4.6`, `gpt-5.4`.
|
||||
- Avoid brittle tests that grep workflow/docs strings for operator policy. Prefer executable behavior, parsed config/schema checks, or live run proof; put release/CI policy reminders in AGENTS/docs instead.
|
||||
- Clean timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` safe.
|
||||
- Hot tests: avoid per-test `vi.resetModules()` + heavy imports. Measure with `pnpm test:perf:imports <file>` / `pnpm test:perf:hotspots --limit N`.
|
||||
- Seam depth: pure helper/contract unit tests; one integration smoke per boundary.
|
||||
- Mock expensive seams directly: scanners, manifests, registries, fs crawls, provider SDKs, network/process launch.
|
||||
- Prefer injection; if module mocking, mock narrow local `*.runtime.ts`, not broad barrels or `openclaw/plugin-sdk/*`.
|
||||
- Share fixtures/builders; delete duplicate assertions; assert behavior that can regress here.
|
||||
- Do not edit baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
|
||||
- Test workers max 16. Memory pressure: `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
|
||||
- Live: `OPENCLAW_LIVE_TEST=1 pnpm test:live`; verbose `OPENCLAW_LIVE_TEST_QUIET=0`.
|
||||
- Guide: `docs/help/testing.md`.
|
||||
- Vitest. Tests colocated `*.test.ts`; e2e `*.e2e.test.ts`.
|
||||
- Example models in tests: `sonnet-4.6`, `gpt-5.4`.
|
||||
- Clean up timers/env/globals/mocks/sockets/temp dirs/module state; `--isolate=false` must stay safe.
|
||||
- Hot tests: avoid per-test `vi.resetModules()` + fresh heavy imports; prefer static or `beforeAll` imports and reset state directly.
|
||||
- Measure first: `pnpm test:perf:imports <file>` for import drag; `pnpm test:perf:hotspots --limit N` for suite targets.
|
||||
- Keep tests at seam depth: unit-test pure helpers/contracts; one integration smoke per boundary, not per branch.
|
||||
- Mock expensive runtime seams directly: scanners, manifests, package registries, filesystem crawls, provider SDKs, network/process launch.
|
||||
- Prefer injected deps over module mocks; if mocking modules, mock narrow local `*.runtime.ts` seams, not broad barrels.
|
||||
- Share fixtures/builders; do not recreate temp dirs, package manifests, or plugin workspaces in every case unless state isolation needs it.
|
||||
- Delete duplicate assertions when another test owns the boundary; assert only the behavior that can regress here.
|
||||
- Avoid broad `importOriginal()` / broad `openclaw/plugin-sdk/*` partial mocks in hot tests. Add narrow local `*.runtime.ts` seam and mock it.
|
||||
- Use existing deps/callback/runtime injection seams before module mocks.
|
||||
- Import-dominated test time is a boundary smell; shrink import surface before adding cases.
|
||||
- Replacing slow integration coverage: extract production composition into a named helper and test that helper.
|
||||
- Do not modify baseline/inventory/ignore/snapshot/expected-failure files to silence checks without explicit approval.
|
||||
- Do not set test workers above 16. For memory pressure: `OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test`.
|
||||
- Live: `OPENCLAW_LIVE_TEST=1 pnpm test:live`; full logs `OPENCLAW_LIVE_TEST_QUIET=0`.
|
||||
- Full testing guide: `docs/help/testing.md`.
|
||||
|
||||
## Docs / Changelog
|
||||
|
||||
- Docs change with behavior/API. Use docs list/read_when hints; docs links per `docs/AGENTS.md`.
|
||||
- Changelog user-facing only; pure test/internal usually no entry.
|
||||
- Changelog placement: active version `### Changes`/`### Fixes`; every added entry must include at least one `Thanks @author` attribution, using credited GitHub username(s). Never add `Thanks @steipete` or `Thanks @codex`.
|
||||
- Changelog bullets are always single-line. No wrapping/continuation across multiple lines. Long entries stay on one long line so dedupe, PR-ref, and credit-audit tooling work and so the visual style stays uniform.
|
||||
- Update docs when behavior/API changes. Use docs list/read_when hints.
|
||||
- Docs links: see `docs/AGENTS.md`.
|
||||
- Changelog: user-facing only. Pure test/internal changes usually no entry.
|
||||
- Changelog placement: append to active version `### Changes`/`### Fixes`; at most one contributor mention, prefer `Thanks @user`.
|
||||
|
||||
## Git
|
||||
|
||||
- Commit via `scripts/committer "<msg>" <file...>`; stage intended files only. It formats staged files; still run gates.
|
||||
- Commits: conventional-ish, concise, grouped.
|
||||
- No manual stash/autostash unless explicit. No branch/worktree changes unless requested.
|
||||
- `main`: no merge commits; rebase on latest `origin/main` before push. Do not
|
||||
keep chasing `main` with repeated full gates after one green run plus a clean
|
||||
rebase sanity pass.
|
||||
- User says `commit`: your changes only. `commit all`: all changes in grouped chunks. `push`: may `git pull --rebase` first.
|
||||
- Do not delete/rename unexpected files; ask if blocking, else ignore.
|
||||
- Bulk PR close/reopen >5: ask with count/scope.
|
||||
- PR/issue workflows: `$openclaw-pr-maintainer`. `/landpr`: `~/.codex/prompts/landpr.md`.
|
||||
- Use `scripts/committer "<msg>" <file...>`; stage only intended files. It formats staged files only; run validation separately.
|
||||
- Commits: conventional-ish, concise/action-oriented. Group related changes.
|
||||
- No manual stash/autostash unless explicitly requested. No branch/worktree changes unless requested.
|
||||
- No merge commits on `main`; rebase on latest `origin/main` before push.
|
||||
- User says "commit": commit your changes only. "commit all": commit everything in grouped chunks. "push": may `git pull --rebase` first.
|
||||
- Do not delete/rename unexpected files; ask if it blocks. Otherwise ignore unrelated WIP.
|
||||
- If bulk PR close/reopen affects >5 PRs, ask with exact count/scope.
|
||||
- PR/issue workflows: use `$openclaw-pr-maintainer`.
|
||||
- `/landpr`: use `~/.codex/prompts/landpr.md`.
|
||||
|
||||
## Security / Release
|
||||
|
||||
- Never commit real phone numbers, videos, credentials, live config.
|
||||
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
|
||||
- Secrets: channel/provider credentials under `~/.openclaw/credentials/`; model auth profiles under `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
|
||||
- Env keys: check `~/.profile`.
|
||||
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm.patchedDependencies` exact versions only.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps need explicit approval. Release docs: `docs/reference/RELEASING.md`; use `$openclaw-release-maintainer`.
|
||||
- GHSA/advisories: `$openclaw-ghsa-maintainer`.
|
||||
- Beta tag/version match: `vYYYY.M.D-beta.N` -> npm `YYYY.M.D-beta.N --tag beta`.
|
||||
- Dependency patches/overrides/vendor changes require explicit approval. `pnpm.patchedDependencies` must use exact versions.
|
||||
- Carbon pins owner-only: do not change `@buape/carbon` versions unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
|
||||
- Releases/publish/version bumps require explicit approval.
|
||||
- Release docs: `docs/reference/RELEASING.md`; use `$openclaw-release-maintainer`.
|
||||
- GHSA/advisories: use `$openclaw-ghsa-maintainer`.
|
||||
- Beta tag/version must match, e.g. `vYYYY.M.D-beta.N` => npm `YYYY.M.D-beta.N --tag beta`.
|
||||
|
||||
## Apps / Platform
|
||||
|
||||
- Before simulator/emulator testing, check real iOS/Android devices.
|
||||
- Before simulator/emulator testing, check connected real iOS/Android devices first.
|
||||
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
|
||||
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- Mac gateway: use app or `openclaw gateway restart/status --deep`; no ad-hoc tmux gateway. Logs: `./scripts/clawlog.sh`.
|
||||
- Version bump touches: `package.json`, `apps/android/app/build.gradle.kts`, `apps/ios/version.json` + `pnpm ios:version:sync`, macOS `Info.plist`, `docs/install/updating.md`. Appcast only for Sparkle release.
|
||||
- Mobile LAN pairing: plaintext `ws://` loopback-only. Private-network `ws://` needs `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; Tailscale/public use `wss://` or tunnel.
|
||||
- SwiftUI: prefer Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
|
||||
- mac gateway: use app or `openclaw gateway restart/status --deep`; avoid ad-hoc tmux gateway sessions. Rebuild mac app locally, not over SSH.
|
||||
- mac logs: `./scripts/clawlog.sh`.
|
||||
- Version bump touches: `package.json`, `apps/android/app/build.gradle.kts`, `apps/ios/version.json` then `pnpm ios:version:sync`, `apps/macos/.../Info.plist`, `docs/install/updating.md`. Appcast only for Sparkle release.
|
||||
- iOS Team ID: `security find-identity -p codesigning -v`; fallback `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- Mobile LAN pairing: plaintext `ws://` is loopback-only by default. Trusted private-network `ws://` needs `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1`; Tailscale/public use `wss://` or a tunnel.
|
||||
- A2UI hash `src/canvas-host/a2ui/.bundle.hash`: generated; ignore unless running `pnpm canvas:a2ui:bundle`; commit separately.
|
||||
|
||||
## Ops / Footguns
|
||||
## External Ops
|
||||
|
||||
- Remote install docs: `docs/install/exe-dev.md`, `docs/install/fly.md`, `docs/install/hetzner.md`.
|
||||
- Parallels smoke: `$openclaw-parallels-smoke`; Discord roundtrip: `parallels-discord-roundtrip`.
|
||||
|
||||
## Misc Footguns
|
||||
|
||||
- Remote install docs: `docs/install/{exe-dev,fly,hetzner}.md`. Parallels smoke: `$openclaw-parallels-smoke`; Discord roundtrip: `parallels-discord-roundtrip`.
|
||||
- Rebrand/migration/config warnings: run `openclaw doctor`.
|
||||
- Never edit `node_modules`.
|
||||
- Local-only `.agents` ignores: `.git/info/exclude`, not repo `.gitignore`.
|
||||
- CLI progress: `src/cli/progress.ts`; status tables: `src/terminal/table.ts`.
|
||||
- Local-only `.agents` ignores: use `.git/info/exclude`, not repo `.gitignore`.
|
||||
- CLI progress: use `src/cli/progress.ts`; status tables: `src/terminal/table.ts`.
|
||||
- Connection/provider additions: update all UI surfaces + docs + status/config forms.
|
||||
- Provider tool schemas: prefer flat string enum helpers over `Type.Union([Type.Literal(...)])`; some providers reject `anyOf`. Not a repo-wide protocol/schema ban.
|
||||
- External messaging: no token-delta channel messages. Follow `docs/concepts/streaming.md`; preview/block streaming uses edits/chunks and preserves final/fallback delivery.
|
||||
- Provider-facing tool schemas: prefer flat string enum helpers over `Type.Union([Type.Literal(...)])`; some providers reject generated `anyOf`. Do not treat this as a repo-wide protocol/schema ban.
|
||||
- External messaging surfaces: no token-delta channel messages. Follow `docs/concepts/streaming.md`; preview/block streaming uses message edits/chunks and must preserve final/fallback delivery.
|
||||
|
||||
2039
CHANGELOG.md
2039
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
41
Dockerfile
41
Dockerfile
@@ -9,26 +9,29 @@
|
||||
# bundled plugin workspace tree, so the main build layer is not invalidated by
|
||||
# unrelated plugin source changes.
|
||||
#
|
||||
# Build stages use full bookworm; the runtime image is always bookworm-slim.
|
||||
# Two runtime variants:
|
||||
# Default (bookworm): docker build .
|
||||
# Slim (bookworm-slim): docker build --build-arg OPENCLAW_VARIANT=slim .
|
||||
ARG OPENCLAW_EXTENSIONS=""
|
||||
ARG OPENCLAW_VARIANT=default
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR=extensions
|
||||
ARG OPENCLAW_DOCKER_APT_UPGRADE=1
|
||||
ARG OPENCLAW_NODE_BOOKWORM_IMAGE="node:24-bookworm@sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST="sha256:3a09aa6354567619221ef6c45a5051b671f953f0a1924d1f819ffb236e520e6b"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE="node:24-bookworm-slim@sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST="sha256:e8e2e91b1378f83c5b2dd15f0247f34110e2fe895f6ca7719dbb780f929368eb"
|
||||
|
||||
# Base images are pinned to SHA256 digests for reproducible builds.
|
||||
# Dependabot refreshes these blessed digests; release builds consume the
|
||||
# reviewed base snapshot instead of mutating distro state on every build.
|
||||
# To update, run: docker buildx imagetools inspect node:24-bookworm and
|
||||
# node:24-bookworm-slim (or podman) and replace the digests below with the
|
||||
# current multi-arch manifest list entries.
|
||||
# Trade-off: digests must be updated manually when upstream tags move.
|
||||
# To update, run: docker buildx imagetools inspect node:24-bookworm (or podman)
|
||||
# and replace the digest below with the current multi-arch manifest list entry.
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS ext-deps
|
||||
ARG OPENCLAW_EXTENSIONS
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
COPY ${OPENCLAW_BUNDLED_PLUGIN_DIR} /tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
# Copy package.json for opted-in extensions so pnpm resolves their deps.
|
||||
RUN --mount=type=bind,source=${OPENCLAW_BUNDLED_PLUGIN_DIR},target=/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR},readonly \
|
||||
mkdir -p /out && \
|
||||
RUN mkdir -p /out && \
|
||||
for ext in $OPENCLAW_EXTENSIONS; do \
|
||||
if [ -f "/tmp/${OPENCLAW_BUNDLED_PLUGIN_DIR}/$ext/package.json" ]; then \
|
||||
mkdir -p "/out/$ext" && \
|
||||
@@ -122,15 +125,22 @@ RUN printf 'packages:\n - .\n - ui\n' > /tmp/pnpm-workspace.runtime.yaml && \
|
||||
node scripts/postinstall-bundled-plugins.mjs && \
|
||||
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete
|
||||
|
||||
# ── Runtime base image ──────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-runtime
|
||||
# ── Runtime base images ─────────────────────────────────────────
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_IMAGE} AS base-default
|
||||
ARG OPENCLAW_NODE_BOOKWORM_DIGEST
|
||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm" \
|
||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_DIGEST}"
|
||||
|
||||
FROM ${OPENCLAW_NODE_BOOKWORM_SLIM_IMAGE} AS base-slim
|
||||
ARG OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST
|
||||
LABEL org.opencontainers.image.base.name="docker.io/library/node:24-bookworm-slim" \
|
||||
org.opencontainers.image.base.digest="${OPENCLAW_NODE_BOOKWORM_SLIM_DIGEST}"
|
||||
|
||||
# ── Stage 3: Runtime ────────────────────────────────────────────
|
||||
FROM base-runtime
|
||||
FROM base-${OPENCLAW_VARIANT}
|
||||
ARG OPENCLAW_VARIANT
|
||||
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
|
||||
ARG OPENCLAW_DOCKER_APT_UPGRADE
|
||||
|
||||
# OCI base-image metadata for downstream image consumers.
|
||||
# If you change these annotations, also update:
|
||||
@@ -145,10 +155,16 @@ LABEL org.opencontainers.image.source="https://github.com/openclaw/openclaw" \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime system utilities missing from bookworm-slim.
|
||||
# Install system utilities present in bookworm but missing in bookworm-slim.
|
||||
# On the full bookworm image these are already installed (apt-get is a no-op).
|
||||
# Smoke workflows can opt out of distro upgrades to cut repeated CI time while
|
||||
# keeping the default runtime image behavior unchanged.
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
if [ "${OPENCLAW_DOCKER_APT_UPGRADE}" != "0" ]; then \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends; \
|
||||
fi && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git lsof openssl
|
||||
|
||||
@@ -157,7 +173,6 @@ RUN chown node:node /app
|
||||
COPY --from=runtime-assets --chown=node:node /app/dist ./dist
|
||||
COPY --from=runtime-assets --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=runtime-assets --chown=node:node /app/package.json .
|
||||
COPY --from=runtime-assets --chown=node:node /app/patches ./patches
|
||||
COPY --from=runtime-assets --chown=node:node /app/openclaw.mjs .
|
||||
COPY --from=runtime-assets --chown=node:node /app/${OPENCLAW_BUNDLED_PLUGIN_DIR} ./${OPENCLAW_BUNDLED_PLUGIN_DIR}
|
||||
COPY --from=runtime-assets --chown=node:node /app/skills ./skills
|
||||
|
||||
@@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
|
||||
@@ -7,6 +7,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
|
||||
@@ -24,6 +24,7 @@ ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
|
||||
|
||||
## Install (recommended)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.14+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
```bash
|
||||
npm install -g openclaw@latest
|
||||
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
|
||||
|
||||
## Quick start (TL;DR)
|
||||
|
||||
Runtime: **Node 24 (recommended) or Node 22.14+**.
|
||||
Runtime: **Node 24 (recommended) or Node 22.16+**.
|
||||
|
||||
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)
|
||||
|
||||
@@ -119,7 +119,7 @@ openclaw onboard --install-daemon
|
||||
openclaw gateway --port 18789 --verbose
|
||||
|
||||
# Send a message
|
||||
openclaw message send --target +1234567890 --message "Hello from OpenClaw"
|
||||
openclaw message send --to +1234567890 --message "Hello from OpenClaw"
|
||||
|
||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/IRC/Microsoft Teams/Matrix/Feishu/LINE/Mattermost/Nextcloud Talk/Nostr/Synology Chat/Tlon/Twitch/Zalo/Zalo Personal/WeChat/QQ/WebChat)
|
||||
openclaw agent --message "Ship checklist" --thinking high
|
||||
|
||||
@@ -288,7 +288,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
|
||||
|
||||
### Node.js Version
|
||||
|
||||
OpenClaw requires **Node.js 22.14.0 or later** (LTS). This version includes important security patches:
|
||||
OpenClaw requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches:
|
||||
|
||||
- CVE-2025-59466: async_hooks DoS vulnerability
|
||||
- CVE-2026-21636: Permission model bypass vulnerability
|
||||
@@ -296,7 +296,7 @@ OpenClaw requires **Node.js 22.14.0 or later** (LTS). This version includes impo
|
||||
Verify your Node.js version:
|
||||
|
||||
```bash
|
||||
node --version # Should be v22.14.0 or later
|
||||
node --version # Should be v22.12.0 or later
|
||||
```
|
||||
|
||||
### Docker Security
|
||||
|
||||
@@ -35,18 +35,11 @@ public struct WakeWordGateMatch: Sendable, Equatable {
|
||||
public let triggerEndTime: TimeInterval
|
||||
public let postGap: TimeInterval
|
||||
public let command: String
|
||||
public let trigger: String?
|
||||
|
||||
public init(
|
||||
triggerEndTime: TimeInterval,
|
||||
postGap: TimeInterval,
|
||||
command: String,
|
||||
trigger: String? = nil)
|
||||
{
|
||||
public init(triggerEndTime: TimeInterval, postGap: TimeInterval, command: String) {
|
||||
self.triggerEndTime = triggerEndTime
|
||||
self.postGap = postGap
|
||||
self.command = command
|
||||
self.trigger = trigger
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,17 +53,13 @@ public enum WakeWordGate {
|
||||
}
|
||||
|
||||
private struct TriggerTokens {
|
||||
let source: String
|
||||
let tokens: [String]
|
||||
}
|
||||
|
||||
private struct MatchCandidate {
|
||||
let index: Int
|
||||
let endIndex: Int
|
||||
let tokenCount: Int
|
||||
let triggerEnd: TimeInterval
|
||||
let gap: TimeInterval
|
||||
let trigger: String
|
||||
}
|
||||
|
||||
public static func match(
|
||||
@@ -98,19 +87,9 @@ public enum WakeWordGate {
|
||||
let gap = nextToken.start - triggerEnd
|
||||
if gap < config.minPostTriggerGap { continue }
|
||||
|
||||
let endIndex = i + count - 1
|
||||
if let best {
|
||||
if endIndex < best.endIndex { continue }
|
||||
if endIndex == best.endIndex, count <= best.tokenCount { continue }
|
||||
}
|
||||
if let best, i <= best.index { continue }
|
||||
|
||||
best = MatchCandidate(
|
||||
index: i,
|
||||
endIndex: endIndex,
|
||||
tokenCount: count,
|
||||
triggerEnd: triggerEnd,
|
||||
gap: gap,
|
||||
trigger: trigger.source)
|
||||
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +97,7 @@ public enum WakeWordGate {
|
||||
let command = commandText(transcript: transcript, segments: segments, triggerEndTime: best.triggerEnd)
|
||||
.trimmingCharacters(in: Self.whitespaceAndPunctuation)
|
||||
guard command.count >= config.minCommandLength else { return nil }
|
||||
return WakeWordGateMatch(
|
||||
triggerEndTime: best.triggerEnd,
|
||||
postGap: best.gap,
|
||||
command: command,
|
||||
trigger: best.trigger)
|
||||
return WakeWordGateMatch(triggerEndTime: best.triggerEnd, postGap: best.gap, command: command)
|
||||
}
|
||||
|
||||
public static func commandText(
|
||||
@@ -170,7 +145,7 @@ public enum WakeWordGate {
|
||||
.map { normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
if tokens.isEmpty { continue }
|
||||
output.append(TriggerTokens(source: tokens.joined(separator: " "), tokens: tokens))
|
||||
output.append(TriggerTokens(tokens: tokens))
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -47,21 +47,6 @@ import Testing
|
||||
#expect(match?.command == "do it")
|
||||
}
|
||||
|
||||
@Test func matchPrefersMostSpecificTriggerWhenOverlapping() {
|
||||
let transcript = "hey clawd do it"
|
||||
let segments = makeSegments(
|
||||
transcript: transcript,
|
||||
words: [
|
||||
("hey", 0.0, 0.1),
|
||||
("clawd", 0.2, 0.1),
|
||||
("do", 0.8, 0.1),
|
||||
("it", 1.0, 0.1),
|
||||
])
|
||||
let config = WakeWordGateConfig(triggers: ["clawd", "hey clawd"], minPostTriggerGap: 0.3)
|
||||
let match = WakeWordGate.match(transcript: transcript, segments: segments, config: config)
|
||||
#expect(match?.trigger == "hey clawd")
|
||||
}
|
||||
|
||||
@Test func commandTextHandlesForeignRangeIndices() {
|
||||
let transcript = "hey clawd do thing"
|
||||
let other = "do thing"
|
||||
|
||||
35
VISION.md
35
VISION.md
@@ -53,24 +53,12 @@ We prioritize secure defaults, but also expose clear knobs for trusted high-powe
|
||||
|
||||
OpenClaw has an extensive plugin API.
|
||||
Core stays lean; optional capability should usually ship as plugins.
|
||||
We are generally slimming down core while expanding what plugins can do.
|
||||
If a useful feature cannot be built as a plugin yet, we welcome PRs and design discussions that extend the plugin API instead of adding one-off core behavior.
|
||||
|
||||
There are two broad plugin styles:
|
||||
|
||||
- Code plugins run OpenClaw plugin code and are appropriate for deeper runtime extension.
|
||||
- Bundle-style plugins package stable external surfaces such as skills, MCP servers, and related configuration.
|
||||
|
||||
Prefer bundle-style plugins when they can express the capability.
|
||||
They have a smaller, more stable interface and better security boundaries.
|
||||
Use code plugins when the capability needs runtime hooks, providers, channels, tools, or other in-process extension points.
|
||||
|
||||
Preferred plugin path is npm package distribution plus local extension loading for development.
|
||||
If you build a plugin, host and maintain it in your own repository.
|
||||
The bar for adding optional plugins to core is intentionally high.
|
||||
Plugin docs: [`docs/tools/plugin.md`](docs/tools/plugin.md)
|
||||
Plugin discovery, official publisher status, provenance, and security review live in [ClawHub](https://clawhub.ai/).
|
||||
OpenClaw docs should document core extension points; plugin promotion belongs in ClawHub, preferably under vetted org publishers for official plugins.
|
||||
Community plugin listing + PR bar: https://docs.openclaw.ai/plugins/community
|
||||
|
||||
Memory is a special plugin slot where only one memory plugin can be active at a time.
|
||||
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
|
||||
@@ -78,16 +66,21 @@ Today we ship multiple memory options; over time we plan to converge on one reco
|
||||
### Skills
|
||||
|
||||
We still ship some bundled skills for baseline UX.
|
||||
New skills should be published through [ClawHub](https://clawhub.ai/) first, not added to core by default.
|
||||
Official or bundled promotion should require a clear product, security, or maintainer-ownership reason.
|
||||
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
|
||||
Core skill additions should be rare and require a strong product or security reason.
|
||||
|
||||
### MCP Support
|
||||
|
||||
OpenClaw supports MCP as both a server and a runtime integration surface.
|
||||
MCP details live in [`docs/cli/mcp.md`](docs/cli/mcp.md).
|
||||
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
|
||||
|
||||
The project goal is pragmatic MCP support without duplicating existing agent,
|
||||
tool, ACPX, plugin, or ClawHub paths.
|
||||
This keeps MCP integration flexible and decoupled from core runtime:
|
||||
|
||||
- add or change MCP servers without restarting the gateway
|
||||
- keep core tool/context surface lean
|
||||
- reduce MCP churn impact on core stability and security
|
||||
|
||||
For now, we prefer this bridge model over building first-class MCP runtime into core.
|
||||
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -105,11 +98,11 @@ It is widely known, fast to iterate in, and easy to read, modify, and extend.
|
||||
|
||||
## What We Will Not Merge (For Now)
|
||||
|
||||
- New core skills when they can live on [ClawHub](https://clawhub.ai/)
|
||||
- New core skills when they can live on ClawHub
|
||||
- Full-doc translation sets for all docs (deferred; we plan AI-generated translations later)
|
||||
- Commercial service integrations that do not clearly fit the model-provider category
|
||||
- Wrapper channels around already supported channels without a clear capability or security gap
|
||||
- MCP work that duplicates existing MCP, ACPX, plugin, or ClawHub paths without a clear product or security gap
|
||||
- First-class MCP runtime in core when `mcporter` already provides the integration path
|
||||
- Agent-hierarchy frameworks (manager-of-managers / nested planner trees) as a default architecture
|
||||
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
|
||||
|
||||
|
||||
166
appcast.xml
166
appcast.xml
@@ -2,54 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.4.24</title>
|
||||
<pubDate>Sat, 25 Apr 2026 19:34:45 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026042490</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.24</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.24</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Google Meet joins OpenClaw as a bundled participant plugin, with personal Google auth, Chrome/Twilio realtime sessions, paired-node Chrome support, artifact/attendance exports, and recovery tooling for already-open Meet tabs.</li>
|
||||
<li>DeepSeek V4 Flash and V4 Pro are in the bundled catalog, V4 Flash is the onboarding default, and DeepSeek thinking/replay behavior is fixed for follow-up tool-call turns.</li>
|
||||
<li>Talk, Voice Call, and Google Meet can use realtime voice loops that consult the full OpenClaw agent for deeper tool-backed answers.</li>
|
||||
<li>Browser automation gets coordinate clicks, longer default action budgets, per-profile headless overrides, and steadier tab reuse/recovery.</li>
|
||||
<li>Plugin and model infrastructure is lighter at startup: static model catalogs, manifest-backed model rows, lazy provider dependencies, and external runtime-dependency repair for packaged installs.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Packaged installs: preserve package-root runtime dependencies and their exported subpaths when bundled plugin runtime mirrors fall back to copying shared chunks, fixing Windows npm updates that could fail to load copied <code>dist</code> modules.</li>
|
||||
<li>Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing <code>every</code> values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys.</li>
|
||||
<li>Telegram: remove the startup persisted-offset <code>getUpdates</code> preflight so polling restarts do not self-conflict before the runner starts. Fixes #69304. (#69779) Thanks @chinar-amrutkar.</li>
|
||||
<li>Browser/Playwright: ignore benign already-handled route races during guarded navigation so browser-page tasks no longer fail when Playwright tears down a route mid-flight. (#68708) Thanks @Steady-ai.</li>
|
||||
<li>Browser/aria snapshots: bind <code>format=aria</code> <code>axN</code> refs to live DOM nodes through backend DOM ids when Playwright is available, so follow-up browser actions can use those refs without timing out. (#62434) Thanks @MrKipler.</li>
|
||||
<li>Telegram: prevent duplicate in-process long pollers for the same bot token and add clearer <code>getUpdates</code> conflict diagnostics for external duplicate pollers. Fixes #56230.</li>
|
||||
<li>Browser/Linux: detect Chromium-based installs under <code>/opt/google</code>, <code>/opt/brave.com</code>, <code>/usr/lib/chromium</code>, and <code>/usr/lib/chromium-browser</code> before asking users to set <code>browser.executablePath</code>. (#48563) Thanks @lupuletic.</li>
|
||||
<li>Sessions/browser: close tracked browser tabs when idle, daily, <code>/new</code>, or <code>/reset</code> session rollover archives the previous transcript, preventing tabs from leaking past the old session. Thanks @jakozloski.</li>
|
||||
<li>Sessions/forking: fall back to transcript-estimated parent token counts when cached totals are stale or missing, so oversized thread forks start fresh instead of cloning the full parent transcript. Thanks @jalehman.</li>
|
||||
<li>OpenAI/Codex: send Codex Responses system prompts through top-level</li>
|
||||
</ul>
|
||||
<code>instructions</code> while preserving the existing native Codex payload controls.
|
||||
<ul>
|
||||
<li>MCP/CLI: retire bundled MCP runtimes at the end of one-shot <code>openclaw agent</code> and <code>openclaw infer model run</code> gateway/local executions, so repeated scripted runs do not accumulate stdio MCP child processes. Fixes #71457.</li>
|
||||
<li>OpenAI/Codex image generation: canonicalize legacy <code>openai-codex.baseUrl</code> values such as <code>https://chatgpt.com/backend-api</code> to the Codex Responses backend before calling <code>gpt-image-2</code>, matching the chat transport. Fixes #71460.</li>
|
||||
<li>Control UI: make <code>/usage</code> use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042.</li>
|
||||
<li>GitHub Copilot: preserve encrypted Responses reasoning item IDs during replay so Copilot can validate encrypted reasoning payloads across requests. (#71448) Thanks @a410979729-sys.</li>
|
||||
<li>Agents/replies: recover final-answer text when streamed assistant chunks contain only whitespace, preventing completed turns from surfacing as empty-payload errors. Fixes #71454. (#71467) Thanks @Sanjays2402.</li>
|
||||
<li>Feishu/TTS: transcode voice-intent MP3 and other audio replies to Ogg/Opus before sending native Feishu audio bubbles, while keeping ordinary MP3 attachments as files. Fixes #61249 and #37868.</li>
|
||||
<li>Telegram/webhook: acknowledge validated webhook updates before running bot middleware, keeping slow agent turns from tripping Telegram delivery retries while preserving per-chat processing lanes. Fixes #71392. Thanks @joelforsberg46-source.</li>
|
||||
<li>MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add <code>mcp.sessionIdleTtlMs</code> idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808.</li>
|
||||
<li>MCP/config reload: hot-apply <code>mcp.*</code> changes by disposing cached session MCP runtimes, and dispose bundled MCP runtimes during gateway shutdown so removed <code>mcp.servers</code> entries reap child processes promptly. Fixes #60656.</li>
|
||||
<li>Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev.</li>
|
||||
<li>Agents/tool-result pruning: harden the tool-result character estimator and context-pruning loops against malformed <code>{ type: "text" }</code> blocks created by void or undefined tool handler results, serializing non-string text payloads for size accounting so they cannot bypass trimming as zero-sized. Fixes #34979. (#51267) Thanks @cgdusek, @alvinttang, and @coffeexcoin.</li>
|
||||
<li>Daemon/service-env: add Nix Home Manager profile bin directories to generated gateway service PATHs on macOS and Linux, honoring <code>NIX_PROFILES</code> right-to-left precedence and falling back to <code>~/.nix-profile/bin</code> when unset. Fixes #44402. (#59935) Thanks @jerome-benoit.</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.4.24/OpenClaw-2026.4.24.zip" length="48033180" type="application/octet-stream" sparkle:edSignature="wxOfxadSZ/9iXMitaC6SA9J6YPZC3P2tkeK7HZPHzjUIlzQTvOl7EjR4aRyXzaYt1N1AK5ba+YhuCwEngrTdCQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.22</title>
|
||||
<pubDate>Thu, 23 Apr 2026 15:18:00 +0000</pubDate>
|
||||
@@ -363,5 +315,121 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.4.20/OpenClaw-2026.4.20.zip" length="47535600" type="application/octet-stream" sparkle:edSignature="D7XcNGxmc10IIayYY91RZBoascFSnXyd4dg6cSpC3+PTIwVrWYs/FwSBc/1J+1P53LlnTHKDGQYMkWVNMnRSAQ=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.4.15</title>
|
||||
<pubDate>Thu, 16 Apr 2026 23:33:29 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>2026041590</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.4.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.4.15</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Anthropic/models: default Anthropic selections, <code>opus</code> aliases, Claude CLI defaults, and bundled image understanding to Claude Opus 4.7.</li>
|
||||
<li>Google/TTS: add Gemini text-to-speech support to the bundled <code>google</code> plugin, including provider registration, voice selection, WAV reply output, PCM telephony output, and setup/docs guidance. (#67515) Thanks @barronlroth.</li>
|
||||
<li>Control UI/Overview: add a Model Auth status card showing OAuth token health and provider rate-limit pressure at a glance, with attention callouts when OAuth tokens are expiring or expired. Backed by a new <code>models.authStatus</code> gateway method that strips credentials and caches for 60s. (#66211) Thanks @omarshahine.</li>
|
||||
<li>Memory/LanceDB: add cloud storage support to <code>memory-lancedb</code> so durable memory indexes can run on remote object storage instead of local disk only. (#63502) Thanks @rugvedS07.</li>
|
||||
<li>GitHub Copilot/memory search: add a GitHub Copilot embedding provider for memory search, and expose a dedicated Copilot embedding host helper so plugins can reuse the transport while honoring remote overrides, token refresh, and safer payload validation. (#61718) Thanks @feiskyer and @vincentkoc.</li>
|
||||
<li>Agents/local models: add experimental <code>agents.defaults.experimental.localModelLean: true</code> to drop heavyweight default tools like <code>browser</code>, <code>cron</code>, and <code>message</code>, reducing prompt size for weaker local-model setups without changing the normal path. (#66495) Thanks @ImLukeF.</li>
|
||||
<li>Packaging/plugins: localize bundled plugin runtime deps to their owning extensions, trim the published docs payload, and tighten install/package-manager guardrails so published builds stay leaner and core stops carrying extension-owned runtime baggage. (#67099) Thanks @vincentkoc.</li>
|
||||
<li>QA/Matrix: split Matrix live QA into a source-linked <code>qa-matrix</code> runner and keep repo-private <code>qa-*</code> surfaces out of packaged and published builds. (#66723) Thanks @gumadeiras.</li>
|
||||
<li>Docs/showcase: add a scannable hero, complete section jump links, and a responsive video grid for community examples. (#48493) Thanks @jchopard69.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Gateway/tools: anchor trusted local <code>MEDIA:</code> tool-result passthrough on the exact raw name of this run's registered built-in tools, and reject client tool definitions whose names normalize-collide with a built-in or with another client tool in the same request (<code>400 invalid_request_error</code> on both JSON and SSE paths), so a client-supplied tool named like a built-in can no longer inherit its local-media trust. (#67303)</li>
|
||||
<li>Agents/replay recovery: classify the provider wording <code>401 input item ID does not belong to this connection</code> as replay-invalid, so users get the existing <code>/new</code> session reset guidance instead of a raw 401-style failure. (#66475) Thanks @dallylee.</li>
|
||||
<li>Gateway/webchat: enforce localRoots containment on webchat audio embedding path [AI-assisted]. (#67298) Thanks @pgondhi987.</li>
|
||||
<li>Matrix/pairing: block DM pairing-store entries from authorizing room control commands [AI-assisted]. (#67294) Thanks @pgondhi987.</li>
|
||||
<li>Docker/build: verify <code>@matrix-org/matrix-sdk-crypto-nodejs</code> native bindings with <code>find</code> under <code>node_modules</code> instead of a hardcoded <code>.pnpm/...</code> path so pnpm v10+ virtual-store layouts no longer fail the image build. (#67143) thanks @ly85206559.</li>
|
||||
<li>Matrix/E2EE: keep startup bootstrap conservative for passwordless token-auth bots, still attempt the guarded repair pass without requiring <code>channels.matrix.password</code>, and document the remaining password-UIA limitation. (#66228) Thanks @SARAMALI15792.</li>
|
||||
<li>Cron/announce delivery: suppress mixed-content isolated cron announce replies that end with <code>NO_REPLY</code> so trailing silent sentinels no longer leak summary text to the target channel. (#65004) thanks @neo1027144-creator.</li>
|
||||
<li>Plugins/bundled channels: partition bundled channel lazy caches by active bundled root so <code>OPENCLAW_BUNDLED_PLUGINS_DIR</code> flips stop reusing stale plugin, setup, secrets, and runtime state. (#67200) Thanks @gumadeiras.</li>
|
||||
<li>Packaging/plugins: prune common test/spec cargo from bundled plugin runtime dependencies and fail npm release validation if packaged test cargo reappears, keeping published tarballs leaner without plugin-specific special cases. (#67275) thanks @gumadeiras.</li>
|
||||
<li>Agents/context + Memory: trim default startup/skills prompt budgets, cap <code>memory_get</code> excerpts by default with explicit continuation metadata, and keep QMD reads aligned with the same bounded excerpt contract so long sessions pull less context by default without losing deterministic follow-up reads.</li>
|
||||
<li>Matrix/commands: skip DM pairing-store reads on room traffic now that room control-command authorization ignores pairing-store entries, keeping the room path narrower without changing room auth behavior. (#67325) Thanks @gumadeiras.</li>
|
||||
<li>Memory-core/dreaming: skip dreaming narrative transcripts from session-store metadata before bootstrap records land so dream diary prompt/prose lines do not pollute session ingestion. (#67315) thanks @jalehman.</li>
|
||||
<li>Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when <code>agents.defaults.contextTokens</code> is the real limit. (#66236) Thanks @ImLukeF.</li>
|
||||
<li>Dreaming/memory-core: change the default <code>dreaming.storage.mode</code> from <code>inline</code> to <code>separate</code> so Dreaming phase blocks (<code>## Light Sleep</code>, <code>## REM Sleep</code>) land in <code>memory/dreaming/{phase}/YYYY-MM-DD.md</code> instead of being injected into <code>memory/YYYY-MM-DD.md</code>. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting <code>plugins.entries.memory-core.config.dreaming.storage.mode: "inline"</code>. (#66412) Thanks @mjamiv.</li>
|
||||
<li>Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine.</li>
|
||||
<li>Dashboard: constrain exec approval modal overflow on desktop so long command content no longer pushes action buttons out of view. (#67082) Thanks @Ziy1-Tan.</li>
|
||||
<li>Agents/CLI transcripts: persist successful CLI-backed turns into the OpenClaw session transcript so google-gemini-cli replies appear in session history and the Control UI again. (#67490) Thanks @obviyus.</li>
|
||||
<li>Discord/tool-call text: strip standalone Gemma-style <code><function>...</function></code> tool-call payloads from visible assistant text without truncating prose examples or trailing replies. (#67318) Thanks @joelnishanth.</li>
|
||||
<li>WhatsApp/web-session: drain the pending per-auth creds save queue before reopening sockets so reconnect-time auth bootstrap no longer races in-flight <code>creds.json</code> writes and falsely restores from backup. (#67464) Thanks @neeravmakwana.</li>
|
||||
<li>BlueBubbles/catchup: add a per-message retry ceiling (<code>catchup.maxFailureRetries</code>, default 10) so a persistently-failing message with a malformed payload no longer wedges the catchup cursor forever. After N consecutive <code>processMessage</code> failures against the same GUID, catchup logs a WARN, skips that message on subsequent sweeps, and lets the cursor advance past it. Transient failures still retry from the same point as before. Also fixes a lost-update race in the persistent dedupe file lock that silently dropped inbound GUIDs on concurrent writes, a dedupe file naming migration gap on version upgrade, and a balloon-event bypass that let catchup replay debouncer-coalesced events as standalone messages. (#67426, #66870) Thanks @omarshahine.</li>
|
||||
<li>Ollama/chat: strip the <code>ollama/</code> provider prefix from Ollama chat request model ids so configured refs like <code>ollama/qwen3:14b-q8_0</code> stop 404ing against the Ollama API. (#67457) Thanks @suboss87.</li>
|
||||
<li>Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so <code>~/...</code> host edit/write operations stop failing or reading back the wrong file when <code>OPENCLAW_HOME</code> differs. (#62804) Thanks @stainlu.</li>
|
||||
<li>Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like <code>[[tts:speed=1.2]]</code> stop silently landing on the wrong provider. (#62846) Thanks @stainlu.</li>
|
||||
<li>OpenAI Codex/models: normalize stale native transport metadata in both runtime resolution and discovery/listing so legacy <code>openai-codex</code> rows with missing <code>api</code> or <code>https://chatgpt.com/backend-api/v1</code> self-heal to the canonical Codex transport instead of routing requests through broken HTML/Cloudflare paths, combining the original fixes proposed in #66969 (saamuelng601-pixel) and #67159 (hclsys). (#67635)</li>
|
||||
<li>Agents/failover: treat HTML provider error pages as upstream transport failures for CDN-style 5xx responses without misclassifying embedded body text as API rate limits, while still preserving auth remediation for HTML 401/403 pages and proxy remediation for HTML 407 pages. (#67642) Thanks @stainlu.</li>
|
||||
<li>Gateway/skills: bump the cached skills-snapshot version whenever a config write touches <code>skills.*</code> (for example <code>skills.allowBundled</code>, <code>skills.entries.<id>.enabled</code>, or <code>skills.profile</code>). Existing agent sessions persist a <code>skillsSnapshot</code> in <code>sessions.json</code> that reuses the skill list frozen at session creation; without this invalidation, removing a bundled skill from the allowlist left the old snapshot live and the model kept calling the disabled tool, producing <code>Tool <name> not found</code> loops that ran until the embedded-run timeout. (#67401) Thanks @xantorres.</li>
|
||||
<li>Agents/tool-loop: enable the unknown-tool stream guard by default. Previously <code>resolveUnknownToolGuardThreshold</code> returned <code>undefined</code> unless <code>tools.loopDetection.enabled</code> was explicitly set to <code>true</code>, which left the protection off in the default configuration. A hallucinated or removed tool (for example <code>himalaya</code> after it was dropped from <code>skills.allowBundled</code>) would then loop "Tool X not found" attempts until the full embedded-run timeout. The guard has no false-positive surface because it only triggers on tools that are objectively not registered in the run, so it now stays on regardless of <code>tools.loopDetection.enabled</code> and still accepts <code>tools.loopDetection.unknownToolThreshold</code> as a per-run override (default 10). (#67401) Thanks @xantorres.</li>
|
||||
<li>TUI/streaming: add a client-side streaming watchdog to <code>tui-event-handlers</code> so the <code>streaming · Xm Ys</code> activity indicator resets to <code>idle</code> after 30s of delta silence on the active run. Guards against lost or late <code>state: "final"</code> chat events (WS reconnects, gateway restarts, etc.) leaving the TUI stuck on <code>streaming</code> indefinitely; a new system log line surfaces the reset so users know to send a new message to resync. The window is configurable via the new <code>streamingWatchdogMs</code> context option (set to <code>0</code> to disable), and the handler now exposes a <code>dispose()</code> that clears the pending timer on shutdown. (#67401) Thanks @xantorres.</li>
|
||||
<li>Extensions/lmstudio: add exponential backoff to the inference-preload wrapper so an LM Studio model-load failure (for example the built-in memory guardrail rejecting a load because the swap is saturated) no longer produces a WARN line every ~2s for every chat request. The wrapper now records consecutive preload failures per <code>(baseUrl, modelKey, contextLength)</code> tuple with a 5s → 10s → 20s → … → 5min cooldown and skips the preload step entirely while a cooldown is active, letting chat requests proceed directly to the stream (the model is often already loaded via the LM Studio UI). The combined <code>preload failed</code> log line now reports consecutive-failure count and remaining cooldown so operators can act on the real issue instead of drowning in repeated warnings. (#67401) Thanks @xantorres.</li>
|
||||
<li>Agents/replay: re-run tool/result pairing after strict replay tool-call ID sanitization on outbound requests so Anthropic-compatible providers like MiniMax no longer receive malformed orphan tool-result IDs such as <code>...toolresult1</code> during compaction and retry flows. (#67620) Thanks @stainlu.</li>
|
||||
<li>Gateway/startup: fix spurious SIGUSR1 restart loop on Linux/systemd when plugin auto-enable is the only startup config write; the config hash guard was not captured for that write path, causing chokidar to treat each boot write as an external change and trigger a reload → restart cycle that corrupts manifest.db after repeated cycles. Fixes #67436. (#67557) thanks @openperf</li>
|
||||
<li>Codex/harness: auto-enable the Codex plugin when <code>codex</code> is selected as an embedded agent harness runtime, including forced default, per-agent, and <code>OPENCLAW_AGENT_RUNTIME</code> paths. (#67474) Thanks @duqaXxX.</li>
|
||||
<li>OpenAI Codex/CLI: keep resumed <code>codex exec resume</code> runs on the safe non-interactive path without reintroducing the removed dangerous bypass flag by passing the supported <code>--skip-git-repo-check</code> resume arg plus Codex's native <code>sandbox_mode="workspace-write"</code> config override. (#67666) Thanks @plgonzalezrx8.</li>
|
||||
<li>Codex/app-server: parse Desktop-originated app-server user agents such as <code>Codex Desktop/0.118.0</code>, keeping the version gate working when the Codex CLI inherits a multi-word originator. (#64666) Thanks @cyrusaf.</li>
|
||||
<li>Cron/announce delivery: keep isolated announce <code>NO_REPLY</code> stripping case-insensitive across direct and text delivery, preserve structured media-only sends when a caption strips silent, and derive main-session awareness from the cleaned payloads so silent captions no longer leak stale <code>NO_REPLY</code> text. (#65016) Thanks @BKF-Gitty.</li>
|
||||
<li>Sessions/Codex: skip redundant <code>delivery-mirror</code> transcript appends only when the latest assistant message has the same visible text, preventing duplicate visible replies on Codex-backed turns without suppressing repeated answers across turns. (#67185) Thanks @andyylin.</li>
|
||||
<li>Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT.</li>
|
||||
<li>BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept <code>updated-message</code> webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine.</li>
|
||||
<li>Agents/skills: sort prompt-facing <code>available_skills</code> entries by skill name after merging sources so <code>skills.load.extraDirs</code> order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9.</li>
|
||||
<li>Agents/OpenAI Responses: add <code>models.providers.*.models.*.compat.supportsPromptCacheKey</code> so OpenAI-compatible proxies that forward <code>prompt_cache_key</code> can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem.</li>
|
||||
<li>Agents/context engines: keep loop-hook and final <code>afterTurn</code> prompt-cache touch metadata aligned with the current assistant turn so cache-aware context engines retain accurate cache TTL state during tool loops. (#67767) thanks @jalehman.</li>
|
||||
<li>Memory/dreaming: strip AI-facing inbound metadata envelopes from session-corpus user turns before normalization so REM topic extraction sees the user's actual message text, including array-shaped split envelopes. (#66548) Thanks @zqchris.</li>
|
||||
<li>Agents/errors: detect standalone Cloudflare/CDN HTML challenge pages before transport DNS classification so provider block pages no longer appear as local DNS lookup failures. (#67704) Thanks @chris-yyau.</li>
|
||||
<li>Security/approvals: redact secrets in exec approval prompts so inline approval review can no longer leak credential material in rendered prompt content. (#61077, #64790)</li>
|
||||
<li>CLI/configure: re-read the persisted config hash after writes so config updates stop failing with stale-hash races. (#64188, #66528)</li>
|
||||
<li>CLI/update: prune stale packaged <code>dist</code> chunks after npm upgrades and keep downgrade/verify inventory checks compat-safe so global upgrades stop failing on stale chunk imports. (#66959) Thanks @obviyus.</li>
|
||||
<li>Onboarding/CLI: fix channel-selection crashes on globally installed CLI setups during onboarding. (#66736)</li>
|
||||
<li>Video generation/live tests: bound provider polling for live video smoke, default to the fast non-FAL text-to-video path, and use a one-second lobster prompt so release validation no longer waits indefinitely on slow provider queues.</li>
|
||||
<li>Memory-core/QMD <code>memory_get</code>: reject reads of arbitrary workspace markdown paths and only allow canonical memory files (<code>MEMORY.md</code>, <code>memory.md</code>, <code>DREAMS.md</code>, <code>dreams.md</code>, <code>memory/**</code>) plus exact paths of active indexed QMD workspace documents, so the QMD memory backend can no longer be used as a generic workspace-file read shim that bypasses <code>read</code> tool-policy denials. (#66026) Thanks @eleqtrizit.</li>
|
||||
<li>Cron/agents: forward embedded-run tool policy and internal event params into the attempt layer so <code>--tools</code> allowlists, cron-owned message-tool suppression, explicit message targeting, and command-path internal events all take effect at runtime again. (#62675) Thanks @hexsprite.</li>
|
||||
<li>Setup/providers: guard preferred-provider lookup during setup so malformed plugin metadata with a missing provider id no longer crashes the wizard with <code>Cannot read properties of undefined (reading 'trim')</code>. (#66649) Thanks @Tianworld.</li>
|
||||
<li>Matrix/security: normalize sandboxed profile avatar params, preserve <code>mxc://</code> avatar URLs, and surface gmail watcher stop failures during reload. (#64701) Thanks @slepybear.</li>
|
||||
<li>Telegram/documents: drop leaked binary caption bytes from inbound Telegram text handling so document uploads like <code>.mobi</code> or <code>.epub</code> no longer explode prompt token counts. (#66663) Thanks @joelnishanth.</li>
|
||||
<li>Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via <code>getResolvedAuth()</code>, mirroring the WebSocket path, so a secret rotated through <code>secrets.reload</code> or config hot-reload stops authenticating on <code>/v1/*</code>, <code>/tools/invoke</code>, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps.</li>
|
||||
<li>Agents/compaction: cap the compaction reserve-token floor to the model context window so small-context local models (e.g. Ollama with 16K tokens) no longer trigger context-overflow errors or infinite compaction loops on every prompt. (#65671) Thanks @openperf.</li>
|
||||
<li>Agents/OpenAI Responses: classify the exact <code>Unknown error (no error details in response)</code> transport failure as failover reason <code>unknown</code> so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer.</li>
|
||||
<li>Models/probe: surface invalid-model probe failures as <code>format</code> instead of <code>unknown</code> in <code>models list --probe</code>, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi.</li>
|
||||
<li>Agents/failover: classify OpenAI-compatible <code>finish_reason: network_error</code> stream failures as timeout so model fallback retries continue instead of stopping with an unknown failover reason. (#61784) thanks @lawrence3699.</li>
|
||||
<li>Onboarding/channels: normalize channel setup metadata before discovery and validation so malformed or mixed-shape channel plugin metadata no longer breaks setup and onboarding channel lists. (#66706) Thanks @darkamenosa.</li>
|
||||
<li>Slack/native commands: fix option menus for slash commands such as <code>/verbose</code> when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared <code>openclaw_cmdarg*</code> listener. Thanks @Wangmerlyn.</li>
|
||||
<li>Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing <code>encryptKey</code> and blank callback tokens — refuse to start the webhook transport without an <code>encryptKey</code>, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.</li>
|
||||
<li>Agents/workspace files: route <code>agents.files.get</code>, <code>agents.files.set</code>, and workspace listing through the shared <code>fs-safe</code> helpers (<code>openFileWithinRoot</code>/<code>readFileWithinRoot</code>/<code>writeFileWithinRoot</code>), reject symlink aliases for allowlisted agent files, and have <code>fs-safe</code> resolve opened-file real paths from the file descriptor before falling back to path-based <code>realpath</code> so a symlink swap between <code>open</code> and <code>realpath</code> can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.</li>
|
||||
<li>Gateway/MCP loopback: switch the <code>/mcp</code> bearer comparison from plain <code>!==</code> to constant-time <code>safeEqualSecret</code> (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via <code>checkBrowserOrigin</code> before the auth gate runs. Loopback origins (<code>127.0.0.1:*</code>, <code>localhost:*</code>, same-origin) still go through, including the <code>localhost</code>↔<code>127.0.0.1</code> host mismatch that browsers flag as <code>Sec-Fetch-Site: cross-site</code>. (#66665) Thanks @eleqtrizit.</li>
|
||||
<li>Auto-reply/billing: classify pure billing cooldown fallback summaries from structured fallback reasons so users see billing guidance instead of the generic failure reply. (#66363) Thanks @Rohan5commit.</li>
|
||||
<li>Agents/fallback: preserve the original prompt body on model fallback retries with session history so the retrying model keeps the active task instead of only seeing a generic continue message. (#66029) Thanks @WuKongAI-CMU.</li>
|
||||
<li>Reply/secrets: resolve active reply channel/account SecretRefs before reply-run message-action discovery so channel token SecretRefs (for example Discord) do not degrade into discovery-time unresolved-secret failures. (#66796) Thanks @joshavant.</li>
|
||||
<li>Agents/Anthropic: ignore non-positive Anthropic Messages token overrides and fail locally when no positive token budget remains, so invalid <code>max_tokens</code> values no longer reach the provider API. (#66664) thanks @jalehman</li>
|
||||
<li>Agents/context engines: preserve prompt-only token counts, not full request totals, when deferred maintenance reuses after-turn runtime context so background compaction bookkeeping matches the active prompt window. (#66820) thanks @jalehman.</li>
|
||||
<li>BlueBubbles/inbound: add a persistent file-backed GUID dedupe so MessagePoller webhook replays after BB Server restart or reconnect no longer cause the agent to re-reply to already-handled messages. (#19176, #12053, #66816) Thanks @omarshahine.</li>
|
||||
<li>Secrets/plugins/status: align SecretRef inspect-vs-strict handling across plugin preload, read-only status/agents surfaces, and runtime auth paths so unresolved refs no longer crash read-only CLI flows while runtime-required non-env refs stay strict. (#66818) Thanks @joshavant.</li>
|
||||
<li>Memory/dreaming: stop ordinary transcripts that merely quote the dream-diary prompt from being classified as internal dreaming runs and silently dropped from session recall ingestion. (#66852) Thanks @gumadeiras.</li>
|
||||
<li>Telegram/documents: sanitize binary reply context and ZIP-like archive extraction so <code>.epub</code> and <code>.mobi</code> uploads can no longer leak raw binary into prompt context through reply metadata or archive-to-<code>text/plain</code> coercion. (#66877) Thanks @martinfrancois.</li>
|
||||
<li>Telegram/native commands: restore plugin-registry-backed auto defaults for native commands and native skills so Telegram slash commands keep registering when <code>commands.native</code> and <code>commands.nativeSkills</code> stay on <code>auto</code>. (#66843) Thanks @kashevk0.</li>
|
||||
<li>OpenRouter/Qwen3: parse <code>reasoning_details</code> stream deltas as thinking content without skipping same-chunk tool calls, so Qwen3 replies no longer fail empty on OpenRouter and mixed reasoning/tool-call chunks still execute normally. (#66905) Thanks @bladin.</li>
|
||||
<li>BlueBubbles/catchup: replay missed webhook messages after gateway restart via a persistent per-account cursor and <code>/api/v1/message/query?after=<ts></code> pass, so messages delivered while the gateway was down no longer disappear. Uses the existing <code>processMessage</code> path and is deduped by #66816's inbound GUID cache. (#66857, #66721) Thanks @omarshahine.</li>
|
||||
<li>Telegram/native commands: keep Telegram command-sync cache process-local so gateway restarts re-register the menu instead of trusting stale on-disk sync state after Telegram cleared commands out-of-band. (#66730) Thanks @nightq.</li>
|
||||
<li>Audio/self-hosted STT: restore <code>models.providers.*.request.allowPrivateNetwork</code> for audio transcription so private or LAN speech-to-text endpoints stop tripping SSRF blocks after the v2026.4.14 regression. (#66692) Thanks @jhsmith409.</li>
|
||||
<li>Auto-reply/media: allow workspace-rooted absolute media paths in auto-reply send flows so valid local media references no longer fail path validation. (#66689)</li>
|
||||
<li>WhatsApp/Baileys media upload: harden encrypted upload handling so large outbound media sends avoid buffer spikes and reliability regressions. (#65966) Thanks @frankekn.</li>
|
||||
<li>QQBot/cron: guard against undefined <code>event.content</code> in <code>parseFaceTags</code> and <code>filterInternalMarkers</code> so cron-triggered agent turns with no content payload no longer crash with <code>TypeError: Cannot read properties of undefined (reading 'startsWith')</code>. (#66302) Thanks @xinmotlanthua.</li>
|
||||
<li>CLI/plugins: stop <code>--dangerously-force-unsafe-install</code> plugin installs from falling back to hook-pack installs after security scan failures, while still preserving non-security fallback behavior for real hook packs. (#58909) Thanks @hxy91819.</li>
|
||||
<li>Claude CLI/sessions: classify <code>No conversation found with session ID</code> as <code>session_expired</code> so expired CLI-backed conversations clear the stale binding and recover on the next turn. (#65028) thanks @Ivan-Fn.</li>
|
||||
<li>Context Engine: gracefully fall back to the legacy engine when a third-party context engine plugin fails at resolution time (unregistered id, factory throw, or contract violation), preventing a full gateway outage on every channel. (#66930) Thanks @openperf.</li>
|
||||
<li>Control UI/chat: keep optimistic user message cards visible during active sends by deferring same-session history reloads until the active run ends, including aborted and errored runs. (#66997) Thanks @scotthuang and @vincentkoc.</li>
|
||||
<li>Media/Slack: allow host-local CSV and Markdown uploads only when the fallback buffer actually decodes as text, so real plain-text files work without letting opaque non-text blobs renamed to <code>.csv</code> or <code>.md</code> slip past the host-read guard. (#67047) Thanks @Unayung.</li>
|
||||
<li>Ollama/onboarding: split setup into <code>Cloud + Local</code>, <code>Cloud only</code>, and <code>Local only</code>, support direct <code>OLLAMA_API_KEY</code> cloud setup without a local daemon, and keep Ollama web search on the local-host path. (#67005) Thanks @obviyus.</li>
|
||||
<li>Webchat/security: reject remote-host <code>file://</code> URLs in the media embedding path. (#67293) Thanks @pgondhi987.</li>
|
||||
<li>Dreaming/memory-core: use the ingestion day, not the source file day, for daily recall dedupe so repeat sweeps of the same daily note can increment <code>dailyCount</code> across days instead of stalling at <code>1</code>. (#67091) Thanks @Bartok9.</li>
|
||||
<li>Node-host/tools.exec: let approval binding distinguish known native binaries from mutable shell payload files, while still fail-closing unknown or racy file probes so absolute-path node-host commands like <code>/usr/bin/whoami</code> no longer get rejected as unsafe interpreter/runtime commands. (#66731) Thanks @tmimmanuel.</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.4.15/OpenClaw-2026.4.15.zip" length="47501638" type="application/octet-stream" sparkle:edSignature="JUG3cicpJqCQDvp7VYoN6qBuN4Kn4s0+QQFjlMR69OZlwViLdiStPIHa+1vpuoR4miYhJc9knSDVCFzSfQuYCQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026042600
|
||||
versionName = "2026.4.26"
|
||||
versionCode = 2026042400
|
||||
versionName = "2026.4.24"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
@@ -53,7 +52,7 @@
|
||||
<service
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone" />
|
||||
android:foregroundServiceType="dataSync" />
|
||||
<service
|
||||
android:name=".node.DeviceNotificationListenerService"
|
||||
android:label="@string/app_name"
|
||||
|
||||
@@ -101,8 +101,7 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = runtimeState(initial = VoiceCaptureMode.Off) { it.voiceCaptureMode }
|
||||
val micEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.micEnabled }
|
||||
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||
|
||||
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
|
||||
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
|
||||
@@ -112,10 +111,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
|
||||
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
|
||||
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
|
||||
val talkModeEnabled: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeEnabled }
|
||||
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
|
||||
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
|
||||
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||
@@ -288,10 +283,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
ensureRuntime().setMicEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setTalkModeEnabled(enabled)
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(enabled: Boolean) {
|
||||
ensureRuntime().setSpeakerEnabled(enabled)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ package ai.openclaw.app
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -23,7 +21,6 @@ class NodeForegroundService : Service() {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var notificationJob: Job? = null
|
||||
private var didStartForeground = false
|
||||
private var voiceCaptureMode = VoiceCaptureMode.Off
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -39,51 +36,22 @@ class NodeForegroundService : Service() {
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
combine(
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.voiceCaptureMode,
|
||||
) { status, server, connected, mode ->
|
||||
VoiceNotificationBase(
|
||||
status = status,
|
||||
server = server,
|
||||
connected = connected,
|
||||
mode = mode,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
runtime.micEnabled,
|
||||
runtime.micIsListening,
|
||||
runtime.talkModeListening,
|
||||
runtime.talkModeSpeaking,
|
||||
) { micEnabled, micListening, talkListening, talkSpeaking ->
|
||||
VoiceNotificationCapture(
|
||||
micEnabled = micEnabled,
|
||||
micListening = micListening,
|
||||
talkListening = talkListening,
|
||||
talkSpeaking = talkSpeaking,
|
||||
)
|
||||
},
|
||||
) { base, capture ->
|
||||
VoiceNotificationState(base = base, capture = capture)
|
||||
}.collect { state ->
|
||||
voiceCaptureMode = state.mode
|
||||
val title =
|
||||
when {
|
||||
state.connected && state.mode == VoiceCaptureMode.TalkMode -> "OpenClaw Node · Talk"
|
||||
state.connected -> "OpenClaw Node · Connected"
|
||||
else -> "OpenClaw Node"
|
||||
runtime.statusText,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.micEnabled,
|
||||
runtime.micIsListening,
|
||||
) { status, server, connected, micEnabled, micListening ->
|
||||
Quint(status, server, connected, micEnabled, micListening)
|
||||
}.collect { (status, server, connected, micEnabled, micListening) ->
|
||||
val title = if (connected) "OpenClaw Node · Connected" else "OpenClaw Node"
|
||||
val micSuffix =
|
||||
if (micEnabled) {
|
||||
if (micListening) " · Mic: Listening" else " · Mic: Pending"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val text =
|
||||
(state.server?.let { "${state.status} · $it" } ?: state.status) +
|
||||
voiceNotificationSuffix(
|
||||
mode = state.mode,
|
||||
manualMicEnabled = state.capture.micEnabled,
|
||||
manualMicListening = state.capture.micListening,
|
||||
talkListening = state.capture.talkListening,
|
||||
talkSpeaking = state.capture.talkSpeaking,
|
||||
)
|
||||
val text = (server?.let { "$status · $it" } ?: status) + micSuffix
|
||||
|
||||
startForegroundWithTypes(
|
||||
notification = buildNotification(title = title, text = text),
|
||||
@@ -92,27 +60,13 @@ class NodeForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_STOP -> {
|
||||
(application as NodeApp).peekRuntime()?.disconnect()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_SET_VOICE_CAPTURE_MODE -> {
|
||||
voiceCaptureMode = intent.getStringExtra(EXTRA_VOICE_CAPTURE_MODE).toVoiceCaptureMode()
|
||||
startForegroundWithTypes(
|
||||
notification =
|
||||
buildNotification(
|
||||
title = "OpenClaw Node",
|
||||
text = if (voiceCaptureMode == VoiceCaptureMode.TalkMode) "Talk mode active" else "Connected",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Keep running; connection is managed by NodeRuntime (auto-reconnect + manual).
|
||||
return START_STICKY
|
||||
@@ -173,13 +127,17 @@ class NodeForegroundService : Service() {
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification(notification: Notification) {
|
||||
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
mgr.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun startForegroundWithTypes(notification: Notification) {
|
||||
val serviceTypes = foregroundServiceTypesForVoiceMode(voiceCaptureMode)
|
||||
if (didStartForeground) {
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
|
||||
updateNotification(notification)
|
||||
return
|
||||
}
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, serviceTypes)
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
didStartForeground = true
|
||||
}
|
||||
|
||||
@@ -188,8 +146,6 @@ class NodeForegroundService : Service() {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private const val ACTION_STOP = "ai.openclaw.app.action.STOP"
|
||||
private const val ACTION_SET_VOICE_CAPTURE_MODE = "ai.openclaw.app.action.SET_VOICE_CAPTURE_MODE"
|
||||
private const val EXTRA_VOICE_CAPTURE_MODE = "ai.openclaw.app.extra.VOICE_CAPTURE_MODE"
|
||||
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, NodeForegroundService::class.java)
|
||||
@@ -200,85 +156,7 @@ class NodeForegroundService : Service() {
|
||||
val intent = Intent(context, NodeForegroundService::class.java).setAction(ACTION_STOP)
|
||||
context.startService(intent)
|
||||
}
|
||||
|
||||
fun setVoiceCaptureMode(
|
||||
context: Context,
|
||||
mode: VoiceCaptureMode,
|
||||
) {
|
||||
val intent =
|
||||
Intent(context, NodeForegroundService::class.java)
|
||||
.setAction(ACTION_SET_VOICE_CAPTURE_MODE)
|
||||
.putExtra(EXTRA_VOICE_CAPTURE_MODE, mode.name)
|
||||
if (mode == VoiceCaptureMode.TalkMode) {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun foregroundServiceTypesForVoiceMode(mode: VoiceCaptureMode): Int {
|
||||
val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
return if (mode == VoiceCaptureMode.TalkMode) {
|
||||
base or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
internal fun voiceNotificationSuffix(
|
||||
mode: VoiceCaptureMode,
|
||||
manualMicEnabled: Boolean,
|
||||
manualMicListening: Boolean,
|
||||
talkListening: Boolean,
|
||||
talkSpeaking: Boolean,
|
||||
): String {
|
||||
return when (mode) {
|
||||
VoiceCaptureMode.TalkMode ->
|
||||
when {
|
||||
talkSpeaking -> " · Talk: Speaking"
|
||||
talkListening -> " · Talk: Listening"
|
||||
else -> " · Talk: On"
|
||||
}
|
||||
VoiceCaptureMode.ManualMic ->
|
||||
if (manualMicEnabled) {
|
||||
if (manualMicListening) " · Mic: Listening" else " · Mic: Pending"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
VoiceCaptureMode.Off -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun String?.toVoiceCaptureMode(): VoiceCaptureMode {
|
||||
return VoiceCaptureMode.entries.firstOrNull { it.name == this } ?: VoiceCaptureMode.Off
|
||||
}
|
||||
|
||||
private data class VoiceNotificationBase(
|
||||
val status: String,
|
||||
val server: String?,
|
||||
val connected: Boolean,
|
||||
val mode: VoiceCaptureMode,
|
||||
)
|
||||
|
||||
private data class VoiceNotificationCapture(
|
||||
val micEnabled: Boolean,
|
||||
val micListening: Boolean,
|
||||
val talkListening: Boolean,
|
||||
val talkSpeaking: Boolean,
|
||||
)
|
||||
|
||||
private data class VoiceNotificationState(
|
||||
val base: VoiceNotificationBase,
|
||||
val capture: VoiceNotificationCapture,
|
||||
) {
|
||||
val status: String
|
||||
get() = base.status
|
||||
val server: String?
|
||||
get() = base.server
|
||||
val connected: Boolean
|
||||
get() = base.connected
|
||||
val mode: VoiceCaptureMode
|
||||
get() = base.mode
|
||||
}
|
||||
private data class Quint<A, B, C, D, E>(val first: A, val second: B, val third: C, val fourth: D, val fifth: E)
|
||||
|
||||
@@ -64,8 +64,6 @@ class NodeRuntime(
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private val externalAudioCaptureActive = MutableStateFlow(false)
|
||||
private val _voiceCaptureMode = MutableStateFlow(VoiceCaptureMode.Off)
|
||||
val voiceCaptureMode: StateFlow<VoiceCaptureMode> = _voiceCaptureMode.asStateFlow()
|
||||
|
||||
private val discovery = GatewayDiscovery(appContext, scope = scope)
|
||||
val gateways: StateFlow<List<GatewayEndpoint>> = discovery.gateways
|
||||
@@ -430,18 +428,6 @@ class NodeRuntime(
|
||||
)
|
||||
}
|
||||
|
||||
val talkModeEnabled: StateFlow<Boolean>
|
||||
get() = talkMode.isEnabled
|
||||
|
||||
val talkModeListening: StateFlow<Boolean>
|
||||
get() = talkMode.isListening
|
||||
|
||||
val talkModeSpeaking: StateFlow<Boolean>
|
||||
get() = talkMode.isSpeaking
|
||||
|
||||
val talkModeStatusText: StateFlow<String>
|
||||
get() = talkMode.statusText
|
||||
|
||||
private fun syncMainSessionKey(agentId: String?) {
|
||||
val resolvedKey = resolveNodeMainSessionKey(agentId)
|
||||
// Always push the resolved session key into TalkMode, even when the
|
||||
@@ -613,8 +599,17 @@ class NodeRuntime(
|
||||
prefs.loadGatewayToken()
|
||||
}
|
||||
|
||||
if (prefs.voiceMicEnabled.value) {
|
||||
setVoiceCaptureMode(VoiceCaptureMode.ManualMic, persistManualMic = false)
|
||||
scope.launch {
|
||||
prefs.talkEnabled.collect { enabled ->
|
||||
// MicCaptureManager handles STT + send to gateway, while the dedicated
|
||||
// reply speaker handles TTS for assistant replies in the voice tab.
|
||||
micCapture.setMicEnabled(enabled)
|
||||
if (enabled) {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
externalAudioCaptureActive.value = enabled
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
@@ -648,7 +643,7 @@ class NodeRuntime(
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
} else {
|
||||
stopManualVoiceSession()
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,17 +757,21 @@ class NodeRuntime(
|
||||
|
||||
fun setVoiceScreenActive(active: Boolean) {
|
||||
if (!active) {
|
||||
stopManualVoiceSession()
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
// Don't re-enable on active=true; mic toggle drives that
|
||||
}
|
||||
|
||||
fun setMicEnabled(value: Boolean) {
|
||||
setVoiceCaptureMode(if (value) VoiceCaptureMode.ManualMic else VoiceCaptureMode.Off)
|
||||
}
|
||||
|
||||
fun setTalkModeEnabled(value: Boolean) {
|
||||
setVoiceCaptureMode(if (value) VoiceCaptureMode.TalkMode else VoiceCaptureMode.Off)
|
||||
prefs.setTalkEnabled(value)
|
||||
if (value) {
|
||||
// Tapping mic on interrupts any active TTS (barge-in)
|
||||
stopVoicePlayback()
|
||||
talkMode.ttsOnAllResponses = false
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
}
|
||||
micCapture.setMicEnabled(value)
|
||||
externalAudioCaptureActive.value = value
|
||||
}
|
||||
|
||||
val speakerEnabled: StateFlow<Boolean>
|
||||
@@ -787,72 +786,11 @@ class NodeRuntime(
|
||||
talkMode.setPlaybackEnabled(value)
|
||||
}
|
||||
|
||||
private fun setVoiceCaptureMode(
|
||||
mode: VoiceCaptureMode,
|
||||
persistManualMic: Boolean = true,
|
||||
) {
|
||||
if (mode == VoiceCaptureMode.TalkMode && !hasRecordAudioPermission()) {
|
||||
_voiceCaptureMode.value = VoiceCaptureMode.Off
|
||||
externalAudioCaptureActive.value = false
|
||||
return
|
||||
}
|
||||
if (_voiceCaptureMode.value == mode) return
|
||||
_voiceCaptureMode.value = mode
|
||||
when (mode) {
|
||||
VoiceCaptureMode.Off -> {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.setEnabled(false)
|
||||
stopVoicePlayback()
|
||||
micCapture.setMicEnabled(false)
|
||||
if (persistManualMic) {
|
||||
prefs.setVoiceMicEnabled(false)
|
||||
}
|
||||
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
VoiceCaptureMode.ManualMic -> {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.setEnabled(false)
|
||||
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.ManualMic)
|
||||
if (persistManualMic) {
|
||||
prefs.setVoiceMicEnabled(true)
|
||||
}
|
||||
// Tapping mic on interrupts any active TTS (barge-in).
|
||||
stopVoicePlayback()
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
micCapture.setMicEnabled(true)
|
||||
externalAudioCaptureActive.value = true
|
||||
}
|
||||
|
||||
VoiceCaptureMode.TalkMode -> {
|
||||
if (persistManualMic) {
|
||||
prefs.setVoiceMicEnabled(false)
|
||||
}
|
||||
micCapture.setMicEnabled(false)
|
||||
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.TalkMode)
|
||||
talkMode.ttsOnAllResponses = true
|
||||
talkMode.setPlaybackEnabled(speakerEnabled.value)
|
||||
scope.launch { talkMode.ensureChatSubscribed() }
|
||||
talkMode.setEnabled(true)
|
||||
externalAudioCaptureActive.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopManualVoiceSession() {
|
||||
if (_voiceCaptureMode.value != VoiceCaptureMode.ManualMic) return
|
||||
setVoiceCaptureMode(VoiceCaptureMode.Off)
|
||||
}
|
||||
|
||||
private fun stopActiveVoiceSession() {
|
||||
talkMode.ttsOnAllResponses = false
|
||||
talkMode.setEnabled(false)
|
||||
stopVoicePlayback()
|
||||
micCapture.setMicEnabled(false)
|
||||
prefs.setVoiceMicEnabled(false)
|
||||
NodeForegroundService.setVoiceCaptureMode(appContext, VoiceCaptureMode.Off)
|
||||
_voiceCaptureMode.value = VoiceCaptureMode.Off
|
||||
prefs.setTalkEnabled(false)
|
||||
externalAudioCaptureActive.value = false
|
||||
}
|
||||
|
||||
@@ -1032,7 +970,6 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_pendingGatewayTrust.value = null
|
||||
|
||||
@@ -37,7 +37,6 @@ class SecurePrefs(
|
||||
private const val notificationsForwardingMaxEventsPerMinuteKey =
|
||||
"notifications.forwarding.maxEventsPerMinute"
|
||||
private const val notificationsForwardingSessionKeyKey = "notifications.forwarding.sessionKey"
|
||||
private const val voiceMicEnabledKey = "voice.micEnabled"
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -163,8 +162,8 @@ class SecurePrefs(
|
||||
private val _voiceWakeMode = MutableStateFlow(loadVoiceWakeMode())
|
||||
val voiceWakeMode: StateFlow<VoiceWakeMode> = _voiceWakeMode
|
||||
|
||||
private val _voiceMicEnabled = MutableStateFlow(plainPrefs.getBoolean(voiceMicEnabledKey, false))
|
||||
val voiceMicEnabled: StateFlow<Boolean> = _voiceMicEnabled
|
||||
private val _talkEnabled = MutableStateFlow(plainPrefs.getBoolean("talk.enabled", false))
|
||||
val talkEnabled: StateFlow<Boolean> = _talkEnabled
|
||||
|
||||
private val _speakerEnabled = MutableStateFlow(plainPrefs.getBoolean("voice.speakerEnabled", true))
|
||||
val speakerEnabled: StateFlow<Boolean> = _speakerEnabled
|
||||
@@ -479,9 +478,9 @@ class SecurePrefs(
|
||||
_voiceWakeMode.value = mode
|
||||
}
|
||||
|
||||
fun setVoiceMicEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean(voiceMicEnabledKey, value) }
|
||||
_voiceMicEnabled.value = value
|
||||
fun setTalkEnabled(value: Boolean) {
|
||||
plainPrefs.edit { putBoolean("talk.enabled", value) }
|
||||
_talkEnabled.value = value
|
||||
}
|
||||
|
||||
fun setSpeakerEnabled(value: Boolean) {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
enum class VoiceCaptureMode {
|
||||
Off,
|
||||
ManualMic,
|
||||
TalkMode,
|
||||
}
|
||||
@@ -35,11 +35,10 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material.icons.filled.RecordVoiceOver
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeOff
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -70,7 +69,6 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.VoiceCaptureMode
|
||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||
import ai.openclaw.app.voice.VoiceConversationRole
|
||||
import kotlin.math.max
|
||||
@@ -83,7 +81,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val gatewayStatus by viewModel.statusText.collectAsState()
|
||||
val voiceCaptureMode by viewModel.voiceCaptureMode.collectAsState()
|
||||
val micEnabled by viewModel.micEnabled.collectAsState()
|
||||
val micCooldown by viewModel.micCooldown.collectAsState()
|
||||
val speakerEnabled by viewModel.speakerEnabled.collectAsState()
|
||||
@@ -93,15 +90,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val micConversation by viewModel.micConversation.collectAsState()
|
||||
val micInputLevel by viewModel.micInputLevel.collectAsState()
|
||||
val micIsSending by viewModel.micIsSending.collectAsState()
|
||||
val talkModeEnabled by viewModel.talkModeEnabled.collectAsState()
|
||||
val talkModeListening by viewModel.talkModeListening.collectAsState()
|
||||
val talkModeSpeaking by viewModel.talkModeSpeaking.collectAsState()
|
||||
|
||||
val hasStreamingAssistant = micConversation.any { it.role == VoiceConversationRole.Assistant && it.isStreaming }
|
||||
val showThinkingBubble = micIsSending && !hasStreamingAssistant
|
||||
|
||||
var hasMicPermission by remember { mutableStateOf(context.hasRecordAudioPermission()) }
|
||||
var pendingVoicePermissionAction by remember { mutableStateOf<PendingVoicePermissionAction?>(null) }
|
||||
var pendingMicEnable by remember { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(lifecycleOwner, context) {
|
||||
val observer =
|
||||
@@ -113,7 +107,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
// Manual mic is tied to the Voice tab; Talk Mode is explicit and can continue.
|
||||
// Stop TTS when leaving the voice screen
|
||||
viewModel.setVoiceScreenActive(false)
|
||||
}
|
||||
}
|
||||
@@ -121,14 +115,10 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val requestMicPermission =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasMicPermission = granted
|
||||
if (granted) {
|
||||
when (pendingVoicePermissionAction) {
|
||||
PendingVoicePermissionAction.ManualMic -> viewModel.setMicEnabled(true)
|
||||
PendingVoicePermissionAction.TalkMode -> viewModel.setTalkModeEnabled(true)
|
||||
null -> Unit
|
||||
}
|
||||
if (granted && pendingMicEnable) {
|
||||
viewModel.setMicEnabled(true)
|
||||
}
|
||||
pendingVoicePermissionAction = null
|
||||
pendingMicEnable = false
|
||||
}
|
||||
|
||||
LaunchedEffect(micConversation.size, showThinkingBubble) {
|
||||
@@ -171,12 +161,12 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
tint = mobileTextTertiary,
|
||||
)
|
||||
Text(
|
||||
"Tap mic or Talk",
|
||||
"Tap the mic to start",
|
||||
style = mobileHeadline,
|
||||
color = mobileTextSecondary,
|
||||
)
|
||||
Text(
|
||||
"Mic sends turns; Talk keeps the conversation open.",
|
||||
"Each pause sends a turn automatically.",
|
||||
style = mobileCallout,
|
||||
color = mobileTextTertiary,
|
||||
)
|
||||
@@ -273,7 +263,7 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
if (hasMicPermission) {
|
||||
viewModel.setMicEnabled(true)
|
||||
} else {
|
||||
pendingVoicePermissionAction = PendingVoicePermissionAction.ManualMic
|
||||
pendingMicEnable = true
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
@@ -297,39 +287,11 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (talkModeEnabled) {
|
||||
viewModel.setTalkModeEnabled(false)
|
||||
return@IconButton
|
||||
}
|
||||
if (hasMicPermission) {
|
||||
viewModel.setTalkModeEnabled(true)
|
||||
} else {
|
||||
pendingVoicePermissionAction = PendingVoicePermissionAction.TalkMode
|
||||
requestMicPermission.launch(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(48.dp),
|
||||
colors =
|
||||
IconButtonDefaults.iconButtonColors(
|
||||
containerColor = if (talkModeEnabled) mobileSuccessSoft else mobileSurface,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RecordVoiceOver,
|
||||
contentDescription = if (talkModeEnabled) "Turn Talk Mode off" else "Turn Talk Mode on",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (talkModeEnabled) mobileSuccess else mobileTextSecondary,
|
||||
)
|
||||
}
|
||||
// Invisible spacer to balance the row (matches speaker column width)
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
if (talkModeEnabled) "Talk on" else "Talk",
|
||||
style = mobileCaption2,
|
||||
color = if (talkModeEnabled) mobileSuccess else mobileTextTertiary,
|
||||
)
|
||||
Text("", style = mobileCaption2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,9 +299,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
val queueCount = micQueuedMessages.size
|
||||
val stateText =
|
||||
when {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeSpeaking -> "Talk speaking"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode && talkModeListening -> "Talk listening"
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> "Talk on"
|
||||
queueCount > 0 -> "$queueCount queued"
|
||||
micIsSending -> "Sending"
|
||||
micCooldown -> "Cooldown"
|
||||
@@ -348,15 +307,14 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
val stateColor =
|
||||
when {
|
||||
voiceCaptureMode == VoiceCaptureMode.TalkMode -> mobileSuccess
|
||||
micEnabled -> mobileSuccess
|
||||
micIsSending -> mobileAccent
|
||||
else -> mobileTextSecondary
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = if (micEnabled || talkModeEnabled) mobileSuccessSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (micEnabled || talkModeEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder),
|
||||
color = if (micEnabled) mobileSuccessSoft else mobileSurface,
|
||||
border = BorderStroke(1.dp, if (micEnabled) mobileSuccess.copy(alpha = 0.3f) else mobileBorder),
|
||||
) {
|
||||
Text(
|
||||
"$gatewayStatus · $stateText",
|
||||
@@ -395,11 +353,6 @@ fun VoiceTabScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
private enum class PendingVoicePermissionAction {
|
||||
ManualMic,
|
||||
TalkMode,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
val isUser = entry.role == VoiceConversationRole.User
|
||||
|
||||
@@ -226,15 +226,14 @@ class TalkModeManager(
|
||||
// If this is a response we initiated, handle normally below.
|
||||
// Otherwise, if ttsOnAllResponses, finish streaming TTS on terminal events.
|
||||
val pending = pendingRunId
|
||||
val knownRun = pending == runId || hasRunCompletion(runId)
|
||||
if (!knownRun) {
|
||||
if (pending == null || runId != pending) {
|
||||
if (ttsOnAllResponses && state == "final") {
|
||||
val text = extractTextFromChatEventMessage(obj["message"])
|
||||
if (!text.isNullOrBlank()) {
|
||||
playTtsForText(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
if (pending == null || runId != pending) return
|
||||
}
|
||||
Log.d(tag, "chat event arrived runId=$runId state=$state pendingRunId=$pendingRunId")
|
||||
val terminal =
|
||||
@@ -540,7 +539,6 @@ class TalkModeManager(
|
||||
|
||||
private suspend fun sendChat(message: String, session: GatewaySession): String {
|
||||
val runId = UUID.randomUUID().toString()
|
||||
armPendingRun(runId)
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("sessionKey", JsonPrimitive(mainSessionKey.ifBlank { "main" }))
|
||||
@@ -549,29 +547,19 @@ class TalkModeManager(
|
||||
put("timeoutMs", JsonPrimitive(30_000))
|
||||
put("idempotencyKey", JsonPrimitive(runId))
|
||||
}
|
||||
try {
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
}
|
||||
return parsed
|
||||
} catch (err: Throwable) {
|
||||
clearPendingRun(runId)
|
||||
throw err
|
||||
val res = session.request("chat.send", params.toString())
|
||||
val parsed = parseRunId(res) ?: runId
|
||||
if (parsed != runId) {
|
||||
pendingRunId = parsed
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
private suspend fun waitForChatFinal(runId: String): Boolean {
|
||||
consumeRunCompletion(runId)?.let { return it }
|
||||
val deferred =
|
||||
if (pendingRunId == runId) {
|
||||
pendingFinal ?: armPendingRun(runId)
|
||||
} else {
|
||||
armPendingRun(runId)
|
||||
}
|
||||
|
||||
consumeRunCompletion(runId)?.let { return it }
|
||||
pendingFinal?.cancel()
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
pendingRunId = runId
|
||||
pendingFinal = deferred
|
||||
|
||||
val result =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -582,25 +570,11 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (!result && pendingRunId == runId) {
|
||||
clearPendingRun(runId)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun armPendingRun(runId: String): CompletableDeferred<Boolean> {
|
||||
pendingFinal?.cancel()
|
||||
val deferred = CompletableDeferred<Boolean>()
|
||||
pendingRunId = runId
|
||||
pendingFinal = deferred
|
||||
return deferred
|
||||
}
|
||||
|
||||
private fun clearPendingRun(runId: String) {
|
||||
if (pendingRunId == runId) {
|
||||
if (!result) {
|
||||
pendingFinal = null
|
||||
pendingRunId = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun cacheRunCompletion(runId: String, isFinal: Boolean) {
|
||||
@@ -619,12 +593,6 @@ class TalkModeManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasRunCompletion(runId: String): Boolean {
|
||||
synchronized(completedRunsLock) {
|
||||
return completedRunStates.containsKey(runId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeRunText(runId: String): String? {
|
||||
synchronized(completedRunsLock) {
|
||||
return completedRunTexts.remove(runId)
|
||||
|
||||
@@ -2,7 +2,6 @@ package ai.openclaw.app
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
@@ -31,35 +30,6 @@ class NodeForegroundServiceTest {
|
||||
assertEquals(expectedFlags, savedIntent.flags and expectedFlags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foregroundServiceTypesForVoiceMode_addsMicrophoneOnlyForTalkMode() {
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.Off),
|
||||
)
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.ManualMic),
|
||||
)
|
||||
assertEquals(
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
|
||||
foregroundServiceTypesForVoiceMode(VoiceCaptureMode.TalkMode),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceNotificationSuffixReflectsActiveCaptureMode() {
|
||||
assertEquals("", voiceNotificationSuffix(VoiceCaptureMode.Off, false, false, false, false))
|
||||
assertEquals(
|
||||
" · Mic: Listening",
|
||||
voiceNotificationSuffix(VoiceCaptureMode.ManualMic, true, true, false, false),
|
||||
)
|
||||
assertEquals(
|
||||
" · Talk: Speaking",
|
||||
voiceNotificationSuffix(VoiceCaptureMode.TalkMode, false, false, true, true),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNotification(service: NodeForegroundService): Notification {
|
||||
val method =
|
||||
NodeForegroundService::class.java.getDeclaredMethod(
|
||||
|
||||
@@ -2,9 +2,7 @@ package ai.openclaw.app
|
||||
|
||||
import android.content.Context
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -24,32 +22,6 @@ class SecurePrefsTest {
|
||||
assertEquals("whileUsing", plainPrefs.getString("location.enabledMode", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun voiceMicEnabled_ignoresOldTalkEnabledKey() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().putBoolean("talk.enabled", true).commit()
|
||||
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
assertFalse(prefs.voiceMicEnabled.value)
|
||||
assertFalse(plainPrefs.contains("voice.micEnabled"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setVoiceMicEnabled_persistsNewKeyOnly() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val plainPrefs = context.getSharedPreferences("openclaw.node", Context.MODE_PRIVATE)
|
||||
plainPrefs.edit().clear().putBoolean("talk.enabled", false).commit()
|
||||
val prefs = SecurePrefs(context)
|
||||
|
||||
prefs.setVoiceMicEnabled(true)
|
||||
|
||||
assertTrue(prefs.voiceMicEnabled.value)
|
||||
assertTrue(plainPrefs.getBoolean("voice.micEnabled", false))
|
||||
assertFalse(plainPrefs.getBoolean("talk.enabled", false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveGatewayBootstrapToken_persistsSeparatelyFromSharedToken() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
@@ -5,7 +5,6 @@ import ai.openclaw.app.gateway.DeviceAuthTokenStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -50,34 +49,6 @@ class TalkModeManagerTest {
|
||||
assertEquals(12L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateFinalForPendingTalkRunDoesNotStartAllResponseTts() {
|
||||
val manager = createManager()
|
||||
val final = CompletableDeferred<Boolean>()
|
||||
|
||||
manager.ttsOnAllResponses = true
|
||||
setPrivateField(manager, "pendingRunId", "run-talk")
|
||||
setPrivateField(manager, "pendingFinal", final)
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once"))
|
||||
assertTrue(final.isCompleted)
|
||||
assertEquals(0L, playbackGeneration(manager).get())
|
||||
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-talk", text = "spoken once"))
|
||||
|
||||
assertEquals(0L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonPendingFinalStillUsesAllResponseTts() {
|
||||
val manager = createManager()
|
||||
|
||||
manager.ttsOnAllResponses = true
|
||||
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-other", text = "speak this"))
|
||||
|
||||
assertEquals(1L, playbackGeneration(manager).get())
|
||||
}
|
||||
|
||||
private fun createManager(): TalkModeManager {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
val sessionJob = SupervisorJob()
|
||||
@@ -115,22 +86,6 @@ class TalkModeManagerTest {
|
||||
field.isAccessible = true
|
||||
return field.get(target)
|
||||
}
|
||||
|
||||
private fun chatFinalPayload(runId: String, text: String): String {
|
||||
return """
|
||||
{
|
||||
"runId": "$runId",
|
||||
"sessionKey": "main",
|
||||
"state": "final",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{ "type": "text", "text": "$text" }
|
||||
]
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryDeviceAuthStore : DeviceAuthTokenStore {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# OpenClaw iOS Changelog
|
||||
|
||||
## 2026.4.26 - 2026-04-26
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
## 2026.4.25 - 2026-04-25
|
||||
## 2026.4.24 - 2026-04-24
|
||||
|
||||
Maintenance update for the current OpenClaw development release.
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Source of truth: apps/ios/version.json
|
||||
// Generated by scripts/ios-sync-versioning.ts.
|
||||
|
||||
OPENCLAW_IOS_VERSION = 2026.4.26
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.26
|
||||
OPENCLAW_IOS_VERSION = 2026.4.24
|
||||
OPENCLAW_MARKETING_VERSION = 2026.4.24
|
||||
OPENCLAW_BUILD_VERSION = 1
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -21,7 +21,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage(TalkSpeechLocale.storageKey) private var talkSpeechLocale: String = TalkSpeechLocale.automaticID
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@@ -279,11 +278,6 @@ struct SettingsTab: View {
|
||||
help: "Enables voice conversation mode with your connected OpenClaw agent.") { newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
Picker("Speech Language", selection: self.$talkSpeechLocale) {
|
||||
ForEach(TalkSpeechLocale.supportedOptions()) { option in
|
||||
Text(option.label).tag(option.id)
|
||||
}
|
||||
}
|
||||
self.featureToggle(
|
||||
"Background Listening",
|
||||
isOn: self.$talkBackgroundEnabled,
|
||||
|
||||
@@ -12,7 +12,6 @@ struct TalkModeGatewayConfigState {
|
||||
let rawConfigApiKey: String?
|
||||
let interruptOnSpeech: Bool?
|
||||
let silenceTimeoutMs: Int
|
||||
let speechLocaleID: String?
|
||||
}
|
||||
|
||||
enum TalkModeGatewayConfigParser {
|
||||
@@ -54,7 +53,6 @@ enum TalkModeGatewayConfigParser {
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
let speechLocaleID = TalkConfigParsing.resolvedSpeechLocaleID(talk)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider: activeProvider,
|
||||
@@ -66,7 +64,6 @@ enum TalkModeGatewayConfigParser {
|
||||
defaultOutputFormat: defaultOutputFormat,
|
||||
rawConfigApiKey: rawConfigApiKey,
|
||||
interruptOnSpeech: interruptOnSpeech,
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
speechLocaleID: speechLocaleID)
|
||||
silenceTimeoutMs: silenceTimeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,6 @@ final class TalkModeManager: NSObject {
|
||||
private var apiKey: String?
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var interruptOnSpeech: Bool = true
|
||||
private var gatewaySpeechLocaleID: String?
|
||||
private var mainSessionKey: String = "main"
|
||||
private var fallbackVoiceId: String?
|
||||
private var lastPlaybackWasPCM: Bool = false
|
||||
@@ -501,17 +500,12 @@ final class TalkModeManager: NSObject {
|
||||
#endif
|
||||
|
||||
self.stopRecognition()
|
||||
let localSpeechLocale = UserDefaults.standard.string(forKey: TalkSpeechLocale.storageKey)
|
||||
let resolvedSpeech = TalkSpeechLocale.makeRecognizer(
|
||||
localSelection: localSpeechLocale,
|
||||
gatewaySelection: self.gatewaySpeechLocaleID)
|
||||
self.speechRecognizer = resolvedSpeech.recognizer
|
||||
self.speechRecognizer = SFSpeechRecognizer()
|
||||
guard let recognizer = self.speechRecognizer else {
|
||||
throw NSError(domain: "TalkMode", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Speech recognizer unavailable",
|
||||
])
|
||||
}
|
||||
GatewayDiagnostics.log("talk speech: locale=\(resolvedSpeech.localeID ?? "default")")
|
||||
|
||||
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
|
||||
self.recognitionRequest?.shouldReportPartialResults = true
|
||||
@@ -2033,7 +2027,6 @@ extension TalkModeManager {
|
||||
if let interrupt = parsed.interruptOnSpeech {
|
||||
self.interruptOnSpeech = interrupt
|
||||
}
|
||||
self.gatewaySpeechLocaleID = parsed.speechLocaleID
|
||||
self.silenceWindow = TimeInterval(parsed.silenceTimeoutMs) / 1000
|
||||
if parsed.normalizedPayload || parsed.defaultVoiceId != nil || parsed.rawConfigApiKey != nil {
|
||||
GatewayDiagnostics.log(
|
||||
@@ -2048,7 +2041,6 @@ extension TalkModeManager {
|
||||
self.gatewayTalkDefaultModelId = nil
|
||||
self.gatewayTalkApiKeyConfigured = false
|
||||
self.gatewayTalkConfigLoaded = false
|
||||
self.gatewaySpeechLocaleID = nil
|
||||
self.silenceWindow = TimeInterval(Self.defaultSilenceTimeoutMs) / 1000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Speech
|
||||
|
||||
enum TalkSpeechLocale {
|
||||
static let storageKey = "talk.speechLocale"
|
||||
static let automaticID = "auto"
|
||||
static let fallbackLocaleID = "en-US"
|
||||
|
||||
struct Option: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
}
|
||||
|
||||
static func supportedOptions(
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> [Option] {
|
||||
var seen = Set<String>()
|
||||
let dynamic: [Option] = supportedLocales
|
||||
.compactMap { locale in
|
||||
let id = self.canonicalID(locale.identifier)
|
||||
guard seen.insert(id).inserted else { return nil }
|
||||
return Option(id: id, label: self.friendlyName(for: locale))
|
||||
}
|
||||
.sorted { (lhs: Option, rhs: Option) in
|
||||
lhs.label.localizedCaseInsensitiveCompare(rhs.label) == .orderedAscending
|
||||
}
|
||||
return [Option(id: self.automaticID, label: "Automatic")] + dynamic
|
||||
}
|
||||
|
||||
static func resolvedLocaleID(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
deviceLocaleID: String = Locale.autoupdatingCurrent.identifier,
|
||||
fallbackLocaleID: String = Self.fallbackLocaleID,
|
||||
supportedLocaleIDs: Set<String>
|
||||
) -> String? {
|
||||
TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
||||
preferredLocaleIDs: [
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(localSelection),
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(gatewaySelection),
|
||||
deviceLocaleID,
|
||||
],
|
||||
fallbackLocaleID: fallbackLocaleID,
|
||||
supportedLocaleIDs: supportedLocaleIDs)
|
||||
}
|
||||
|
||||
static func makeRecognizer(
|
||||
localSelection: String?,
|
||||
gatewaySelection: String?,
|
||||
supportedLocales: Set<Locale> = SFSpeechRecognizer.supportedLocales()
|
||||
) -> (recognizer: SFSpeechRecognizer?, localeID: String?) {
|
||||
let supportedIDs = Set(supportedLocales.map(\.identifier))
|
||||
guard let localeID = self.resolvedLocaleID(
|
||||
localSelection: localSelection,
|
||||
gatewaySelection: gatewaySelection,
|
||||
supportedLocaleIDs: supportedIDs)
|
||||
else {
|
||||
let recognizer = SFSpeechRecognizer()
|
||||
return (recognizer, recognizer?.locale.identifier)
|
||||
}
|
||||
|
||||
if let recognizer = SFSpeechRecognizer(locale: Locale(identifier: localeID)) {
|
||||
return (recognizer, localeID)
|
||||
}
|
||||
|
||||
let recognizer = SFSpeechRecognizer()
|
||||
return (recognizer, recognizer?.locale.identifier)
|
||||
}
|
||||
|
||||
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
|
||||
}
|
||||
|
||||
private static func normalizedLocaleID(_ raw: String?) -> String? {
|
||||
TalkConfigParsing.normalizedSpeechLocaleID(raw)
|
||||
}
|
||||
|
||||
private static func canonicalID(_ raw: String) -> String {
|
||||
raw.replacingOccurrences(of: "_", with: "-")
|
||||
}
|
||||
|
||||
private static func friendlyName(for locale: Locale) -> String {
|
||||
let id = self.canonicalID(locale.identifier)
|
||||
let cleanLocale = Locale(identifier: id)
|
||||
if let langCode = cleanLocale.language.languageCode?.identifier,
|
||||
let lang = cleanLocale.localizedString(forLanguageCode: langCode),
|
||||
let regionCode = cleanLocale.region?.identifier,
|
||||
let region = cleanLocale.localizedString(forRegionCode: regionCode)
|
||||
{
|
||||
return "\(lang) (\(region))"
|
||||
}
|
||||
if let langCode = cleanLocale.language.languageCode?.identifier,
|
||||
let lang = cleanLocale.localizedString(forLanguageCode: langCode)
|
||||
{
|
||||
return lang
|
||||
}
|
||||
return cleanLocale.localizedString(forIdentifier: id) ?? id
|
||||
}
|
||||
}
|
||||
@@ -47,16 +47,6 @@ private let iOSSilenceTimeoutMs = 900
|
||||
fallback: iOSSilenceTimeoutMs) == 1500)
|
||||
}
|
||||
|
||||
@Test func readsConfiguredSpeechLocale() {
|
||||
let talk: [String: Any] = [
|
||||
"speechLocale": " ru-RU ",
|
||||
]
|
||||
|
||||
#expect(
|
||||
TalkConfigParsing.resolvedSpeechLocaleID(
|
||||
TalkConfigParsing.bridgeFoundationDictionary(talk)) == "ru-RU")
|
||||
}
|
||||
|
||||
@Test func defaultsSilenceTimeoutMsWhenMissing() {
|
||||
#expect(TalkConfigParsing.resolvedSilenceTimeoutMs(nil, fallback: iOSSilenceTimeoutMs) == iOSSilenceTimeoutMs)
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct TalkSpeechLocaleTests {
|
||||
@Test func localSelectionOverridesGatewayConfig() {
|
||||
let locale = TalkSpeechLocale.resolvedLocaleID(
|
||||
localSelection: "de-DE",
|
||||
gatewaySelection: "ru-RU",
|
||||
deviceLocaleID: "en-US",
|
||||
supportedLocaleIDs: ["de-DE", "ru-RU", "en-US"])
|
||||
|
||||
#expect(locale == "de-DE")
|
||||
}
|
||||
|
||||
@Test func automaticLocalSelectionAllowsGatewayConfig() {
|
||||
let locale = TalkSpeechLocale.resolvedLocaleID(
|
||||
localSelection: TalkSpeechLocale.automaticID,
|
||||
gatewaySelection: "ru_RU",
|
||||
deviceLocaleID: "en-US",
|
||||
supportedLocaleIDs: ["ru-RU", "en-US"])
|
||||
|
||||
#expect(locale == "ru-RU")
|
||||
}
|
||||
|
||||
@Test func unsupportedConfiguredLocaleFallsBackToDeviceThenEnglish() {
|
||||
let deviceLocale = TalkSpeechLocale.resolvedLocaleID(
|
||||
localSelection: "zz-ZZ",
|
||||
gatewaySelection: nil,
|
||||
deviceLocaleID: "fr-FR",
|
||||
supportedLocaleIDs: ["fr-FR", "en-US"])
|
||||
let english = TalkSpeechLocale.resolvedLocaleID(
|
||||
localSelection: "zz-ZZ",
|
||||
gatewaySelection: nil,
|
||||
deviceLocaleID: "yy-YY",
|
||||
supportedLocaleIDs: ["en-US"])
|
||||
|
||||
#expect(deviceLocale == "fr-FR")
|
||||
#expect(english == "en-US")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "2026.4.26"
|
||||
"version": "2026.4.24"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import ServiceManagement
|
||||
import SwiftUI
|
||||
|
||||
@@ -177,23 +176,6 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var talkPhaseSoundsEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkPhaseSoundsEnabled, forKey: talkPhaseSoundsEnabledKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var talkShiftToStopEnabled: Bool {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
UserDefaults.standard.set(self.talkShiftToStopEnabled, forKey: talkShiftToStopEnabledKey)
|
||||
Task { TalkSpeechInterruptMonitor.shared.setEnabled(self.talkShiftToStopEnabled && self.talkEnabled) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gateway-provided UI accent color (hex). Optional; clients provide a default.
|
||||
var seamColorHex: String?
|
||||
|
||||
@@ -327,18 +309,6 @@ final class AppState {
|
||||
self.voiceWakeTriggersTalkMode = UserDefaults.standard
|
||||
.object(forKey: voiceWakeTriggersTalkModeKey) as? Bool ?? false
|
||||
self.talkEnabled = UserDefaults.standard.bool(forKey: talkEnabledKey)
|
||||
if let storedPhaseSounds = UserDefaults.standard.object(forKey: talkPhaseSoundsEnabledKey) as? Bool {
|
||||
self.talkPhaseSoundsEnabled = storedPhaseSounds
|
||||
} else {
|
||||
self.talkPhaseSoundsEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: talkPhaseSoundsEnabledKey)
|
||||
}
|
||||
if let storedShiftToStop = UserDefaults.standard.object(forKey: talkShiftToStopEnabledKey) as? Bool {
|
||||
self.talkShiftToStopEnabled = storedShiftToStop
|
||||
} else {
|
||||
self.talkShiftToStopEnabled = true
|
||||
UserDefaults.standard.set(true, forKey: talkShiftToStopEnabledKey)
|
||||
}
|
||||
self.seamColorHex = nil
|
||||
if let storedHeartbeats = UserDefaults.standard.object(forKey: heartbeatsEnabledKey) as? Bool {
|
||||
self.heartbeatsEnabled = storedHeartbeats
|
||||
@@ -367,8 +337,7 @@ final class AppState {
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
self.remoteTarget = "\(NSUserName())@\(host)"
|
||||
} else {
|
||||
@@ -437,30 +406,6 @@ final class AppState {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func sshTunnelGatewayUrl(existingUrl: String?, expectedRemoteHost: String?) -> String {
|
||||
let fallback = "ws://127.0.0.1:18789"
|
||||
let trimmed = existingUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty,
|
||||
let url = URL(string: trimmed),
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return fallback
|
||||
}
|
||||
|
||||
let preservePort: Bool = if LoopbackHost.isLoopbackHost(host) {
|
||||
true
|
||||
} else if let expectedRemoteHost {
|
||||
OpenClawConfigFile.canonicalHostForComparison(host) ==
|
||||
OpenClawConfigFile.canonicalHostForComparison(expectedRemoteHost)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
guard preservePort else { return fallback }
|
||||
|
||||
return "ws://127.0.0.1:\(url.port ?? 18789)"
|
||||
}
|
||||
|
||||
private static func updateGatewayString(
|
||||
_ dictionary: inout [String: Any],
|
||||
key: String,
|
||||
@@ -517,14 +462,17 @@ final class AppState {
|
||||
case .ssh:
|
||||
changed = Self.updateGatewayString(&remote, key: "transport", value: nil) || changed
|
||||
|
||||
if let host = draft.remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(draft.remoteTarget)
|
||||
let expectedRemoteHost = CommandResolver.parseSSHTarget(sanitizedTarget)?.host ?? draft.remoteHost
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let desiredUrl = Self.sshTunnelGatewayUrl(
|
||||
existingUrl: existingUrl,
|
||||
expectedRemoteHost: expectedRemoteHost)
|
||||
changed = Self.updateGatewayString(&remote, key: "url", value: desiredUrl) || changed
|
||||
changed = Self.updateGatewayString(&remote, key: "sshTarget", value: sanitizedTarget) || changed
|
||||
changed = Self.updateGatewayString(&remote, key: "sshIdentity", value: draft.remoteIdentity) || changed
|
||||
}
|
||||
@@ -592,8 +540,7 @@ final class AppState {
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
remoteTransport != .direct,
|
||||
let host = AppState.remoteHost(from: remoteUrl),
|
||||
!LoopbackHost.isLoopbackHost(host)
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
}
|
||||
@@ -831,8 +778,6 @@ extension AppState {
|
||||
state.voiceWakeAdditionalLocaleIDs = ["en-US", "de-DE"]
|
||||
state.voicePushToTalkEnabled = false
|
||||
state.talkEnabled = false
|
||||
state.talkPhaseSoundsEnabled = true
|
||||
state.talkShiftToStopEnabled = true
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import Foundation
|
||||
import OpenClawProtocol
|
||||
|
||||
func whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: Date,
|
||||
timeoutMs: Int,
|
||||
didRunFinalWait: inout Bool,
|
||||
now: Date = Date()) -> Int?
|
||||
{
|
||||
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
|
||||
let remainingMs = max(timeoutMs - elapsedMs, 0)
|
||||
if remainingMs > 0 {
|
||||
return remainingMs
|
||||
}
|
||||
if didRunFinalWait {
|
||||
return nil
|
||||
}
|
||||
didRunFinalWait = true
|
||||
return 1
|
||||
}
|
||||
|
||||
extension ChannelsStore {
|
||||
func start() {
|
||||
guard !self.isPreview else { return }
|
||||
@@ -95,28 +77,18 @@ extension ChannelsStore {
|
||||
guard !self.whatsappBusy else { return }
|
||||
self.whatsappBusy = true
|
||||
defer { self.whatsappBusy = false }
|
||||
let startedAt = Date()
|
||||
var didRunFinalWait = false
|
||||
do {
|
||||
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: timeoutMs,
|
||||
didRunFinalWait: &didRunFinalWait)
|
||||
{
|
||||
var params: [String: AnyCodable] = [
|
||||
"timeoutMs": AnyCodable(remainingMs),
|
||||
]
|
||||
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
|
||||
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
|
||||
}
|
||||
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .webLoginWait,
|
||||
params: params,
|
||||
timeoutMs: Double(remainingMs) + 5000)
|
||||
self.applyWhatsAppLoginWaitResult(result)
|
||||
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
|
||||
break
|
||||
}
|
||||
let params: [String: AnyCodable] = [
|
||||
"timeoutMs": AnyCodable(timeoutMs),
|
||||
]
|
||||
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .webLoginWait,
|
||||
params: params,
|
||||
timeoutMs: Double(timeoutMs) + 5000)
|
||||
self.whatsappLoginMessage = result.message
|
||||
self.whatsappLoginConnected = result.connected
|
||||
if result.connected {
|
||||
self.whatsappLoginQrDataUrl = nil
|
||||
}
|
||||
} catch {
|
||||
self.whatsappLoginMessage = error.localizedDescription
|
||||
@@ -179,10 +151,9 @@ private struct WhatsAppLoginStartResult: Codable {
|
||||
let connected: Bool?
|
||||
}
|
||||
|
||||
struct WhatsAppLoginWaitResult: Codable {
|
||||
private struct WhatsAppLoginWaitResult: Codable {
|
||||
let connected: Bool
|
||||
let message: String
|
||||
let qrDataUrl: String?
|
||||
}
|
||||
|
||||
private struct ChannelLogoutResult: Codable {
|
||||
|
||||
@@ -290,16 +290,6 @@ final class ChannelsStore {
|
||||
return self.snapshot?.channelOrder ?? []
|
||||
}
|
||||
|
||||
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
|
||||
self.whatsappLoginMessage = result.message
|
||||
self.whatsappLoginConnected = result.connected
|
||||
if let qrDataUrl = result.qrDataUrl {
|
||||
self.whatsappLoginQrDataUrl = qrDataUrl
|
||||
} else if result.connected {
|
||||
self.whatsappLoginQrDataUrl = nil
|
||||
}
|
||||
}
|
||||
|
||||
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
|
||||
self.isPreview = isPreview
|
||||
}
|
||||
|
||||
@@ -24,8 +24,6 @@ let voiceWakeAdditionalLocalesKey = "openclaw.voiceWakeAdditionalLocaleIDs"
|
||||
let voicePushToTalkEnabledKey = "openclaw.voicePushToTalkEnabled"
|
||||
let voiceWakeTriggersTalkModeKey = "openclaw.voiceWakeTriggersTalkMode"
|
||||
let talkEnabledKey = "openclaw.talkEnabled"
|
||||
let talkPhaseSoundsEnabledKey = "openclaw.talkPhaseSoundsEnabled"
|
||||
let talkShiftToStopEnabledKey = "openclaw.talkShiftToStopEnabled"
|
||||
let iconOverrideKey = "openclaw.iconOverride"
|
||||
let connectionModeKey = "openclaw.connectionMode"
|
||||
let remoteTargetKey = "openclaw.remoteTarget"
|
||||
|
||||
@@ -9,15 +9,8 @@ enum ExecAllowlistMatcher {
|
||||
for entry in entries {
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(entry.pattern) {
|
||||
case let .valid(pattern):
|
||||
if ExecApprovalHelpers.patternHasPathSelector(pattern) {
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
} else if pattern != "*",
|
||||
!ExecApprovalHelpers.patternHasPathSelector(rawExecutable),
|
||||
self.matchesExecutableBasename(pattern: pattern, resolution: resolution)
|
||||
{
|
||||
return entry
|
||||
}
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
case .invalid:
|
||||
continue
|
||||
}
|
||||
@@ -41,20 +34,6 @@ enum ExecAllowlistMatcher {
|
||||
return matches
|
||||
}
|
||||
|
||||
private static func matchesExecutableBasename(
|
||||
pattern: String,
|
||||
resolution: ExecCommandResolution) -> Bool
|
||||
{
|
||||
var candidates = Set<String>()
|
||||
if !resolution.executableName.isEmpty {
|
||||
candidates.insert(resolution.executableName)
|
||||
}
|
||||
if let resolvedPath = resolution.resolvedPath, !resolvedPath.isEmpty {
|
||||
candidates.insert(URL(fileURLWithPath: resolvedPath).lastPathComponent)
|
||||
}
|
||||
return candidates.contains { self.matches(pattern: pattern, target: $0) }
|
||||
}
|
||||
|
||||
private static func matches(pattern: String, target: String) -> Bool {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
|
||||
@@ -616,18 +616,6 @@ enum ExecApprovalsStore {
|
||||
let trimmedResolved = entry.lastResolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let normalizedResolved = trimmedResolved.isEmpty ? nil : trimmedResolved
|
||||
|
||||
if !ExecApprovalHelpers.patternHasPathSelector(trimmedPattern),
|
||||
!trimmedResolved.isEmpty,
|
||||
case let .valid(migratedPattern) = ExecApprovalHelpers.validateAllowlistPattern(trimmedResolved)
|
||||
{
|
||||
return ExecAllowlistEntry(
|
||||
id: entry.id,
|
||||
pattern: migratedPattern,
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: normalizedResolved)
|
||||
}
|
||||
|
||||
switch ExecApprovalHelpers.validateAllowlistPattern(trimmedPattern) {
|
||||
case let .valid(pattern):
|
||||
return ExecAllowlistEntry(
|
||||
@@ -736,10 +724,11 @@ enum ExecApprovalHelpers {
|
||||
static func validateAllowlistPattern(_ pattern: String?) -> ExecAllowlistPatternValidation {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return .invalid(.empty) }
|
||||
guard self.containsPathComponent(trimmed) else { return .invalid(.missingPathComponent) }
|
||||
return .valid(trimmed)
|
||||
}
|
||||
|
||||
static func isValidAllowlistPattern(_ pattern: String?) -> Bool {
|
||||
static func isPathPattern(_ pattern: String?) -> Bool {
|
||||
switch self.validateAllowlistPattern(pattern) {
|
||||
case .valid:
|
||||
true
|
||||
@@ -748,11 +737,6 @@ enum ExecApprovalHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
static func isPathPattern(_ pattern: String?) -> Bool {
|
||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return self.patternHasPathSelector(trimmed)
|
||||
}
|
||||
|
||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
@@ -775,7 +759,7 @@ enum ExecApprovalHelpers {
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
|
||||
static func patternHasPathSelector(_ pattern: String) -> Bool {
|
||||
private static func containsPathComponent(_ pattern: String) -> Bool {
|
||||
pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ struct GatewayAgentInvocation {
|
||||
var channel: GatewayAgentChannel = .last
|
||||
var timeoutSeconds: Int?
|
||||
var idempotencyKey: String = UUID().uuidString
|
||||
var voiceWakeTrigger: String?
|
||||
}
|
||||
|
||||
/// Single, shared Gateway websocket connection for the whole app.
|
||||
@@ -71,7 +70,6 @@ actor GatewayConnection {
|
||||
case wizardStatus = "wizard.status"
|
||||
case talkConfig = "talk.config"
|
||||
case talkMode = "talk.mode"
|
||||
case talkSpeak = "talk.speak"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
case channelsLogout = "channels.logout"
|
||||
@@ -500,10 +498,6 @@ extension GatewayConnection {
|
||||
if let timeout = invocation.timeoutSeconds {
|
||||
params["timeout"] = AnyCodable(timeout)
|
||||
}
|
||||
if let trigger = invocation.voiceWakeTrigger {
|
||||
params["voiceWakeTrigger"] = AnyCodable(
|
||||
trigger.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
|
||||
do {
|
||||
try await self.requestVoid(method: .agent, params: params)
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import Foundation
|
||||
import OpenClawDiscovery
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
enum GatewayDiscoverySelectionSupport {
|
||||
private static let defaultSshTunnelGatewayUrl = "ws://127.0.0.1:18789"
|
||||
|
||||
static func applyRemoteSelection(
|
||||
gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
state: AppState)
|
||||
@@ -17,40 +13,18 @@ enum GatewayDiscoverySelectionSupport {
|
||||
state.remoteTransport = preferredTransport
|
||||
}
|
||||
|
||||
if preferredTransport == .direct {
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
} else {
|
||||
state.remoteUrl = self.sshTunnelGatewayUrl(current: state.remoteUrl)
|
||||
}
|
||||
state.remoteUrl = GatewayDiscoveryHelpers.directUrl(for: gateway) ?? ""
|
||||
state.remoteTarget = GatewayDiscoveryHelpers.sshTarget(for: gateway) ?? ""
|
||||
|
||||
if preferredTransport == .direct {
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
if let endpoint = GatewayDiscoveryHelpers.serviceEndpoint(for: gateway) {
|
||||
OpenClawConfigFile.setRemoteGatewayUrl(
|
||||
host: endpoint.host,
|
||||
port: endpoint.port)
|
||||
} else {
|
||||
OpenClawConfigFile.setRemoteGatewayUrlString(state.remoteUrl)
|
||||
OpenClawConfigFile.clearRemoteGatewayUrl()
|
||||
}
|
||||
}
|
||||
|
||||
private static func sshTunnelGatewayUrl(current: String) -> String {
|
||||
let trimmed = current.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty,
|
||||
let url = URL(string: trimmed),
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!host.isEmpty,
|
||||
LoopbackHost.isLoopbackHost(host)
|
||||
else {
|
||||
return self.defaultSshTunnelGatewayUrl
|
||||
}
|
||||
|
||||
return "ws://127.0.0.1:\(url.port ?? 18789)"
|
||||
}
|
||||
|
||||
static func preferredTransport(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
current: AppState.RemoteTransport) -> AppState.RemoteTransport
|
||||
|
||||
@@ -135,10 +135,6 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
self.osLogger = os.Logger(subsystem: subsystem, category: category)
|
||||
}
|
||||
|
||||
func log(event: LogEvent) {
|
||||
self.writeLog(level: event.level, message: event.message, metadata: event.metadata)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
@@ -147,14 +143,6 @@ struct OpenClawOSLogHandler: AppLogLevelBackedHandler {
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
self.writeLog(level: level, message: message, metadata: metadata)
|
||||
}
|
||||
|
||||
private func writeLog(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?)
|
||||
{
|
||||
let merged = Self.mergeMetadata(self.metadata, metadata)
|
||||
let rendered = Self.renderMessage(message, metadata: merged)
|
||||
@@ -198,17 +186,6 @@ struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
let label: String
|
||||
var metadata: Logger.Metadata = [:]
|
||||
|
||||
func log(event: LogEvent) {
|
||||
self.writeLog(
|
||||
level: event.level,
|
||||
message: event.message,
|
||||
metadata: event.metadata,
|
||||
source: event.source,
|
||||
file: event.file,
|
||||
function: event.function,
|
||||
line: event.line)
|
||||
}
|
||||
|
||||
func log(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
@@ -217,25 +194,6 @@ struct OpenClawFileLogHandler: AppLogLevelBackedHandler {
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
self.writeLog(
|
||||
level: level,
|
||||
message: message,
|
||||
metadata: metadata,
|
||||
source: source,
|
||||
file: file,
|
||||
function: function,
|
||||
line: line)
|
||||
}
|
||||
|
||||
private func writeLog(
|
||||
level: Logger.Level,
|
||||
message: Logger.Message,
|
||||
metadata: Logger.Metadata?,
|
||||
source: String,
|
||||
file: String,
|
||||
function: String,
|
||||
line: UInt)
|
||||
{
|
||||
guard AppLogSettings.fileLoggingEnabled() else { return }
|
||||
let (subsystem, category) = OpenClawLogging.parseLabel(self.label)
|
||||
|
||||
@@ -54,15 +54,8 @@ actor MacNodeBrowserProxy {
|
||||
|
||||
func request(paramsJSON: String?) async throws -> String {
|
||||
let params = try Self.decodeRequestParams(from: paramsJSON)
|
||||
let endpoint = self.endpointProvider()
|
||||
let request = try Self.makeRequest(params: params, endpoint: endpoint)
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await self.performRequest(request)
|
||||
} catch {
|
||||
throw Self.unavailableError(endpoint: endpoint, cause: error)
|
||||
}
|
||||
let request = try Self.makeRequest(params: params, endpoint: self.endpointProvider())
|
||||
let (data, response) = try await self.performRequest(request)
|
||||
let http = try Self.requireHTTPResponse(response)
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw NSError(domain: "MacNodeBrowserProxy", code: http.statusCode, userInfo: [
|
||||
@@ -172,19 +165,6 @@ actor MacNodeBrowserProxy {
|
||||
return http
|
||||
}
|
||||
|
||||
private static func unavailableError(endpoint: Endpoint, cause: Error) -> NSError {
|
||||
let url = endpoint.baseURL.absoluteString
|
||||
let message = """
|
||||
UNAVAILABLE: macOS app node could not reach the local browser control service at \(url). \
|
||||
In remote mode, browser control is owned by the CLI node-host; start `openclaw node start` \
|
||||
on this Mac and target that browser node. Underlying error: \(cause.localizedDescription)
|
||||
"""
|
||||
return NSError(domain: "MacNodeBrowserProxy", code: 9, userInfo: [
|
||||
NSLocalizedDescriptionKey: message,
|
||||
NSUnderlyingErrorKey: cause,
|
||||
])
|
||||
}
|
||||
|
||||
private static func httpErrorMessage(statusCode: Int, data: Data) -> String {
|
||||
if let object = try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) as? [String: Any],
|
||||
let error = object["error"] as? String,
|
||||
|
||||
@@ -116,40 +116,27 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCaps(
|
||||
browserControlEnabled: Bool,
|
||||
cameraEnabled: Bool,
|
||||
locationMode: OpenClawLocationMode,
|
||||
connectionMode: AppState.ConnectionMode) -> [String]
|
||||
{
|
||||
private func currentCaps() -> [String] {
|
||||
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
|
||||
if browserControlEnabled, connectionMode == .local {
|
||||
if OpenClawConfigFile.browserControlEnabled() {
|
||||
caps.append(OpenClawCapability.browser.rawValue)
|
||||
}
|
||||
if cameraEnabled {
|
||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||
caps.append(OpenClawCapability.camera.rawValue)
|
||||
}
|
||||
if locationMode != .off {
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
if OpenClawLocationMode(rawValue: rawLocationMode) != .off {
|
||||
caps.append(OpenClawCapability.location.rawValue)
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
let rawLocationMode = UserDefaults.standard.string(forKey: locationModeKey) ?? "off"
|
||||
return Self.resolvedCaps(
|
||||
browserControlEnabled: OpenClawConfigFile.browserControlEnabled(),
|
||||
cameraEnabled: UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false,
|
||||
locationMode: OpenClawLocationMode(rawValue: rawLocationMode) ?? .off,
|
||||
connectionMode: AppStateStore.shared.connectionMode)
|
||||
}
|
||||
|
||||
private func currentPermissions() async -> [String: Bool] {
|
||||
let statuses = await PermissionManager.status()
|
||||
return Dictionary(uniqueKeysWithValues: statuses.map { ($0.key.rawValue, $0.value) })
|
||||
}
|
||||
|
||||
nonisolated static func resolvedCommands(caps: [String]) -> [String] {
|
||||
private func currentCommands(caps: [String]) -> [String] {
|
||||
var commands: [String] = [
|
||||
OpenClawCanvasCommand.present.rawValue,
|
||||
OpenClawCanvasCommand.hide.rawValue,
|
||||
@@ -184,10 +171,6 @@ final class MacNodeModeCoordinator {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func currentCommands(caps: [String]) -> [String] {
|
||||
Self.resolvedCommands(caps: caps)
|
||||
}
|
||||
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let host = url.host ?? "gateway"
|
||||
|
||||
@@ -192,17 +192,20 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
|
||||
static func remoteGatewayPort(matchingHost sshHost: String) -> Int? {
|
||||
guard let normalizedSshHost = canonicalHostForComparison(sshHost),
|
||||
let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedSshHost.isEmpty,
|
||||
let url = self.remoteGatewayUrl(),
|
||||
let port = url.port,
|
||||
port > 0,
|
||||
let urlHost = url.host,
|
||||
let normalizedUrlHost = canonicalHostForComparison(urlHost)
|
||||
let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!urlHost.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard normalizedSshHost == normalizedUrlHost else { return nil }
|
||||
let sshKey = Self.hostKey(trimmedSshHost)
|
||||
let urlKey = Self.hostKey(urlHost)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil }
|
||||
return port
|
||||
}
|
||||
|
||||
@@ -220,16 +223,6 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
}
|
||||
|
||||
static func setRemoteGatewayUrlString(_ value: String) {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.updateGatewayDict { gateway in
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
remote["url"] = trimmed
|
||||
gateway["remote"] = remote
|
||||
}
|
||||
}
|
||||
|
||||
static func clearRemoteGatewayUrl() {
|
||||
self.updateGatewayDict { gateway in
|
||||
guard var remote = gateway["remote"] as? [String: Any] else { return }
|
||||
@@ -256,17 +249,15 @@ enum OpenClawConfigFile {
|
||||
return url
|
||||
}
|
||||
|
||||
static func canonicalHostForComparison(_ raw: String?) -> String? {
|
||||
guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
||||
!host.isEmpty
|
||||
else {
|
||||
return nil
|
||||
static func hostKey(_ host: String) -> String {
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if trimmed.contains(":") { return trimmed }
|
||||
let digits = CharacterSet(charactersIn: "0123456789.")
|
||||
if trimmed.rangeOfCharacter(from: digits.inverted) == nil {
|
||||
return trimmed
|
||||
}
|
||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
|
||||
while host.hasSuffix(".") {
|
||||
host.removeLast()
|
||||
}
|
||||
return host.isEmpty ? nil : host
|
||||
return trimmed.split(separator: ".").first.map(String.init) ?? trimmed
|
||||
}
|
||||
|
||||
private static func parseConfigData(_ data: Data) -> [String: Any]? {
|
||||
|
||||
@@ -150,11 +150,9 @@ final class RemotePortTunnel {
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard let sshKey = OpenClawConfigFile.canonicalHostForComparison(sshHost),
|
||||
let urlKey = OpenClawConfigFile.canonicalHostForComparison(host)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let sshKey = OpenClawConfigFile.hostKey(sshHost)
|
||||
let urlKey = OpenClawConfigFile.hostKey(host)
|
||||
guard !sshKey.isEmpty, !urlKey.isEmpty else { return nil }
|
||||
guard sshKey == urlKey else {
|
||||
Self.logger.debug(
|
||||
"remote url host mismatch sshHost=\(sshHost, privacy: .public) urlHost=\(host, privacy: .public)")
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.26</string>
|
||||
<string>2026.4.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026042600</string>
|
||||
<string>2026042400</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -105,7 +105,7 @@ struct SystemRunSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add command name or path glob", text: self.$newPattern)
|
||||
TextField("Add allowlist path pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
if self.model.addEntry(self.newPattern) == nil {
|
||||
@@ -113,10 +113,10 @@ struct SystemRunSettingsView: View {
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!self.model.isValidPattern(self.newPattern))
|
||||
.disabled(!self.model.isPathPattern(self.newPattern))
|
||||
}
|
||||
|
||||
Text("Bare names match PATH-resolved commands. Use a path glob for a specific binary.")
|
||||
Text("Path patterns only. Basename entries like \"echo\" are ignored.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
if let validationMessage = self.model.allowlistValidationMessage {
|
||||
@@ -424,8 +424,8 @@ final class ExecApprovalsSettingsModel {
|
||||
self.entries.first(where: { $0.id == id })
|
||||
}
|
||||
|
||||
func isValidPattern(_ pattern: String) -> Bool {
|
||||
ExecApprovalHelpers.isValidAllowlistPattern(pattern)
|
||||
func isPathPattern(_ pattern: String) -> Bool {
|
||||
ExecApprovalHelpers.isPathPattern(pattern)
|
||||
}
|
||||
|
||||
func refreshSkillBins(force: Bool = false) async {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@@ -18,10 +17,6 @@ final class TalkModeController {
|
||||
} else {
|
||||
TalkOverlayController.shared.dismiss()
|
||||
}
|
||||
TalkSpeechInterruptMonitor.shared.setEnabled(enabled && AppStateStore.shared.talkShiftToStopEnabled)
|
||||
// Talk Mode and Push-to-Talk share the right Option key — disable PTT while Talk Mode is active.
|
||||
let pttEnabled = !enabled && AppStateStore.shared.voicePushToTalkEnabled
|
||||
VoicePushToTalkHotkey.shared.setEnabled(pttEnabled)
|
||||
await TalkModeRuntime.shared.setEnabled(enabled)
|
||||
// Resume voice wake listener *after* TalkMode audio is fully torn down.
|
||||
// Check swabbleEnabled (not voiceWakeTriggersTalkMode) so the paused wake listener
|
||||
@@ -32,15 +27,8 @@ final class TalkModeController {
|
||||
}
|
||||
|
||||
func updatePhase(_ phase: TalkModePhase) {
|
||||
let previousPhase = self.phase
|
||||
self.phase = phase
|
||||
TalkOverlayController.shared.updatePhase(phase)
|
||||
|
||||
// Play distinct system sounds for each phase transition.
|
||||
if phase != previousPhase {
|
||||
Self.playPhaseSound(phase, previousPhase: previousPhase)
|
||||
}
|
||||
|
||||
let effectivePhase = self.isPaused ? "paused" : phase.rawValue
|
||||
Task {
|
||||
await GatewayConnection.shared.talkMode(
|
||||
@@ -49,25 +37,6 @@ final class TalkModeController {
|
||||
}
|
||||
}
|
||||
|
||||
private static func playPhaseSound(_ phase: TalkModePhase, previousPhase: TalkModePhase) {
|
||||
guard AppStateStore.shared.talkPhaseSoundsEnabled else { return }
|
||||
let soundName: String? = switch phase {
|
||||
case .thinking:
|
||||
"Tink" // 생각 중: 짧고 가벼운 소리
|
||||
case .speaking:
|
||||
"Pop" // 대답 시작: 톡 소리
|
||||
case .listening:
|
||||
// 대답 중단(speaking→listening): 부드러운 종료음
|
||||
// 듣기 시작(thinking→listening 등): 잠수함 소리
|
||||
previousPhase == .speaking ? "Bottle" : "Submarine"
|
||||
case .idle:
|
||||
nil
|
||||
}
|
||||
if let soundName {
|
||||
NSSound(named: NSSound.Name(soundName))?.play()
|
||||
}
|
||||
}
|
||||
|
||||
func updateLevel(_ level: Double) {
|
||||
TalkOverlayController.shared.updateLevel(level)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ struct TalkModeGatewayConfigState {
|
||||
let outputFormat: String?
|
||||
let interruptOnSpeech: Bool
|
||||
let silenceTimeoutMs: Int
|
||||
let speechLocaleID: String?
|
||||
let apiKey: String?
|
||||
let seamColorHex: String?
|
||||
}
|
||||
@@ -54,7 +53,6 @@ enum TalkModeGatewayConfigParser {
|
||||
}
|
||||
let outputFormat = activeConfig?["outputFormat"]?.stringValue
|
||||
let interrupt = talk?["interruptOnSpeech"]?.boolValue
|
||||
let speechLocaleID = TalkConfigParsing.resolvedSpeechLocaleID(talk)
|
||||
let apiKey = activeConfig?["apiKey"]?.stringValue
|
||||
let resolvedVoice: String? = if activeProvider == defaultProvider {
|
||||
(voice?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? voice : nil) ??
|
||||
@@ -80,7 +78,6 @@ enum TalkModeGatewayConfigParser {
|
||||
outputFormat: outputFormat,
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
speechLocaleID: speechLocaleID,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
|
||||
}
|
||||
@@ -107,7 +104,6 @@ enum TalkModeGatewayConfigParser {
|
||||
outputFormat: nil,
|
||||
interruptOnSpeech: true,
|
||||
silenceTimeoutMs: defaultSilenceTimeoutMs,
|
||||
speechLocaleID: nil,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: nil)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import AVFoundation
|
||||
import Foundation
|
||||
import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import OSLog
|
||||
import Speech
|
||||
|
||||
@@ -70,7 +69,6 @@ actor TalkModeRuntime {
|
||||
private var defaultOutputFormat: String?
|
||||
private var interruptOnSpeech: Bool = true
|
||||
private var activeTalkProvider = TalkModeRuntime.defaultTalkProvider
|
||||
private var speechLocaleID: String?
|
||||
private var lastInterruptedAtSeconds: Double?
|
||||
private var voiceAliases: [String: String] = [:]
|
||||
private var lastSpokenText: String?
|
||||
@@ -187,23 +185,12 @@ actor TalkModeRuntime {
|
||||
self.recognitionGeneration &+= 1
|
||||
let generation = self.recognitionGeneration
|
||||
|
||||
let voiceWakeLocale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
|
||||
let supportedLocaleIDs = Set(SFSpeechRecognizer.supportedLocales().map(\.identifier))
|
||||
let localeID = TalkConfigParsing.resolvedSpeechRecognitionLocaleID(
|
||||
preferredLocaleIDs: [
|
||||
self.speechLocaleID,
|
||||
voiceWakeLocale,
|
||||
Locale.autoupdatingCurrent.identifier,
|
||||
],
|
||||
supportedLocaleIDs: supportedLocaleIDs)
|
||||
self.recognizer = localeID
|
||||
.map { SFSpeechRecognizer(locale: Locale(identifier: $0)) }
|
||||
?? SFSpeechRecognizer()
|
||||
let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
|
||||
self.recognizer = SFSpeechRecognizer(locale: Locale(identifier: locale))
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
self.logger.error("talk recognizer unavailable")
|
||||
return
|
||||
}
|
||||
self.logger.debug("talk recognizer locale=\(recognizer.locale.identifier, privacy: .public)")
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
Self.configureRecognitionRequest(request)
|
||||
@@ -488,16 +475,7 @@ actor TalkModeRuntime {
|
||||
self.ttsLogger
|
||||
.error(
|
||||
"talk TTS failed: \(error.localizedDescription, privacy: .public); " +
|
||||
"retrying gateway talk.speak")
|
||||
do {
|
||||
try await self.playGatewayTalkSpeak(input: input)
|
||||
return
|
||||
} catch {
|
||||
self.ttsLogger
|
||||
.error(
|
||||
"talk gateway TTS failed: \(error.localizedDescription, privacy: .public); " +
|
||||
"falling back to system voice")
|
||||
}
|
||||
"falling back to system voice")
|
||||
do {
|
||||
try await self.playSystemVoice(input: input)
|
||||
} catch {
|
||||
@@ -742,42 +720,6 @@ actor TalkModeRuntime {
|
||||
return await self.playMP3(stream: stream)
|
||||
}
|
||||
|
||||
private func playGatewayTalkSpeak(input: TalkPlaybackInput) async throws {
|
||||
let params = Self.makeTalkSpeakParams(
|
||||
text: input.cleanedText,
|
||||
voiceId: input.voiceId,
|
||||
modelId: self.currentModelId ?? self.defaultModelId,
|
||||
outputFormat: self.defaultOutputFormat,
|
||||
directive: input.directive)
|
||||
let result: TalkSpeakResult = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .talkSpeak,
|
||||
params: params,
|
||||
timeoutMs: max(30000, input.synthTimeoutSeconds * 1000 + 5000))
|
||||
guard let audioData = Data(base64Encoded: result.audiobase64), !audioData.isEmpty else {
|
||||
throw NSError(domain: "TalkSpeak", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway talk.speak returned empty audio",
|
||||
])
|
||||
}
|
||||
_ = await self.stopPCM()
|
||||
_ = await self.stopMP3()
|
||||
if self.interruptOnSpeech {
|
||||
guard await self.prepareForPlayback(generation: input.generation) else { return }
|
||||
}
|
||||
await MainActor.run { TalkModeController.shared.updatePhase(.speaking) }
|
||||
self.phase = .speaking
|
||||
let playback = await self.playTalkAudio(data: audioData)
|
||||
self.ttsLogger
|
||||
.info(
|
||||
"talk gateway audio provider=\(result.provider, privacy: .public) " +
|
||||
"format=\(result.outputformat ?? "unknown", privacy: .public) " +
|
||||
"finished=\(playback.finished, privacy: .public)")
|
||||
if !playback.finished, playback.interruptedAt == nil {
|
||||
throw NSError(domain: "TalkSpeak", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway talk.speak audio playback failed",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func playSystemVoice(input: TalkPlaybackInput) async throws {
|
||||
self.ttsLogger.info("talk system voice start chars=\(input.cleanedText.count, privacy: .public)")
|
||||
if self.interruptOnSpeech {
|
||||
@@ -905,54 +847,6 @@ actor TalkModeRuntime {
|
||||
}
|
||||
|
||||
extension TalkModeRuntime {
|
||||
static func makeTalkSpeakParams(
|
||||
text: String,
|
||||
voiceId: String?,
|
||||
modelId: String?,
|
||||
outputFormat: String?,
|
||||
directive: TalkDirective?) -> [String: AnyCodable]
|
||||
{
|
||||
var params: [String: AnyCodable] = ["text": AnyCodable(text)]
|
||||
|
||||
func addString(_ key: String, _ value: String?) {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return }
|
||||
params[key] = AnyCodable(trimmed)
|
||||
}
|
||||
|
||||
addString("voiceId", voiceId)
|
||||
addString("modelId", directive?.modelId ?? modelId)
|
||||
addString("outputFormat", directive?.outputFormat ?? outputFormat)
|
||||
if let speed = directive?.speed {
|
||||
params["speed"] = AnyCodable(speed)
|
||||
}
|
||||
if let rateWPM = directive?.rateWPM {
|
||||
params["rateWpm"] = AnyCodable(rateWPM)
|
||||
}
|
||||
if let stability = directive?.stability {
|
||||
params["stability"] = AnyCodable(stability)
|
||||
}
|
||||
if let similarity = directive?.similarity {
|
||||
params["similarity"] = AnyCodable(similarity)
|
||||
}
|
||||
if let style = directive?.style {
|
||||
params["style"] = AnyCodable(style)
|
||||
}
|
||||
if let speakerBoost = directive?.speakerBoost {
|
||||
params["speakerBoost"] = AnyCodable(speakerBoost)
|
||||
}
|
||||
if let seed = directive?.seed {
|
||||
params["seed"] = AnyCodable(seed)
|
||||
}
|
||||
addString("normalize", directive?.normalize)
|
||||
addString("language", directive?.language)
|
||||
if let latencyTier = directive?.latencyTier {
|
||||
params["latencyTier"] = AnyCodable(latencyTier)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
// MARK: - Audio playback (MainActor helpers)
|
||||
|
||||
@MainActor
|
||||
@@ -1021,22 +915,11 @@ extension TalkModeRuntime {
|
||||
self.defaultOutputFormat = cfg.outputFormat
|
||||
self.interruptOnSpeech = cfg.interruptOnSpeech
|
||||
self.activeTalkProvider = cfg.activeProvider
|
||||
let configuredSilenceMs = cfg.silenceTimeoutMs
|
||||
let locale = await MainActor.run { AppStateStore.shared.voiceWakeLocaleID }
|
||||
let isCJKLocale = locale.hasPrefix("ko") || locale.hasPrefix("ja") || locale.hasPrefix("zh")
|
||||
let effectiveSilenceMs = isCJKLocale ? max(configuredSilenceMs, 2000) : configuredSilenceMs
|
||||
if isCJKLocale, configuredSilenceMs < 2000 {
|
||||
self.logger
|
||||
.info(
|
||||
"talk CJK locale: silence timeout clamped " +
|
||||
"\(configuredSilenceMs, privacy: .public)ms -> 2000ms")
|
||||
}
|
||||
self.silenceWindow = TimeInterval(effectiveSilenceMs) / 1000
|
||||
self.speechLocaleID = cfg.speechLocaleID
|
||||
self.silenceWindow = TimeInterval(cfg.silenceTimeoutMs) / 1000
|
||||
self.apiKey = cfg.apiKey
|
||||
let hasApiKey = (cfg.apiKey?.isEmpty == false)
|
||||
let voiceLabel = cfg.voiceId.flatMap { $0.isEmpty ? nil : $0 } ?? "none"
|
||||
let modelLabel = cfg.modelId.flatMap { $0.isEmpty ? nil : $0 } ?? "none"
|
||||
let voiceLabel = (cfg.voiceId?.isEmpty == false) ? cfg.voiceId! : "none"
|
||||
let modelLabel = (cfg.modelId?.isEmpty == false) ? cfg.modelId! : "none"
|
||||
self.logger
|
||||
.info(
|
||||
"talk config provider=\(cfg.activeProvider, privacy: .public) " +
|
||||
@@ -1044,8 +927,7 @@ extension TalkModeRuntime {
|
||||
"modelId=\(modelLabel, privacy: .public) " +
|
||||
"apiKey=\(hasApiKey, privacy: .public) " +
|
||||
"interrupt=\(cfg.interruptOnSpeech, privacy: .public) " +
|
||||
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public) " +
|
||||
"speechLocale=\(cfg.speechLocaleID ?? "device", privacy: .public)")
|
||||
"silenceTimeoutMs=\(cfg.silenceTimeoutMs, privacy: .public)")
|
||||
}
|
||||
|
||||
static func selectTalkProviderConfig(
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import AppKit
|
||||
import OSLog
|
||||
|
||||
/// Monitors right Option key (keyCode 61) to interrupt Talk Mode speech.
|
||||
/// Independent of Push-to-Talk — active whenever Talk Mode is enabled.
|
||||
final class TalkSpeechInterruptMonitor: @unchecked Sendable {
|
||||
static let shared = TalkSpeechInterruptMonitor()
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "talk.interrupt")
|
||||
private var globalMonitor: Any?
|
||||
private var localMonitor: Any?
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if enabled {
|
||||
self.startMonitoring()
|
||||
} else {
|
||||
self.stopMonitoring()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
guard self.globalMonitor == nil, self.localMonitor == nil else { return }
|
||||
self.globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
self?.handleFlags(keyCode: event.keyCode, modifierFlags: event.modifierFlags)
|
||||
}
|
||||
self.localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
self?.handleFlags(keyCode: event.keyCode, modifierFlags: event.modifierFlags)
|
||||
return event
|
||||
}
|
||||
self.logger.info("talk interrupt monitor started")
|
||||
}
|
||||
|
||||
private func stopMonitoring() {
|
||||
if let globalMonitor {
|
||||
NSEvent.removeMonitor(globalMonitor)
|
||||
self.globalMonitor = nil
|
||||
}
|
||||
if let localMonitor {
|
||||
NSEvent.removeMonitor(localMonitor)
|
||||
self.localMonitor = nil
|
||||
}
|
||||
self.logger.info("talk interrupt monitor stopped")
|
||||
}
|
||||
|
||||
private func handleFlags(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
// Right Option key down (keyCode 61).
|
||||
guard keyCode == 61, modifierFlags.contains(.option) else { return }
|
||||
Task { @MainActor in
|
||||
guard TalkModeController.shared.phase == .speaking else { return }
|
||||
self.logger.info("right option — interrupting talk mode speech")
|
||||
TalkModeController.shared.stopSpeaking(reason: .userTap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,6 @@ final class VoicePushToTalkHotkey: @unchecked Sendable {
|
||||
|
||||
private func updateModifierState(keyCode: UInt16, modifierFlags: NSEvent.ModifierFlags) {
|
||||
// assert(Thread.isMainThread) - Removed for Swift 6
|
||||
|
||||
// Right Option (keyCode 61) acts as a hold-to-talk modifier.
|
||||
if keyCode == 61 {
|
||||
self.optionDown = modifierFlags.contains(.option)
|
||||
|
||||
@@ -17,7 +17,6 @@ final class VoiceSessionCoordinator {
|
||||
var isFinal: Bool
|
||||
var sendChime: VoiceWakeChime
|
||||
var autoSendDelay: TimeInterval?
|
||||
var voiceWakeTrigger: String?
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "voicewake.coordinator")
|
||||
@@ -29,8 +28,7 @@ final class VoiceSessionCoordinator {
|
||||
source: Source,
|
||||
text: String,
|
||||
attributed: NSAttributedString? = nil,
|
||||
forwardEnabled: Bool = false,
|
||||
voiceWakeTrigger: String? = nil) -> UUID
|
||||
forwardEnabled: Bool = false) -> UUID
|
||||
{
|
||||
let token = UUID()
|
||||
self.logger.info("coordinator start token=\(token.uuidString) source=\(source.rawValue) len=\(text.count)")
|
||||
@@ -42,8 +40,7 @@ final class VoiceSessionCoordinator {
|
||||
attributed: attributedText,
|
||||
isFinal: false,
|
||||
sendChime: .none,
|
||||
autoSendDelay: nil,
|
||||
voiceWakeTrigger: voiceWakeTrigger)
|
||||
autoSendDelay: nil)
|
||||
self.session = session
|
||||
VoiceWakeOverlayController.shared.startSession(
|
||||
token: token,
|
||||
@@ -66,8 +63,7 @@ final class VoiceSessionCoordinator {
|
||||
token: UUID,
|
||||
text: String,
|
||||
sendChime: VoiceWakeChime,
|
||||
autoSendAfter: TimeInterval?,
|
||||
voiceWakeTrigger: String? = nil)
|
||||
autoSendAfter: TimeInterval?)
|
||||
{
|
||||
guard let session, session.token == token else { return }
|
||||
self.logger
|
||||
@@ -77,9 +73,6 @@ final class VoiceSessionCoordinator {
|
||||
self.session?.isFinal = true
|
||||
self.session?.sendChime = sendChime
|
||||
self.session?.autoSendDelay = autoSendAfter
|
||||
if let voiceWakeTrigger {
|
||||
self.session?.voiceWakeTrigger = voiceWakeTrigger
|
||||
}
|
||||
|
||||
let attributed = VoiceWakeOverlayController.shared.makeAttributed(from: text)
|
||||
VoiceWakeOverlayController.shared.presentFinal(
|
||||
@@ -93,20 +86,15 @@ final class VoiceSessionCoordinator {
|
||||
func sendNow(token: UUID, reason: String = "explicit") {
|
||||
guard let session, session.token == token else { return }
|
||||
let text = session.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let voiceWakeTrigger = session.voiceWakeTrigger
|
||||
let sendChime = session.sendChime
|
||||
guard !text.isEmpty else {
|
||||
self.logger.info("coordinator sendNow \(reason) empty -> dismiss")
|
||||
VoiceWakeOverlayController.shared.dismiss(token: token, reason: .empty, outcome: .empty)
|
||||
self.clearSession()
|
||||
return
|
||||
}
|
||||
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: sendChime)
|
||||
VoiceWakeOverlayController.shared.beginSendUI(token: token, sendChime: session.sendChime)
|
||||
Task.detached {
|
||||
_ = await VoiceWakeForwarder.forward(
|
||||
transcript: text,
|
||||
options: .init(
|
||||
voiceWakeTrigger: voiceWakeTrigger))
|
||||
_ = await VoiceWakeForwarder.forward(transcript: text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ enum VoiceWakeForwarder {
|
||||
var deliver: Bool = true
|
||||
var to: String?
|
||||
var channel: GatewayAgentChannel = .webchat
|
||||
var voiceWakeTrigger: String?
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -54,8 +53,7 @@ enum VoiceWakeForwarder {
|
||||
thinking: options.thinking,
|
||||
deliver: deliver,
|
||||
to: options.to,
|
||||
channel: options.channel,
|
||||
voiceWakeTrigger: options.voiceWakeTrigger))
|
||||
channel: options.channel))
|
||||
|
||||
if result.ok {
|
||||
self.logger.info("voice wake forward ok")
|
||||
|
||||
@@ -41,11 +41,7 @@ enum VoiceWakeRecognitionDebugSupport {
|
||||
minCommandLength: config.minCommandLength,
|
||||
trimWake: trimWake)
|
||||
else { return nil }
|
||||
return WakeWordGateMatch(
|
||||
triggerEndTime: 0,
|
||||
postGap: 0,
|
||||
command: command,
|
||||
trigger: VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers))
|
||||
return WakeWordGateMatch(triggerEndTime: 0, postGap: 0, command: command)
|
||||
}
|
||||
|
||||
static func transcriptSummary(
|
||||
|
||||
@@ -37,7 +37,6 @@ actor VoiceWakeRuntime {
|
||||
private var listeningState: ListeningState = .idle
|
||||
private var overlayToken: UUID?
|
||||
private var activeTriggerEndTime: TimeInterval?
|
||||
private var activeTriggerWord: String?
|
||||
private var scheduledRestartTask: Task<Void, Never>?
|
||||
private var lastLoggedText: String?
|
||||
private var lastLoggedAt: Date?
|
||||
@@ -257,7 +256,6 @@ actor VoiceWakeRuntime {
|
||||
self.currentConfig = nil
|
||||
self.listeningState = .idle
|
||||
self.activeTriggerEndTime = nil
|
||||
self.activeTriggerWord = nil
|
||||
self.logger.debug("voicewake runtime stopped")
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "stopped")
|
||||
|
||||
@@ -368,11 +366,7 @@ actor VoiceWakeRuntime {
|
||||
} else {
|
||||
self.logger.info("voicewake runtime detected len=\(match.command.count)")
|
||||
}
|
||||
await self.beginCapture(
|
||||
command: match.command,
|
||||
triggerEndTime: match.triggerEndTime,
|
||||
triggerWord: match.trigger,
|
||||
config: config)
|
||||
await self.beginCapture(command: match.command, triggerEndTime: match.triggerEndTime, config: config)
|
||||
} else if !transcript.isEmpty, update.error == nil {
|
||||
if self.isTriggerOnly(transcript: transcript, triggers: config.triggers) {
|
||||
self.preDetectTask?.cancel()
|
||||
@@ -500,33 +494,13 @@ actor VoiceWakeRuntime {
|
||||
return
|
||||
}
|
||||
self.logger.info("voicewake runtime detected (trigger-only pause)")
|
||||
let matchedTrigger = self.matchedTriggerWord(transcript: lastText, triggers: triggers)
|
||||
await self.beginCapture(
|
||||
command: "",
|
||||
triggerEndTime: nil,
|
||||
triggerWord: matchedTrigger,
|
||||
config: config)
|
||||
await self.beginCapture(command: "", triggerEndTime: nil, config: config)
|
||||
}
|
||||
|
||||
private func isTriggerOnly(transcript: String, triggers: [String]) -> Bool {
|
||||
Self.isTriggerOnlyText(transcript: transcript, triggers: triggers)
|
||||
}
|
||||
|
||||
private func matchedTriggerWord(transcript: String, triggers: [String]) -> String? {
|
||||
Self.matchedTriggerWordText(transcript: transcript, triggers: triggers)
|
||||
}
|
||||
|
||||
private static func isTriggerOnlyText(transcript: String, triggers: [String]) -> Bool {
|
||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return false }
|
||||
guard
|
||||
VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers)
|
||||
|| VoiceWakeTextUtils.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
|
||||
else { return false }
|
||||
return self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
|
||||
}
|
||||
|
||||
private static func matchedTriggerWordText(transcript: String, triggers: [String]) -> String? {
|
||||
VoiceWakeTextUtils.matchedTriggerWord(transcript: transcript, triggers: triggers)
|
||||
guard VoiceWakeTextUtils.startsWithTrigger(transcript: transcript, triggers: triggers) else { return false }
|
||||
return Self.trimmedAfterTrigger(transcript, triggers: triggers).isEmpty
|
||||
}
|
||||
|
||||
private func preDetectSilenceCheck(
|
||||
@@ -553,16 +527,10 @@ actor VoiceWakeRuntime {
|
||||
await self.beginCapture(
|
||||
command: match.command,
|
||||
triggerEndTime: match.triggerEndTime,
|
||||
triggerWord: match.trigger,
|
||||
config: config)
|
||||
}
|
||||
|
||||
private func beginCapture(
|
||||
command: String,
|
||||
triggerEndTime: TimeInterval?,
|
||||
triggerWord: String?,
|
||||
config: RuntimeConfig) async
|
||||
{
|
||||
private func beginCapture(command: String, triggerEndTime: TimeInterval?, config: RuntimeConfig) async {
|
||||
// When "Trigger Talk Mode" is enabled, skip the capture/overlay flow entirely
|
||||
// and activate Talk Mode immediately. Talk Mode handles its own STT pipeline.
|
||||
// Pause the wake listener to avoid two audio pipelines competing on the mic
|
||||
@@ -577,6 +545,7 @@ actor VoiceWakeRuntime {
|
||||
await AppStateStore.shared.setTalkEnabled(true)
|
||||
return
|
||||
}
|
||||
|
||||
self.listeningState = .voiceWake
|
||||
self.isCapturing = true
|
||||
DiagnosticsFileLog.shared.log(category: "voicewake.runtime", event: "beginCapture")
|
||||
@@ -588,7 +557,6 @@ actor VoiceWakeRuntime {
|
||||
self.heardBeyondTrigger = !command.isEmpty
|
||||
self.triggerChimePlayed = false
|
||||
self.activeTriggerEndTime = triggerEndTime
|
||||
self.activeTriggerWord = triggerWord
|
||||
self.preDetectTask?.cancel()
|
||||
self.preDetectTask = nil
|
||||
self.triggerOnlyTask?.cancel()
|
||||
@@ -609,8 +577,7 @@ actor VoiceWakeRuntime {
|
||||
source: .wakeWord,
|
||||
text: snapshot,
|
||||
attributed: attributed,
|
||||
forwardEnabled: true,
|
||||
voiceWakeTrigger: triggerWord)
|
||||
forwardEnabled: true)
|
||||
}
|
||||
|
||||
// Keep the "ears" boosted for the capture window so the status icon animates while recording.
|
||||
@@ -665,9 +632,7 @@ actor VoiceWakeRuntime {
|
||||
self.lastHeard = nil
|
||||
self.heardBeyondTrigger = false
|
||||
self.triggerChimePlayed = false
|
||||
let triggerWord = self.activeTriggerWord
|
||||
self.activeTriggerEndTime = nil
|
||||
self.activeTriggerWord = nil
|
||||
self.lastTranscript = nil
|
||||
self.lastTranscriptAt = nil
|
||||
self.preDetectTask?.cancel()
|
||||
@@ -688,17 +653,14 @@ actor VoiceWakeRuntime {
|
||||
token: token,
|
||||
text: finalTranscript,
|
||||
sendChime: sendChime,
|
||||
autoSendAfter: delay,
|
||||
voiceWakeTrigger: triggerWord)
|
||||
autoSendAfter: delay)
|
||||
}
|
||||
} else if !finalTranscript.isEmpty {
|
||||
if sendChime != .none {
|
||||
await MainActor.run { VoiceWakeChimePlayer.play(sendChime, reason: "voicewake.send") }
|
||||
}
|
||||
Task.detached {
|
||||
await VoiceWakeForwarder.forward(
|
||||
transcript: finalTranscript,
|
||||
options: .init(voiceWakeTrigger: triggerWord))
|
||||
await VoiceWakeForwarder.forward(transcript: finalTranscript)
|
||||
}
|
||||
}
|
||||
self.overlayToken = nil
|
||||
@@ -822,14 +784,6 @@ actor VoiceWakeRuntime {
|
||||
!self.trimmedAfterTrigger(text, triggers: triggers).isEmpty
|
||||
}
|
||||
|
||||
static func _testIsTriggerOnly(_ text: String, triggers: [String]) -> Bool {
|
||||
self.isTriggerOnlyText(transcript: text, triggers: triggers)
|
||||
}
|
||||
|
||||
static func _testMatchedTriggerWord(_ text: String, triggers: [String]) -> String? {
|
||||
self.matchedTriggerWordText(transcript: text, triggers: triggers)
|
||||
}
|
||||
|
||||
static func _testAttributedColor(isFinal: Bool) -> NSColor {
|
||||
VoiceOverlayTextFormatting.makeAttributed(committed: "sample", volatile: "", isFinal: isFinal)
|
||||
.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor ?? .clear
|
||||
|
||||
@@ -72,31 +72,6 @@ struct VoiceWakeSettings: View {
|
||||
binding: self.$state.voicePushToTalkEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
if self.state.voicePushToTalkEnabled, self.state.talkEnabled {
|
||||
Text("Push-to-Talk is paused while Talk Mode is active. It resumes when Talk Mode is turned off.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, 20)
|
||||
}
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Play phase-transition sounds",
|
||||
subtitle: """
|
||||
Play short system sounds when Talk Mode switches between
|
||||
listening, thinking, and speaking.
|
||||
""",
|
||||
binding: self.$state.talkPhaseSoundsEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
SettingsToggleRow(
|
||||
title: "Press Right Option to stop speech",
|
||||
subtitle: """
|
||||
Tap the right Option key to interrupt the assistant while it is
|
||||
speaking and return to listening.
|
||||
""",
|
||||
binding: self.$state.talkShiftToStopEnabled)
|
||||
.disabled(!voiceWakeSupported)
|
||||
|
||||
if !voiceWakeSupported {
|
||||
Label("Voice Wake requires macOS 26 or newer.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout)
|
||||
|
||||
@@ -4,11 +4,6 @@ import SwabbleKit
|
||||
enum VoiceWakeTextUtils {
|
||||
private static let whitespaceAndPunctuation = CharacterSet.whitespacesAndNewlines
|
||||
.union(.punctuationCharacters)
|
||||
.union(.symbols)
|
||||
private static let wakePrefixFillers: Set<String> = [
|
||||
"a", "ah", "eh", "er", "erm", "hey", "hmm", "huh", "mhm", "mm", "oh", "uh", "um",
|
||||
"yo", "呃", "嗯", "啊", "诶", "欸",
|
||||
]
|
||||
typealias TrimWake = (String, [String]) -> String
|
||||
|
||||
static func normalizeToken(_ token: String) -> String {
|
||||
@@ -17,104 +12,6 @@ enum VoiceWakeTextUtils {
|
||||
.lowercased()
|
||||
}
|
||||
|
||||
private static func normalizedTriggerTokens(_ trigger: String) -> [String] {
|
||||
trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private static func isASCIIWordScalar(_ scalar: UnicodeScalar) -> Bool {
|
||||
scalar.isASCII && CharacterSet.alphanumerics.contains(scalar)
|
||||
}
|
||||
|
||||
private static func requiresASCIIWordBoundaries(_ value: String) -> Bool {
|
||||
value.unicodeScalars.contains(where: self.isASCIIWordScalar)
|
||||
}
|
||||
|
||||
private static func hasASCIIWordBoundaries(
|
||||
transcript: String,
|
||||
range: Range<String.Index>,
|
||||
trigger: String) -> Bool
|
||||
{
|
||||
guard self.requiresASCIIWordBoundaries(trigger) else { return true }
|
||||
|
||||
if range.lowerBound > transcript.startIndex {
|
||||
let beforeIndex = transcript.index(before: range.lowerBound)
|
||||
let beforeScalars = transcript[beforeIndex].unicodeScalars
|
||||
if beforeScalars.contains(where: self.isASCIIWordScalar) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if range.upperBound < transcript.endIndex {
|
||||
let afterScalars = transcript[range.upperBound].unicodeScalars
|
||||
if afterScalars.contains(where: self.isASCIIWordScalar) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private static func bestRawTriggerMatch(
|
||||
transcript: String,
|
||||
triggers: [String]) -> (range: Range<String.Index>, normalizedTrigger: String)?
|
||||
{
|
||||
var bestMatch: (range: Range<String.Index>, normalizedTrigger: String, tokenCount: Int)?
|
||||
|
||||
for trigger in triggers {
|
||||
let normalizedTokens = self.normalizedTriggerTokens(trigger)
|
||||
guard !normalizedTokens.isEmpty else { continue }
|
||||
let rawTrigger = trigger.trimmingCharacters(in: self.whitespaceAndPunctuation)
|
||||
let tokenCount = normalizedTokens.count
|
||||
guard !rawTrigger.isEmpty else { continue }
|
||||
|
||||
var searchStart = transcript.startIndex
|
||||
while searchStart < transcript.endIndex,
|
||||
let range = transcript.range(
|
||||
of: rawTrigger,
|
||||
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive],
|
||||
range: searchStart..<transcript.endIndex)
|
||||
{
|
||||
defer {
|
||||
searchStart = transcript.index(after: range.lowerBound)
|
||||
}
|
||||
guard self.hasASCIIWordBoundaries(
|
||||
transcript: transcript,
|
||||
range: range,
|
||||
trigger: rawTrigger)
|
||||
else { continue }
|
||||
|
||||
if let bestMatch {
|
||||
if range.lowerBound > bestMatch.range.lowerBound { continue }
|
||||
if range.lowerBound == bestMatch.range.lowerBound,
|
||||
tokenCount <= bestMatch.tokenCount
|
||||
{
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
bestMatch = (range, normalizedTokens.joined(separator: " "), tokenCount)
|
||||
break
|
||||
}
|
||||
|
||||
if let bestMatch,
|
||||
bestMatch.range.lowerBound == transcript.startIndex,
|
||||
bestMatch.tokenCount >= tokenCount
|
||||
{
|
||||
// Earlier matches take precedence, so once we match from the
|
||||
// start there is no need to scan later triggers with fewer
|
||||
// tokens at the same offset.
|
||||
if bestMatch.tokenCount > tokenCount {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch.map { (range: $0.range, normalizedTrigger: $0.normalizedTrigger) }
|
||||
}
|
||||
|
||||
static func startsWithTrigger(transcript: String, triggers: [String]) -> Bool {
|
||||
let tokens = transcript
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
@@ -122,7 +19,10 @@ enum VoiceWakeTextUtils {
|
||||
.filter { !$0.isEmpty }
|
||||
guard !tokens.isEmpty else { return false }
|
||||
for trigger in triggers {
|
||||
let triggerTokens = self.normalizedTriggerTokens(trigger)
|
||||
let triggerTokens = trigger
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !triggerTokens.isEmpty, tokens.count >= triggerTokens.count else { continue }
|
||||
if zip(triggerTokens, tokens.prefix(triggerTokens.count)).allSatisfy({ $0 == $1 }) {
|
||||
return true
|
||||
@@ -140,55 +40,9 @@ enum VoiceWakeTextUtils {
|
||||
guard !transcript.isEmpty else { return nil }
|
||||
guard !self.normalizeToken(transcript).isEmpty else { return nil }
|
||||
guard WakeWordGate.matchesTextOnly(text: transcript, triggers: triggers) else { return nil }
|
||||
guard
|
||||
self.startsWithTrigger(transcript: transcript, triggers: triggers)
|
||||
|| self.hasOnlyFillerBeforeTrigger(transcript: transcript, triggers: triggers)
|
||||
else { return nil }
|
||||
guard self.startsWithTrigger(transcript: transcript, triggers: triggers) else { return nil }
|
||||
let trimmed = trimWake(transcript, triggers)
|
||||
guard trimmed.count >= minCommandLength else { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func hasOnlyFillerBeforeTrigger(transcript: String, triggers: [String]) -> Bool {
|
||||
guard let match = self.bestRawTriggerMatch(transcript: transcript, triggers: triggers) else { return false }
|
||||
let prefixTokens = transcript[..<match.range.lowerBound]
|
||||
.split(whereSeparator: {
|
||||
$0.isWhitespace || self.whitespaceAndPunctuation.contains($0.unicodeScalars.first!)
|
||||
})
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
return prefixTokens.allSatisfy { self.wakePrefixFillers.contains($0) }
|
||||
}
|
||||
|
||||
static func matchedTriggerWord(transcript: String, triggers: [String]) -> String? {
|
||||
if let rawMatch = self.bestRawTriggerMatch(transcript: transcript, triggers: triggers) {
|
||||
return rawMatch.normalizedTrigger
|
||||
}
|
||||
|
||||
let transcriptTokens = transcript
|
||||
.split(whereSeparator: { $0.isWhitespace })
|
||||
.map { self.normalizeToken(String($0)) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !transcriptTokens.isEmpty else { return nil }
|
||||
|
||||
var bestStartIndex = Int.max
|
||||
var bestTokenCount = -1
|
||||
var bestTokens: [String]?
|
||||
|
||||
for trigger in triggers {
|
||||
let triggerTokens = self.normalizedTriggerTokens(trigger)
|
||||
guard !triggerTokens.isEmpty, transcriptTokens.count >= triggerTokens.count else { continue }
|
||||
for index in 0...(transcriptTokens.count - triggerTokens.count) {
|
||||
let candidate = transcriptTokens[index..<(index + triggerTokens.count)]
|
||||
guard zip(triggerTokens, candidate).allSatisfy({ $0 == $1 }) else { continue }
|
||||
if index < bestStartIndex || (index == bestStartIndex && triggerTokens.count > bestTokenCount) {
|
||||
bestStartIndex = index
|
||||
bestTokenCount = triggerTokens.count
|
||||
bestTokens = triggerTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestTokens?.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,14 +595,11 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let besteffortdeliver: Bool?
|
||||
public let lane: String?
|
||||
public let cleanupbundlemcponrunend: Bool?
|
||||
public let modelrun: Bool?
|
||||
public let promptmode: AnyCodable?
|
||||
public let extrasystemprompt: String?
|
||||
public let bootstrapcontextmode: AnyCodable?
|
||||
public let bootstrapcontextrunkind: AnyCodable?
|
||||
public let internalevents: [[String: AnyCodable]]?
|
||||
public let inputprovenance: [String: AnyCodable]?
|
||||
public let voicewaketrigger: String?
|
||||
public let idempotencykey: String
|
||||
public let label: String?
|
||||
|
||||
@@ -630,14 +627,11 @@ public struct AgentParams: Codable, Sendable {
|
||||
besteffortdeliver: Bool?,
|
||||
lane: String?,
|
||||
cleanupbundlemcponrunend: Bool?,
|
||||
modelrun: Bool?,
|
||||
promptmode: AnyCodable?,
|
||||
extrasystemprompt: String?,
|
||||
bootstrapcontextmode: AnyCodable?,
|
||||
bootstrapcontextrunkind: AnyCodable?,
|
||||
internalevents: [[String: AnyCodable]]?,
|
||||
inputprovenance: [String: AnyCodable]?,
|
||||
voicewaketrigger: String?,
|
||||
idempotencykey: String,
|
||||
label: String?)
|
||||
{
|
||||
@@ -664,14 +658,11 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.besteffortdeliver = besteffortdeliver
|
||||
self.lane = lane
|
||||
self.cleanupbundlemcponrunend = cleanupbundlemcponrunend
|
||||
self.modelrun = modelrun
|
||||
self.promptmode = promptmode
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
self.bootstrapcontextmode = bootstrapcontextmode
|
||||
self.bootstrapcontextrunkind = bootstrapcontextrunkind
|
||||
self.internalevents = internalevents
|
||||
self.inputprovenance = inputprovenance
|
||||
self.voicewaketrigger = voicewaketrigger
|
||||
self.idempotencykey = idempotencykey
|
||||
self.label = label
|
||||
}
|
||||
@@ -700,14 +691,11 @@ public struct AgentParams: Codable, Sendable {
|
||||
case besteffortdeliver = "bestEffortDeliver"
|
||||
case lane
|
||||
case cleanupbundlemcponrunend = "cleanupBundleMcpOnRunEnd"
|
||||
case modelrun = "modelRun"
|
||||
case promptmode = "promptMode"
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
case bootstrapcontextmode = "bootstrapContextMode"
|
||||
case bootstrapcontextrunkind = "bootstrapContextRunKind"
|
||||
case internalevents = "internalEvents"
|
||||
case inputprovenance = "inputProvenance"
|
||||
case voicewaketrigger = "voiceWakeTrigger"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case label
|
||||
}
|
||||
@@ -735,26 +723,17 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
public let agentid: String
|
||||
public let name: String?
|
||||
public let avatar: String?
|
||||
public let avatarsource: String?
|
||||
public let avatarstatus: String?
|
||||
public let avatarreason: String?
|
||||
public let emoji: String?
|
||||
|
||||
public init(
|
||||
agentid: String,
|
||||
name: String?,
|
||||
avatar: String?,
|
||||
avatarsource: String?,
|
||||
avatarstatus: String?,
|
||||
avatarreason: String?,
|
||||
emoji: String?)
|
||||
{
|
||||
self.agentid = agentid
|
||||
self.name = name
|
||||
self.avatar = avatar
|
||||
self.avatarsource = avatarsource
|
||||
self.avatarstatus = avatarstatus
|
||||
self.avatarreason = avatarreason
|
||||
self.emoji = emoji
|
||||
}
|
||||
|
||||
@@ -762,9 +741,6 @@ public struct AgentIdentityResult: Codable, Sendable {
|
||||
case agentid = "agentId"
|
||||
case name
|
||||
case avatar
|
||||
case avatarsource = "avatarSource"
|
||||
case avatarstatus = "avatarStatus"
|
||||
case avatarreason = "avatarReason"
|
||||
case emoji
|
||||
}
|
||||
}
|
||||
@@ -2354,17 +2330,20 @@ public struct TalkRealtimeSessionParams: Codable, Sendable {
|
||||
public let provider: String?
|
||||
public let model: String?
|
||||
public let voice: String?
|
||||
public let instructions: String?
|
||||
|
||||
public init(
|
||||
sessionkey: String?,
|
||||
provider: String?,
|
||||
model: String?,
|
||||
voice: String?)
|
||||
voice: String?,
|
||||
instructions: String?)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
self.voice = voice
|
||||
self.instructions = instructions
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -2372,6 +2351,7 @@ public struct TalkRealtimeSessionParams: Codable, Sendable {
|
||||
case provider
|
||||
case model
|
||||
case voice
|
||||
case instructions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2634,22 +2614,18 @@ public struct WebLoginStartParams: Codable, Sendable {
|
||||
public struct WebLoginWaitParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
public let accountid: String?
|
||||
public let currentqrdataurl: String?
|
||||
|
||||
public init(
|
||||
timeoutms: Int?,
|
||||
accountid: String?,
|
||||
currentqrdataurl: String?)
|
||||
accountid: String?)
|
||||
{
|
||||
self.timeoutms = timeoutms
|
||||
self.accountid = accountid
|
||||
self.currentqrdataurl = currentqrdataurl
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case timeoutms = "timeoutMs"
|
||||
case accountid = "accountId"
|
||||
case currentqrdataurl = "currentQrDataUrl"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@@ -37,130 +36,6 @@ struct AppStateRemoteConfigTests {
|
||||
#expect((remote["token"] as? String) == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigPinsLoopbackUrlForSshTransport() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://gateway.example:18789"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
#expect((remote["transport"] as? String) == nil)
|
||||
#expect(remote["sshTarget"] as? String == "alice@gateway.example")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigPreservesCustomLoopbackTunnelPort() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://localhost.:29876"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigPreservesCustomPortWhenExistingHostMatchesSshTarget() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://gateway.example:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:19999")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigDropsCustomPortWhenExistingHostDoesNotMatchSshTarget() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://other-host.example:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: "gateway.example",
|
||||
remoteTarget: "alice@gateway.example",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test
|
||||
func updatedRemoteGatewayConfigDoesNotPreservePortForHostnamePrefixCollision() {
|
||||
let remote = AppState._testUpdatedRemoteGatewayConfig(
|
||||
current: ["url": "ws://example.attacker.tld:19999"],
|
||||
draft: .init(
|
||||
transport: .ssh,
|
||||
remoteUrl: "",
|
||||
remoteHost: nil,
|
||||
remoteTarget: "alice@example.com",
|
||||
remoteIdentity: "",
|
||||
remoteToken: "",
|
||||
remoteTokenDirty: false))
|
||||
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
}
|
||||
|
||||
@Test
|
||||
func appStateInitDoesNotInferLoopbackHostIntoRemoteTarget() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: [remoteTargetKey: nil])
|
||||
{
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let state = AppState(preview: true)
|
||||
#expect(state.remoteTarget == "")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func appStateInitPreservesExistingRemoteTargetWhenRemoteUrlIsLoopback() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withIsolatedState(
|
||||
env: ["OPENCLAW_CONFIG_PATH": configPath],
|
||||
defaults: [remoteTargetKey: "alice@gateway.example"])
|
||||
{
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"mode": "remote",
|
||||
"remote": [
|
||||
"url": "ws://127.0.0.1:19999",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
let state = AppState(preview: true)
|
||||
#expect(state.remoteTarget == "alice@gateway.example")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func syncedGatewayRootPreservesObjectTokenAcrossModeAndTransportChangesWhenUntouched() {
|
||||
let initialRoot: [String: Any] = [
|
||||
|
||||
@@ -156,63 +156,4 @@ struct ChannelsSettingsSmokeTests {
|
||||
let view = ChannelsSettings(store: store)
|
||||
_ = view.body
|
||||
}
|
||||
|
||||
@Test func `whatsapp login wait result keeps latest qr until connected`() {
|
||||
let store = makeChannelsStore(channels: [:])
|
||||
store.whatsappLoginQrDataUrl = "data:image/png;base64,initial"
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: false,
|
||||
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
|
||||
qrDataUrl: "data:image/png;base64,rotated"))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
|
||||
#expect(store.whatsappLoginConnected == false)
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: false,
|
||||
message: "Still waiting for the QR scan. Let me know when you’ve scanned it.",
|
||||
qrDataUrl: nil))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
|
||||
|
||||
store.applyWhatsAppLoginWaitResult(
|
||||
WhatsAppLoginWaitResult(
|
||||
connected: true,
|
||||
message: "✅ Linked! WhatsApp is ready.",
|
||||
qrDataUrl: nil))
|
||||
|
||||
#expect(store.whatsappLoginQrDataUrl == nil)
|
||||
#expect(store.whatsappLoginConnected == true)
|
||||
}
|
||||
|
||||
@Test func `whatsapp login wait budget allows one final poll`() {
|
||||
let startedAt = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
var didRunFinalWait = false
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1_000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 0.25, since: startedAt)) == 750)
|
||||
#expect(didRunFinalWait == false)
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1_000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 1.25, since: startedAt)) == 1)
|
||||
#expect(didRunFinalWait == true)
|
||||
|
||||
#expect(
|
||||
whatsappLoginWaitRequestTimeoutMs(
|
||||
startedAt: startedAt,
|
||||
timeoutMs: 1_000,
|
||||
didRunFinalWait: &didRunFinalWait,
|
||||
now: Date(timeInterval: 1.5, since: startedAt)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,34 +66,22 @@ struct ExecAllowlistTests {
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match accepts basename pattern for PATH resolved executable`() {
|
||||
@Test func `match ignores basename pattern`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
#expect(match == nil)
|
||||
}
|
||||
|
||||
@Test func `match accepts basename glob for PATH resolved executable`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "r?")
|
||||
let resolution = Self.homebrewRGResolution()
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func `match ignores basename for path selected executable`() {
|
||||
@Test func `match ignores basename for relative executable`() {
|
||||
let entry = ExecAllowlistEntry(pattern: "echo")
|
||||
let relativeResolution = ExecCommandResolution(
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "./echo",
|
||||
resolvedPath: "/tmp/oc-basename/echo",
|
||||
executableName: "echo",
|
||||
cwd: "/tmp/oc-basename")
|
||||
let absoluteResolution = ExecCommandResolution(
|
||||
rawExecutable: "/tmp/oc-basename/echo",
|
||||
resolvedPath: "/tmp/oc-basename/echo",
|
||||
executableName: "echo",
|
||||
cwd: "/tmp/oc-basename")
|
||||
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: relativeResolution) == nil)
|
||||
#expect(ExecAllowlistMatcher.match(entries: [entry], resolution: absoluteResolution) == nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match == nil)
|
||||
}
|
||||
|
||||
@Test func `match is case insensitive`() {
|
||||
|
||||
@@ -33,13 +33,18 @@ struct ExecApprovalHelpersTests {
|
||||
#expect(ExecApprovalHelpers.isPathPattern("/usr/bin/rg"))
|
||||
#expect(ExecApprovalHelpers.isPathPattern(" ~/bin/rg "))
|
||||
#expect(!ExecApprovalHelpers.isPathPattern("rg"))
|
||||
#expect(ExecApprovalHelpers.isValidAllowlistPattern("rg"))
|
||||
|
||||
if case let .invalid(reason) = ExecApprovalHelpers.validateAllowlistPattern(" ") {
|
||||
#expect(reason == .empty)
|
||||
} else {
|
||||
Issue.record("Expected empty pattern rejection")
|
||||
}
|
||||
|
||||
if case let .invalid(reason) = ExecApprovalHelpers.validateAllowlistPattern("echo") {
|
||||
#expect(reason == .missingPathComponent)
|
||||
} else {
|
||||
Issue.record("Expected basename pattern rejection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `requires ask matches policy`() {
|
||||
|
||||
@@ -31,7 +31,7 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func `update allowlist accepts basename pattern`() async throws {
|
||||
func `update allowlist reports rejected basename pattern`() async throws {
|
||||
try await self.withTempStateDir { _ in
|
||||
let rejected = ExecApprovalsStore.updateAllowlist(
|
||||
agentId: "main",
|
||||
@@ -39,10 +39,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
ExecAllowlistEntry(pattern: "echo"),
|
||||
ExecAllowlistEntry(pattern: "/bin/echo"),
|
||||
])
|
||||
#expect(rejected.isEmpty)
|
||||
#expect(rejected.count == 1)
|
||||
#expect(rejected.first?.reason == .missingPathComponent)
|
||||
#expect(rejected.first?.pattern == "echo")
|
||||
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: "main")
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["echo", "/bin/echo"])
|
||||
#expect(resolved.allowlist.map(\.pattern) == ["/bin/echo"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,6 @@ import Testing
|
||||
|
||||
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
var state: URLSessionTask.State = .running
|
||||
var autoRespond = false
|
||||
private(set) var sentMessages: [URLSessionWebSocketTask.Message] = []
|
||||
private var sentChallenge = false
|
||||
private var respondedRequestIds = Set<String>()
|
||||
|
||||
func resume() {}
|
||||
|
||||
@@ -17,90 +13,41 @@ private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
|
||||
self.state = .canceling
|
||||
}
|
||||
|
||||
func send(_ message: URLSessionWebSocketTask.Message) async throws {
|
||||
self.sentMessages.append(message)
|
||||
}
|
||||
func send(_: URLSessionWebSocketTask.Message) async throws {}
|
||||
|
||||
func receive() async throws -> URLSessionWebSocketTask.Message {
|
||||
if self.autoRespond {
|
||||
if !self.sentChallenge {
|
||||
self.sentChallenge = true
|
||||
return .string("""
|
||||
{"type":"event","event":"connect.challenge","payload":{"nonce":"test-nonce"}}
|
||||
""")
|
||||
}
|
||||
if let request = self.latestUnrespondedRequest() {
|
||||
self.respondedRequestIds.insert(request.id)
|
||||
if request.method == "connect" {
|
||||
return .string("""
|
||||
{"type":"res","id":"\(request.id)","ok":true,"payload":{"type":"hello","protocol":3,"server":{},"features":{},"snapshot":{"presence":[],"health":{},"stateVersion":{"presence":0,"health":0},"uptimeMs":0},"policy":{}}}
|
||||
""")
|
||||
}
|
||||
return .string("""
|
||||
{"type":"res","id":"\(request.id)","ok":true,"payload":{}}
|
||||
""")
|
||||
}
|
||||
}
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
completionHandler(.failure(URLError(.cannotConnectToHost)))
|
||||
}
|
||||
|
||||
private func latestUnrespondedRequest() -> (id: String, method: String)? {
|
||||
for message in self.sentMessages.reversed() {
|
||||
let data: Data?
|
||||
switch message {
|
||||
case .string(let text):
|
||||
data = Data(text.utf8)
|
||||
case .data(let raw):
|
||||
data = raw
|
||||
@unknown default:
|
||||
data = nil
|
||||
}
|
||||
guard let data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = json["id"] as? String,
|
||||
let method = json["method"] as? String,
|
||||
!self.respondedRequestIds.contains(id)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return (id, method)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeWebSocketSession: WebSocketSessioning, @unchecked Sendable {
|
||||
let task = FakeWebSocketTask()
|
||||
|
||||
func makeWebSocketTask(url _: URL) -> WebSocketTaskBox {
|
||||
WebSocketTaskBox(task: self.task)
|
||||
WebSocketTaskBox(task: FakeWebSocketTask())
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSession) {
|
||||
let session = FakeWebSocketSession()
|
||||
let connection = GatewayConnection(
|
||||
private func makeTestGatewayConnection() -> GatewayConnection {
|
||||
GatewayConnection(
|
||||
configProvider: {
|
||||
(url: URL(string: "ws://127.0.0.1:1")!, token: nil, password: nil)
|
||||
},
|
||||
sessionBox: WebSocketSessionBox(session: session))
|
||||
return (connection, session)
|
||||
sessionBox: WebSocketSessionBox(session: FakeWebSocketSession()))
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionControlTests {
|
||||
@Test func `status fails when process missing`() async {
|
||||
let (connection, _) = makeTestGatewayConnection()
|
||||
let connection = makeTestGatewayConnection()
|
||||
let result = await connection.status()
|
||||
#expect(result.ok == false)
|
||||
#expect(result.error != nil)
|
||||
}
|
||||
|
||||
@Test func `reject empty message`() async {
|
||||
let (connection, _) = makeTestGatewayConnection()
|
||||
let connection = makeTestGatewayConnection()
|
||||
let result = await connection.sendAgent(
|
||||
message: "",
|
||||
thinking: nil,
|
||||
@@ -109,38 +56,4 @@ private func makeTestGatewayConnection() -> (GatewayConnection, FakeWebSocketSes
|
||||
to: nil)
|
||||
#expect(result.ok == false)
|
||||
}
|
||||
|
||||
@Test func `send agent keeps empty voice wake trigger field`() async throws {
|
||||
let (connection, session) = makeTestGatewayConnection()
|
||||
session.task.autoRespond = true
|
||||
_ = await connection.sendAgent(GatewayAgentInvocation(
|
||||
message: "test",
|
||||
sessionKey: "main",
|
||||
thinking: nil,
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last,
|
||||
timeoutSeconds: nil,
|
||||
idempotencyKey: "idem-1",
|
||||
voiceWakeTrigger: " "))
|
||||
|
||||
guard let lastMessage = session.task.sentMessages.last else {
|
||||
Issue.record("expected websocket send payload")
|
||||
return
|
||||
}
|
||||
let payloadData: Data
|
||||
switch lastMessage {
|
||||
case .string(let text):
|
||||
payloadData = Data(text.utf8)
|
||||
case .data(let data):
|
||||
payloadData = data
|
||||
@unknown default:
|
||||
Issue.record("unexpected websocket message type")
|
||||
return
|
||||
}
|
||||
|
||||
let json = try JSONSerialization.jsonObject(with: payloadData) as? [String: Any]
|
||||
let params = json?["params"] as? [String: Any]
|
||||
#expect(params?["voiceWakeTrigger"] as? String == "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,35 +84,7 @@ struct GatewayDiscoverySelectionSupportTests {
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .ssh)
|
||||
#expect(state.remoteUrl == "ws://127.0.0.1:18789")
|
||||
#expect(CommandResolver.parseSSHTarget(state.remoteTarget)?.host == "nearby-gateway.local")
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:18789")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `selecting nearby lan gateway preserves existing ssh tunnel port`() async {
|
||||
let configPath = TestIsolation.tempConfigPath()
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": configPath]) {
|
||||
let state = AppState(preview: true)
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteUrl = "ws://localhost:29876"
|
||||
|
||||
GatewayDiscoverySelectionSupport.applyRemoteSelection(
|
||||
gateway: self.makeGateway(
|
||||
serviceHost: "nearby-gateway.local",
|
||||
servicePort: 19999,
|
||||
stableID: "bonjour|nearby-gateway-custom"),
|
||||
state: state)
|
||||
|
||||
#expect(state.remoteTransport == .ssh)
|
||||
#expect(state.remoteUrl == "ws://127.0.0.1:29876")
|
||||
|
||||
let configRoot = OpenClawConfigFile.loadDict()
|
||||
let remote = ((configRoot["gateway"] as? [String: Any])?["remote"] as? [String: Any]) ?? [:]
|
||||
#expect(remote["url"] as? String == "ws://127.0.0.1:29876")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,28 +83,4 @@ struct MacNodeBrowserProxyTests {
|
||||
let arr = try #require(parsed["arr"] as? [Any])
|
||||
#expect(arr.count == 2)
|
||||
}
|
||||
|
||||
@Test func requestReportsActionableUnavailableWhenControlServiceIsMissing() async throws {
|
||||
let proxy = MacNodeBrowserProxy(
|
||||
endpointProvider: {
|
||||
MacNodeBrowserProxy.Endpoint(
|
||||
baseURL: URL(string: "http://127.0.0.1:18791")!,
|
||||
token: nil,
|
||||
password: nil)
|
||||
},
|
||||
performRequest: { _ in
|
||||
throw URLError(.cannotConnectToHost)
|
||||
})
|
||||
|
||||
do {
|
||||
_ = try await proxy.request(paramsJSON: #"{"method":"GET","path":"/"}"#)
|
||||
Issue.record("request should fail when browser control is unreachable")
|
||||
} catch {
|
||||
let message = error.localizedDescription
|
||||
#expect(message.contains("UNAVAILABLE: macOS app node could not reach the local browser control service"))
|
||||
#expect(message.contains("http://127.0.0.1:18791"))
|
||||
#expect(message.contains("browser control is owned by the CLI node-host"))
|
||||
#expect(message.contains("openclaw node start"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
struct MacNodeModeCoordinatorTests {
|
||||
@Test func remoteModeDoesNotAdvertiseBrowserProxy() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
locationMode: .off,
|
||||
connectionMode: .remote)
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(!caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(!commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
#expect(commands.contains(OpenClawCanvasCommand.present.rawValue))
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
}
|
||||
|
||||
@Test func localModeAdvertisesBrowserProxyWhenEnabled() {
|
||||
let caps = MacNodeModeCoordinator.resolvedCaps(
|
||||
browserControlEnabled: true,
|
||||
cameraEnabled: false,
|
||||
locationMode: .off,
|
||||
connectionMode: .local)
|
||||
let commands = MacNodeModeCoordinator.resolvedCommands(caps: caps)
|
||||
|
||||
#expect(caps.contains(OpenClawCapability.browser.rawValue))
|
||||
#expect(commands.contains(OpenClawBrowserCommand.proxy.rawValue))
|
||||
}
|
||||
}
|
||||
@@ -35,30 +35,8 @@ struct OpenClawConfigFileTests {
|
||||
])
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort() == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "GATEWAY.ts.net.") == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == nil)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
||||
#expect(OpenClawConfigFile.remoteGatewayPort(matchingHost: "gateway.attacker.tld") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func `set remote gateway url string replaces scheme`() async {
|
||||
let override = self.makeConfigOverridePath()
|
||||
|
||||
await TestIsolation.withEnvValues(["OPENCLAW_CONFIG_PATH": override]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "wss://old-host:111",
|
||||
],
|
||||
],
|
||||
])
|
||||
OpenClawConfigFile.setRemoteGatewayUrlString("ws://127.0.0.1:18789")
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let url = ((root["gateway"] as? [String: Any])?["remote"] as? [String: Any])?["url"] as? String
|
||||
#expect(url == "ws://127.0.0.1:18789")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user