Compare commits

..

23 Commits

Author SHA1 Message Date
Vincent Koc
40a422837b fix(gateway): keep local CLI shared auth off device scopes 2026-06-23 14:42:21 +08:00
mushuiyu886
01abe0a33d fix(agents): suggest recovery for unknown tool ids (#93374)
Merged via squash.

Prepared head SHA: bee84e4eb8
Co-authored-by: mushuiyu886 <266724580+mushuiyu886@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:20:09 +08:00
Sunjae Kim
f0a2ba0584 Fix Gemini day freshness time range handling (#95682)
Merged via squash.

Prepared head SHA: f6038b3a33
Co-authored-by: Sunjae-k <52808029+Sunjae-k@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 14:19:25 +08:00
Vincent Koc
f24b1a9c0c test(qa): relax heartbeat target none startup probe 2026-06-23 08:18:55 +02:00
Eden Kang
7c60379589 CLI: escape zsh completion descriptions (#64490)
* CLI: escape zsh completion descriptions

* Update src/cli/completion-cli.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* CLI: use parser-safe zsh completion escaping

* CLI: escape zsh completion descriptions

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
2026-06-23 14:12:27 +08:00
Vincent Koc
0fed6402be fix(ci): require OpenGrep SARIF artifacts 2026-06-23 14:08:20 +08:00
Vincent Koc
a13e2b92b3 perf(ci): widen main test fanout and move codeql off blacksmith (#95967)
* perf(ci): widen main test fanout and move codeql off blacksmith

* test(ci): update fanout guard
2026-06-23 13:56:29 +08:00
Alix-007
e583e62190 fix(cron): normalize run-log jobId on write to match read-side validation (#93567)
Merged via squash.

Prepared head SHA: ee41f84b53
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 13:55:57 +08:00
Vincent Koc
fe5c098fd7 test(ios): remove host zip dependency from IPA validator fixture 2026-06-23 13:54:42 +08:00
Vincent Koc
28a5b0a212 fix(canvas): guard native A2UI resources 2026-06-23 13:44:14 +08:00
Vincent Koc
53f9b6a36b test(qa): align release memory scenario assertions 2026-06-23 07:43:06 +02:00
joshavant
eae53595b0 fix: unblock ios release upload metadata 2026-06-23 00:39:45 -05:00
Andy Ye
ca2f4c0d67 Warn on generated wrapper overwrites and status diagnostics (#90537)
Merged via squash.

Prepared head SHA: c6b6589e6d
Co-authored-by: TurboTheTurtle <35905412+TurboTheTurtle@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-23 13:39:20 +08:00
Patrick Erichsen
f66e83154b docs: update ClawHub skill route references
Update OpenClaw ClawHub docs and user-facing copy for canonical owner-qualified skill routes.\n\nEvidence:\n- pnpm docs:list\n- pnpm test src/plugins/clawhub.test.ts src/cli/plugins-cli.install.test.ts src/gateway/server-methods/skills.clawhub.test.ts ui/src/ui/views/skills.test.ts\n- pnpm exec oxfmt --check --threads=1 docs/clawhub/cli.md docs/clawhub/publishing.md docs/cli/skills.md docs/help/faq.md docs/start/showcase.md docs/tools/creating-skills.md docs/tools/skills.md src/gateway/server-methods/skills.clawhub.test.ts src/plugins/clawhub.test.ts src/plugins/clawhub.ts ui/src/ui/views/skills.test.ts\n- git diff --check\n- exact-head hosted CI passed for 8530374388d8a73235b2ac8444b95a4a4c7d0f1c\n\nNote: repo-native scripts/pr prepare-run was attempted; local broad pnpm test was stopped after unrelated existing failures in agent/media/provider shards, while hosted exact-head CI and targeted ClawHub route/copy validation were green.
2026-06-22 22:27:57 -07:00
Vincent Koc
1479078a25 fix(ci): require iOS Periphery evidence artifact 2026-06-23 13:17:42 +08:00
Patrick Erichsen
0a97f73402 feat: add bundled plugin icon manifest URLs (#95845) 2026-06-22 22:14:18 -07:00
Vincent Koc
7668a72843 fix(qa): allow evidence-free maturity input checks 2026-06-23 13:05:20 +08:00
joshavant
10d850b39c chore: make ios testflight upload path canonical 2026-06-23 00:01:20 -05:00
joshavant
d4f666874f feat: harden ios app store push release mode 2026-06-23 00:01:20 -05:00
Dallin Romney
606706492f ci: fail qa profile evidence on qa failures (#95971) 2026-06-22 22:00:30 -07:00
Vincent Koc
cc1b3a8550 fix(install): skip llama cpp native build by default 2026-06-23 12:58:41 +08:00
Dallin Romney
438f208a76 perf(qa-lab): speed up unified QA suites (#95944)
* perf(qa-lab): speed up smoke ci suite

* fix(qa-lab): satisfy suite scheduler lint

* fix(qa-lab): settle unified partitions before retry

* fix(qa-lab): preserve isolated suite safeguards

* refactor(qa-lab): make suite isolation explicit

* fix(qa-lab): preserve channel-driver suite serialization

* fix(qa-lab): narrow flow-only isolation metadata
2026-06-22 21:55:54 -07:00
Jason O'Neal
b8f1961aae fix(model-fallback): classify Codex usage-limit payloads (#95400)
* fix(model-fallback): classify Codex usage-limit payloads

* test: add real behavior proof for Codex usage-limit fallback

Adds a permanent real behavior proof test that exercises the production
classifyEmbeddedAgentRunResultForModelFallback() classifier with the exact
Codex subscription usage-limit error text.

Covers:
- Primary path: isError payload with usage-limit text -> rate_limit fallback
- Non-error payload: same text as normal assistant output -> no fallback
- Visible output already delivered -> no fallback
- Cross-provider: same text via openrouter -> rate_limit fallback

* fix(fallback-classifier): guard on finalAssistantVisibleText delivery evidence

When finalAssistantVisibleText contains real visible output (non-empty,
non-silent-reply), the agent already delivered a response to the user.
The classifier must not trigger model fallback in that case, because the
user already has their answer and rotating models would only burn quota
without improving the outcome.

Adds a guard in classifyEmbeddedAgentRunResultForModelFallback() that
checks finalAssistantVisibleText after committed outbound delivery
evidence and before the hook_block check. Uses the existing
isSilentReplyPayloadText() helper to avoid suppressing NO_REPLY and
similar intentional silent tokens.

This fixes the already-delivered-output test case in the Codex
usage-limit real behavior proof test.

* fix(test): use toEqual for cross-provider proof test type safety

The ModelFallbackResultClassification union includes { error: unknown },
so accessing .reason/.code after not.toBeNull() fails type checking.
Use toEqual with the full expected object instead, matching the pattern
used in result-fallback-classifier.test.ts.

* fix(model-fallback): refresh usage-limit fallback

Signed-off-by: sallyom <somalley@redhat.com>

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: sallyom <somalley@redhat.com>
2026-06-23 00:55:17 -04:00
168 changed files with 3789 additions and 1164 deletions

View File

@@ -1177,7 +1177,9 @@ jobs:
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
strategy:
fail-fast: false
max-parallel: 12
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout

View File

@@ -22,12 +22,6 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -55,32 +49,32 @@ jobs:
include:
- language: javascript-typescript
category: core-auth-secrets
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: javascript-typescript
category: network-ssrf-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
- language: javascript-typescript
category: mcp-process-tool-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: blacksmith-4vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 25
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
- language: actions
category: actions
runs_on: blacksmith-8vcpu-ubuntu-2404
runs_on: ubuntu-24.04
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:

View File

@@ -220,7 +220,7 @@ jobs:
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: warn
if-no-files-found: error
retention-days: 14
- name: Fail on dead code

View File

@@ -89,7 +89,6 @@ jobs:
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: all
fail_on_qa_failure: false
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

View File

@@ -66,5 +66,5 @@ jobs:
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: warn
if-no-files-found: error
retention-days: 30

View File

@@ -97,5 +97,5 @@ jobs:
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: warn
if-no-files-found: error
retention-days: 30

View File

@@ -20,11 +20,6 @@ on:
required: true
default: release
type: string
fail_on_qa_failure:
description: Fail the workflow when the QA profile command exits non-zero
required: false
default: true
type: boolean
workflow_call:
inputs:
ref:
@@ -40,11 +35,6 @@ on:
description: Taxonomy QA profile id to run
required: true
type: string
fail_on_qa_failure:
description: Fail the reusable workflow when the QA profile command exits non-zero
required: false
default: false
type: boolean
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
@@ -367,8 +357,8 @@ jobs:
retention-days: 30
if-no-files-found: error
- name: Fail if configured QA gate failed
if: always() && inputs.fail_on_qa_failure
- name: Fail if QA profile failed
if: always()
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
QA_PROFILE: ${{ steps.profile.outputs.profile }}

View File

@@ -67,9 +67,9 @@ Release behavior:
- App Store release uses canonical `ai.openclawfoundation.app*` bundle IDs through a temporary generated xcconfig in `apps/ios/build/AppStoreRelease.xcconfig`.
- App Store release uses manual `Apple Distribution` signing with profile names pinned in `apps/ios/Config/AppStoreSigning.json`.
- Fastlane owns one-time Developer Portal setup, encrypted `match` signing sync to the repo/branch pinned in `apps/ios/Config/AppStoreSigning.json`, and release handling.
- App Store release also switches the app to `OpenClawPushTransport=relay`, `OpenClawPushDistribution=official`, `OpenClawPushAPNsEnvironment=production`, `OpenClawPushRelayProfile=production`, `OpenClawPushProofPolicy=appleStrict`, and the App-Attest-capable entitlement file.
- App Store release also switches the app to `OpenClawPushMode=appStore`, which derives relay transport, official distribution, the canonical production relay, production APNs, production relay profile, `appleStrict` proof, and the App-Attest-capable entitlement file.
- `pnpm ios:release:upload` generates App Store screenshots and uploads release notes before archiving and uploading the IPA.
- `pnpm ios:release` remains a compatibility alias for `pnpm ios:release:upload`; prefer the explicit upload command in new release docs and automation.
- The release archive is validated before upload by inspecting the exported IPA's signed entitlements, embedded App Store profile, and push mode. The upload fails if the IPA is not an App Store production relay build.
- App Review submission is manual in App Store Connect. The release lane uploads a build and metadata, but does not submit for review.
- The release flow does not modify `apps/ios/.local-signing.xcconfig` or `apps/ios/LocalSigning.xcconfig`.
- `apps/ios/version.json` is the pinned iOS release version source.
@@ -83,9 +83,8 @@ Release behavior:
Relay behavior for App Store builds:
- Release builds default to `https://ios-push-relay.openclaw.ai`.
- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com`
This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters.
- App Store release builds use the canonical hosted relay at `https://ios-push-relay.openclaw.ai`.
- App Store release builds reject custom relay URL overrides. Future self-hosted relay support should use a separate explicit release path, not the public App Store build lane.
Signing setup commands:
@@ -162,25 +161,19 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
Use `pnpm ios:release:signing:setup` for the initial portal setup, then `MATCH_PASSWORD=... pnpm ios:release:signing:sync:push` to publish encrypted Fastlane match assets to the shared private repo.
4. Optional: set a custom official relay URL for the build. If unset, the release flow uses `https://ios-push-relay.openclaw.ai`.
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
5. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
4. If you are starting a brand-new production release train, pin iOS to the current gateway version first:
```bash
pnpm ios:version:pin -- --from-gateway
```
6. Upload the build:
5. Upload the build:
```bash
pnpm ios:release:upload
```
7. Expected behavior:
6. Expected behavior:
- Fastlane reads `apps/ios/version.json`
- verifies synced iOS versioning artifacts
- resolves the next App Store Connect build number for that short version
@@ -188,15 +181,16 @@ pnpm ios:release:upload
- uploads release notes and screenshots to the editable App Store version
- generates `apps/ios/build/AppStoreRelease.xcconfig`
- archives `OpenClaw`
- validates the exported IPA's push mode, signed entitlements, and embedded App Store profile
- uploads the IPA to App Store Connect for TestFlight/App Review use
- leaves App Review submission for a maintainer to complete manually
8. Expected outputs after a successful run:
7. Expected outputs after a successful run:
- `apps/ios/build/app-store/OpenClaw-<version>.ipa`
- `apps/ios/build/app-store/OpenClaw-<version>.app.dSYM.zip`
- Fastlane log line like `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
9. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
8. If this is a fresh clone on a maintainer machine that already works elsewhere, it is OK to copy the non-secret `apps/ios/fastlane/.env` from another trusted local clone on the same Mac. The Keychain-backed private key remains machine-local and is not stored in the repo.
## iOS Versioning Workflow
@@ -246,13 +240,13 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- `apps/ios/Sources/OpenClaw.entitlements` derives `aps-environment` from the active build configuration/signing override.
- App Attest relay builds use `apps/ios/Sources/OpenClawAppAttest.entitlements`; local/direct builds do not require App Attest provisioning.
- APNs token registration to gateway happens only after gateway connection (`push.apns.register`).
- Local/manual builds default to `OpenClawPushTransport=direct`, `OpenClawPushDistribution=local`, and a development `aps-environment` entitlement.
- Local/manual Debug builds default to `OpenClawPushMode=localSandbox`, direct APNs registration, and a development `aps-environment` entitlement. Local/manual Release builds default to `OpenClawPushMode=localProduction` and direct production APNs registration.
- Your selected team/profile must support Push Notifications for the app bundle ID you are signing.
- If push capability or provisioning is wrong, APNs registration fails at runtime (check Xcode logs for `APNs registration failed`).
- The gateway host also needs direct APNs auth configured separately with `OPENCLAW_APNS_TEAM_ID`, `OPENCLAW_APNS_KEY_ID`, and either `OPENCLAW_APNS_PRIVATE_KEY_P8` or `OPENCLAW_APNS_PRIVATE_KEY_PATH`.
- Recommended gateway-host storage for the APNs `.p8` file is `~/.openclaw/credentials/apns/AuthKey_<KEYID>.p8` with restrictive permissions, then point `OPENCLAW_APNS_PRIVATE_KEY_PATH` at that file.
- `apps/ios/fastlane/.env` only covers App Store Connect / Fastlane auth; it does not provide gateway APNs credentials for local direct-push testing.
- Debug builds default to `OpenClawPushAPNsEnvironment=sandbox`; Release builds default to `production`.
- Debug builds default to sandbox APNs through `OpenClawPushMode=localSandbox`; Release builds default to production APNs through `OpenClawPushMode=localProduction`.
## APNs Expectations For Official Builds
@@ -261,7 +255,7 @@ See `apps/ios/VERSIONING.md` for the detailed spec.
- The relay registration is bound to the gateway identity fetched from `gateway.identity.get`, so another gateway cannot reuse that stored registration.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- If the relay base URL changes in a later build, the app refreshes the relay registration instead of reusing the old relay origin.
- Production relay mode uses the `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- App Store release mode uses the internal `production` relay profile, production APNs, App Attest, and a StoreKit app transaction JWS during registration.
- Gateway-side relay sending is configured through `gateway.push.apns.relay.baseUrl` in `openclaw.json`. `OPENCLAW_APNS_RELAY_BASE_URL` remains a temporary env override only.
## Official Build Relay Trust Model

View File

@@ -82,18 +82,10 @@
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>
<string>$(OPENCLAW_PUSH_APNS_ENVIRONMENT)</string>
<key>OpenClawPushDistribution</key>
<string>$(OPENCLAW_PUSH_DISTRIBUTION)</string>
<key>OpenClawPushProofPolicy</key>
<string>$(OPENCLAW_PUSH_PROOF_POLICY)</string>
<key>OpenClawPushMode</key>
<string>$(OPENCLAW_PUSH_MODE)</string>
<key>OpenClawPushRelayBaseURL</key>
<string>$(OPENCLAW_PUSH_RELAY_BASE_URL)</string>
<key>OpenClawPushRelayProfile</key>
<string>$(OPENCLAW_PUSH_RELAY_PROFILE)</string>
<key>OpenClawPushTransport</key>
<string>$(OPENCLAW_PUSH_TRANSPORT)</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -27,7 +27,16 @@ enum PushProofPolicy: String {
case internalSimulator
}
enum PushBuildMode: String {
case localSandbox
case localProduction
case appStore
case deviceSandbox
case simulatorSandbox
}
struct PushBuildConfig {
let mode: PushBuildMode
let transport: PushTransportMode
let distribution: PushDistributionMode
let relayBaseURL: URL?
@@ -54,31 +63,64 @@ struct PushBuildConfig {
}
init(bundle: Bundle = .main) {
self.transport = Self.readEnum(
bundle: bundle,
key: "OpenClawPushTransport",
fallback: .direct)
self.distribution = Self.readEnum(
bundle: bundle,
key: "OpenClawPushDistribution",
fallback: .local)
self.apnsEnvironment = Self.readEnum(
bundle: bundle,
key: "OpenClawPushAPNsEnvironment",
fallback: Self.defaultAPNsEnvironment)
self.relayProfile = Self.readEnum(
bundle: bundle,
key: "OpenClawPushRelayProfile",
fallback: Self.defaultRelayProfile(apnsEnvironment: self.apnsEnvironment))
self.proofPolicy = Self.readEnum(
bundle: bundle,
key: "OpenClawPushProofPolicy",
fallback: Self.defaultProofPolicy(relayProfile: self.relayProfile))
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
self.init(readValue: { bundle.object(forInfoDictionaryKey: $0) })
}
private static func readURL(bundle: Bundle, key: String) -> URL? {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
init(infoDictionary: [String: Any]) {
self.init(readValue: { infoDictionary[$0] })
}
private init(readValue: (String) -> Any?) {
self.mode = Self.readEnum(
readValue: readValue,
key: "OpenClawPushMode",
fallback: .localSandbox)
let relayBaseURLOverride = Self.readURL(
readValue: readValue,
key: "OpenClawPushRelayBaseURL")
switch self.mode {
case .localSandbox:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .localProduction:
self.transport = .direct
self.distribution = .local
self.relayBaseURL = nil
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .appStore:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = URL(string: "https://\(Self.openClawHostedRelayHost)")!
self.apnsEnvironment = .production
self.relayProfile = .production
self.proofPolicy = .appleStrict
case .deviceSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .deviceSandbox
self.proofPolicy = .appleDevelopment
case .simulatorSandbox:
self.transport = .relay
self.distribution = .official
self.relayBaseURL = relayBaseURLOverride
?? URL(string: "https://\(Self.openClawSandboxRelayHost)")!
self.apnsEnvironment = .sandbox
self.relayProfile = .simulatorSandbox
self.proofPolicy = .internalSimulator
}
}
private static func readURL(readValue: (String) -> Any?, key: String) -> URL? {
guard let raw = readValue(key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard let components = URLComponents(string: trimmed),
@@ -96,29 +138,12 @@ struct PushBuildConfig {
}
private static func readEnum<T: RawRepresentable>(
bundle: Bundle,
readValue: (String) -> Any?,
key: String,
fallback: T)
-> T where T.RawValue == String {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return fallback }
guard let raw = readValue(key) as? String else { return fallback }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return T(rawValue: trimmed) ?? T(rawValue: trimmed.lowercased()) ?? fallback
}
private static let defaultAPNsEnvironment: PushAPNsEnvironment = .sandbox
private static func defaultRelayProfile(apnsEnvironment: PushAPNsEnvironment) -> PushRelayProfile {
apnsEnvironment == .production ? .production : .deviceSandbox
}
private static func defaultProofPolicy(relayProfile: PushRelayProfile) -> PushProofPolicy {
switch relayProfile {
case .production:
.appleStrict
case .deviceSandbox:
.appleDevelopment
case .simulatorSandbox:
.internalSimulator
}
}
}

View File

@@ -69,7 +69,7 @@ actor PushRegistrationManager {
async throws -> String {
guard self.buildConfig.distribution == .official else {
throw PushRelayError.relayMisconfigured(
"Relay transport requires OpenClawPushDistribution=official")
"Relay transport requires an official push build mode")
}
try Self.validateRelayContract(
relayProfile: self.buildConfig.relayProfile,

View File

@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import OpenClaw
struct PushBuildConfigTests {
@Test func `app store mode derives production relay contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "appStore",
"OpenClawPushRelayBaseURL": "https://wrong.example.com",
])
#expect(config.mode == .appStore)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://ios-push-relay.openclaw.ai")
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
@Test func `simulator sandbox mode derives internal proof contract`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "simulatorSandbox",
"OpenClawPushRelayBaseURL": "https://staging-relay.example.com",
])
#expect(config.mode == .simulatorSandbox)
#expect(config.transport == .relay)
#expect(config.distribution == .official)
#expect(config.relayBaseURL?.absoluteString == "https://staging-relay.example.com")
#expect(config.apnsEnvironment == .sandbox)
#expect(config.relayProfile == .simulatorSandbox)
#expect(config.proofPolicy == .internalSimulator)
}
@Test func `local release mode remains direct production push`() {
let config = PushBuildConfig(infoDictionary: [
"OpenClawPushMode": "localProduction",
"OpenClawPushRelayBaseURL": "https://ios-push-relay.openclaw.ai",
])
#expect(config.mode == .localProduction)
#expect(config.transport == .direct)
#expect(config.distribution == .local)
#expect(config.relayBaseURL == nil)
#expect(config.apnsEnvironment == .production)
#expect(config.relayProfile == .production)
#expect(config.proofPolicy == .appleStrict)
}
}

View File

@@ -15,13 +15,12 @@
import Foundation
import XCTest
var deviceLanguage = ""
var locale = ""
@MainActor
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
@@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
@@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
@@ -59,6 +60,8 @@ open class Snapshot: NSObject {
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
@@ -103,17 +106,17 @@ open class Snapshot: NSObject {
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
}
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
}
}
@@ -165,7 +168,7 @@ open class Snapshot: NSObject {
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS)
#if os(iOS) && !targetEnvironment(macCatalyst)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
@@ -181,7 +184,7 @@ open class Snapshot: NSObject {
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
@@ -281,6 +284,7 @@ private extension XCUIElementQuery {
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
@@ -306,4 +310,4 @@ private extension CGFloat {
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.27]
// SnapshotHelperVersion [1.30]

View File

@@ -10,7 +10,24 @@ default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
/\AiPhone .* Plus\z/,
/\AiPhone .*\z/
]
},
{
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
/\AiPad Air 13-inch/,
/\AiPad .*13-inch/
]
}
].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
@@ -77,11 +94,23 @@ end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
return default_snapshot_devices if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
match
end
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
@@ -113,6 +142,51 @@ def resolve_simulator_device(name)
fallback
end
def install_ready_for_review_edit_state_lookup!
require "spaceship"
app_class = Spaceship::ConnectAPI::App
app_class.class_eval do
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
end
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
end
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
return version if version
# First public releases can leave the only version in READY_FOR_REVIEW.
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
# second version; use the existing review-ready version as the edit target.
client ||= Spaceship::ConnectAPI
platform ||= Spaceship::ConnectAPI::Platform::IOS
filter = {
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
platform: platform
}
get_app_store_versions(client: client, filter: filter, includes: includes)
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
.last
end
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
return app_info if app_info
client ||= Spaceship::ConnectAPI
client
.get_app_infos(app_id: id, includes: includes)
.to_models
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
end
end
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
@@ -754,6 +828,11 @@ def prepare_app_store_release!(version:, build_number:)
release_xcconfig
end
def validate_app_store_ipa!(ipa_path)
script_path = File.join(repo_root, "scripts", "ios-validate-app-store-ipa.sh")
sh(shell_join(["bash", script_path, "--ipa", ipa_path]))
end
def build_app_store_release(context)
version = context[:version]
project_path = File.join(ios_root, "OpenClaw.xcodeproj")
@@ -804,6 +883,7 @@ def build_app_store_release(context)
UI.user_error!("xcodebuild export produced multiple IPAs in #{output_directory}: #{exported_ipas.join(", ")}") if exported_ipas.length > 1
exported_ipa = exported_ipas.first
FileUtils.mv(exported_ipa, expected_ipa_path) unless exported_ipa == expected_ipa_path
validate_app_store_ipa!(expected_ipa_path)
{
archive_path: archive_path,
@@ -923,25 +1003,12 @@ platform :ios do
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Build + upload an App Store distribution build to App Store Connect"
lane :app_store do
context = prepare_app_store_context(require_api_key: true)
build = build_app_store_release(context)
upload_to_testflight(
api_key: context[:api_key],
ipa: build[:ipa_path],
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
UI.success("Uploaded iOS App Store build: version=#{build[:version]} short=#{build[:short_version]} build=#{build[:build_number]}")
ensure
ENV.delete("XCODE_XCCONFIG_FILE")
end
desc "Generate screenshots, update App Store version metadata, then upload an App Store build"
lane :release_upload do
unless ENV["OPENCLAW_IOS_RELEASE_WRAPPER"] == "1"
UI.user_error!("Use `pnpm ios:release:upload`; direct Fastlane TestFlight upload is disabled.")
end
release_signing_check!
preserve_local_signing do
screenshots
@@ -968,6 +1035,7 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = app_store_connect_api_key_config

View File

@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to App Store Connect:
@@ -112,12 +112,9 @@ Upload to App Store Connect:
pnpm ios:release:upload
```
Direct Fastlane entry point:
```bash
cd apps/ios
fastlane ios release_upload
```
Direct Fastlane TestFlight upload is disabled. Use the package script so the
release wrapper, App Store push mode, and exported-IPA validation gate all run
in the same path.
Maintainer recovery path for a fresh clone on the same Mac:
@@ -144,13 +141,7 @@ fastlane ios auth_check
pnpm ios:version:pin -- --from-gateway
```
5. Set the official relay URL before release:
```bash
export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com
```
6. Upload:
5. Upload:
```bash
pnpm ios:release:upload
@@ -159,6 +150,7 @@ pnpm ios:release:upload
Quick verification after upload:
- confirm `apps/ios/build/app-store/OpenClaw-<version>.ipa` exists
- confirm Fastlane validates the exported IPA before upload
- confirm Fastlane prints `Uploaded iOS App Store build: version=<version> short=<short> build=<build>`
- remember that App Store Connect/TestFlight processing can take a few minutes after the upload succeeds
@@ -175,5 +167,7 @@ Versioning rules:
- `pnpm ios:version:check` validates that checked-in iOS version artifacts are in sync
- The release flow regenerates `apps/ios/OpenClaw.xcodeproj` from `apps/ios/project.yml` before archiving
- Local App Store signing uses a temporary generated xcconfig with profile names from `apps/ios/Config/AppStoreSigning.json` and leaves local development signing overrides untouched
- App Store release uses `OpenClawPushMode=appStore`, which derives the canonical production hosted relay, production APNs, production relay profile, and `appleStrict` proof. The release lane rejects custom production relay URL overrides.
- The exported IPA is validated before upload by inspecting its push mode, signed entitlements, and embedded App Store profile.
- `pnpm ios:release:upload` generates and uploads screenshots and release notes before archiving, then uploads the IPA without submitting it for App Review
- See `apps/ios/VERSIONING.md` for the detailed workflow

View File

@@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj")
scheme("OpenClawUITests")
configuration("Debug")
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)",
])
# The Fastfile screenshot lane resolves concrete device names from the installed
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
# this file intentionally does not hardcode simulator model names.
languages([
"en-US",

View File

@@ -122,21 +122,13 @@ targets:
Debug:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: development
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_MODE: localSandbox
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: sandbox
OPENCLAW_PUSH_RELAY_PROFILE: deviceSandbox
OPENCLAW_PUSH_PROOF_POLICY: appleDevelopment
Release:
OPENCLAW_CODE_SIGN_ENTITLEMENTS: Sources/OpenClaw.entitlements
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT: production
OPENCLAW_PUSH_TRANSPORT: direct
OPENCLAW_PUSH_DISTRIBUTION: local
OPENCLAW_PUSH_MODE: localProduction
OPENCLAW_PUSH_RELAY_BASE_URL: ""
OPENCLAW_PUSH_APNS_ENVIRONMENT: production
OPENCLAW_PUSH_RELAY_PROFILE: production
OPENCLAW_PUSH_PROOF_POLICY: appleStrict
info:
path: Sources/Info.plist
properties:
@@ -178,12 +170,8 @@ targets:
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for talk mode and voice wake.
NSSupportsLiveActivities: true
ITSAppUsesNonExemptEncryption: false
OpenClawPushTransport: "$(OPENCLAW_PUSH_TRANSPORT)"
OpenClawPushDistribution: "$(OPENCLAW_PUSH_DISTRIBUTION)"
OpenClawPushMode: "$(OPENCLAW_PUSH_MODE)"
OpenClawPushRelayBaseURL: "$(OPENCLAW_PUSH_RELAY_BASE_URL)"
OpenClawPushAPNsEnvironment: "$(OPENCLAW_PUSH_APNS_ENVIRONMENT)"
OpenClawPushRelayProfile: "$(OPENCLAW_PUSH_RELAY_PROFILE)"
OpenClawPushProofPolicy: "$(OPENCLAW_PUSH_PROOF_POLICY)"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown

View File

@@ -1,4 +1,3 @@
// Bundled A2UI runtime resource embedded by OpenClawKit.
var __defProp$1 = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
@@ -11936,6 +11935,10 @@ var __runInitializers = function(thisArg, initializers, value) {
};
return _classThis;
})();
/**
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
* helpers.
*/
const modalStyles = i$10`
dialog {
position: fixed;

View File

@@ -23,8 +23,8 @@ OpenClaw agent or Gateway.
```bash
openclaw skills search "calendar"
openclaw skills install <slug>
openclaw skills update <slug>
openclaw skills install @owner/<slug>
openclaw skills update @owner/<slug>
openclaw skills verify <slug>
openclaw plugins search "calendar"

View File

@@ -24,13 +24,13 @@ where you have publisher access.
Skills are published from a skill folder. The public page is:
```text
https://clawhub.ai/<owner>/<slug>
https://clawhub.ai/<owner>/skills/<slug>
```
Example:
```text
https://clawhub.ai/alice/review-helper
https://clawhub.ai/alice/skills/review-helper
```
The publish request includes the selected owner, slug, version, changelog, and

View File

@@ -25,16 +25,16 @@ Related:
```bash
openclaw skills search "calendar"
openclaw skills search --limit 20 --json
openclaw skills install <slug>
openclaw skills install <slug> --version <version>
openclaw skills install @owner/<slug>
openclaw skills install @owner/<slug> --version <version>
openclaw skills install git:owner/repo
openclaw skills install git:owner/repo@main
openclaw skills install ./path/to/skill --as custom-name
openclaw skills install <slug> --force
openclaw skills install <slug> --agent <id>
openclaw skills install <slug> --global
openclaw skills update <slug>
openclaw skills update <slug> --global
openclaw skills install @owner/<slug> --force
openclaw skills install @owner/<slug> --agent <id>
openclaw skills install @owner/<slug> --global
openclaw skills update @owner/<slug>
openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
openclaw skills update --all --global
@@ -64,8 +64,8 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
`install ./path` copies a local skill directory. By default, `install`, `update`,
and `verify` target the active workspace `skills/` directory; with `--global`,
they target the shared managed skills directory. `list`/`info`/`check` still
@@ -94,15 +94,15 @@ Notes:
`SKILL.md`.
- `install --as <slug>` overrides the inferred slug for Git and local directory
installs.
- `install --version <version>` applies only to ClawHub skill slugs.
- `install --version <version>` applies only to ClawHub skill refs.
- `install --force` overwrites an existing workspace skill folder for the same
slug.
- `--global` targets the shared managed skills directory and cannot be combined
with `--agent <id>`.
- `--agent <id>` targets one configured agent workspace and overrides current
working directory inference.
- `update <slug>` updates a single tracked skill. Add `--global` to target the
shared managed skills directory instead of the workspace.
- `update @owner/<slug>` updates a single tracked skill. Add `--global` to
target the shared managed skills directory instead of the workspace.
- `update --all` updates tracked ClawHub installs in the selected workspace, or
in the shared managed skills directory when combined with `--global`.
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by

View File

@@ -602,7 +602,7 @@ See [Inferred commitments](/concepts/commitments).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `wss://` for public hosts; plaintext `ws://` is accepted only for loopback, LAN, link-local, `.local`, `.ts.net`, and Tailscale CGNAT hosts.
- `remote.remotePort`: gateway port on the remote SSH host. Defaults to `18789`; use this when the local tunnel port differs from the remote gateway port.
- `gateway.remote.token` / `.password` are remote-client credential fields. They do not configure gateway auth by themselves.
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used by official/TestFlight iOS builds after they publish relay-backed registrations to the gateway. This URL must match the relay URL compiled into the iOS build.
- `gateway.push.apns.relay.baseUrl`: base HTTPS URL for the external APNs relay used after relay-backed iOS builds publish registrations to the gateway. Public App Store/TestFlight builds use the hosted OpenClaw relay. Custom relay URLs must match a deliberately separate iOS build/deployment path whose relay URL points at that relay.
- `gateway.push.apns.relay.timeoutMs`: gateway-to-relay send timeout in milliseconds. Defaults to `10000`.
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.

View File

@@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
</Accordion>
<Accordion title="Enable relay-backed push for official iOS builds">
Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`.
Relay-backed push for public App Store/TestFlight builds uses the hosted OpenClaw relay: `https://ios-push-relay.openclaw.ai`.
To use a custom relay, set this in gateway config:
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. If you are using a custom relay build, set this in gateway config:
```json5
{
@@ -369,12 +369,12 @@ candidate contains redacted secret placeholders such as `***`.
- Uses a registration-scoped send grant forwarded by the paired iOS app. The gateway does not need a deployment-wide relay token.
- Binds each relay-backed registration to the gateway identity that the iOS app paired with, so another gateway cannot reuse the stored registration.
- Keeps local/manual iOS builds on direct APNs. Relay-backed sends apply only to official distributed builds that registered through the relay.
- Must match the relay base URL baked into the official/TestFlight iOS build, so registration and send traffic reach the same relay deployment.
- Must match the relay base URL baked into the iOS build, so registration and send traffic reach the same relay deployment.
End-to-end flow:
1. Install an official/TestFlight iOS build.
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
3. Pair the iOS app to the gateway and let both node and operator sessions connect.
4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway.
5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes.
@@ -387,7 +387,7 @@ candidate contains redacted secret placeholders such as `***`.
Compatibility note:
- `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides.
- Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build.
- Custom gateway relay URLs must match the relay base URL baked into the iOS build. The public App Store release lane rejects custom iOS relay URL overrides.
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config.
See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model.

View File

@@ -346,10 +346,10 @@ lives on the [First-run FAQ](/help/faq-first-run).
```bash
openclaw skills search "calendar"
openclaw skills search --limit 20
openclaw skills install <skill-slug>
openclaw skills install <skill-slug> --version <version>
openclaw skills install <skill-slug> --force
openclaw skills install <skill-slug> --global
openclaw skills install @owner/<skill-slug>
openclaw skills install @owner/<skill-slug> --version <version>
openclaw skills install @owner/<skill-slug> --force
openclaw skills install @owner/<skill-slug> --global
openclaw skills update --all
openclaw skills update --all --global
openclaw skills list --eligible
@@ -433,11 +433,11 @@ lives on the [First-run FAQ](/help/faq-first-run).
Install skills:
```bash
openclaw skills install <skill-slug>
openclaw skills install @owner/<skill-slug>
openclaw skills update --all
```
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install <slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install @owner/<skill-slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
</Accordion>

View File

@@ -75,9 +75,9 @@ openclaw gateway call node.list --params "{}"
Official distributed iOS builds use the external push relay instead of publishing the raw APNs
token to the gateway.
By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`.
Official/TestFlight builds from the public App Store release lane use the hosted relay at `https://ios-push-relay.openclaw.ai`.
Custom relay deployments can override the gateway relay URL:
Custom relay deployments require a deliberately separate iOS build/deployment path whose relay URL matches the gateway relay URL. The public App Store release lane does not accept custom relay URL overrides. If you are using a custom relay build, set the matching gateway relay URL:
```json5
{
@@ -100,7 +100,7 @@ How the flow works:
- The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway.
- The app forwards that relay-backed registration to the paired gateway with `push.apns.register`.
- The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges.
- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build.
- Custom gateway relay URLs must match the relay URL baked into the iOS build.
- If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding.
What the gateway does **not** need for this path:
@@ -111,7 +111,7 @@ What the gateway does **not** need for this path:
Expected operator flow:
1. Install the official/TestFlight iOS build.
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment.
2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a deliberately separate custom relay build.
3. Pair the app to the gateway and let it finish connecting.
4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds.
5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration.
@@ -130,7 +130,7 @@ compatible but does not count as a durable last-seen update.
Compatibility note:
- `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway.
- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds.
- The public App Store release lane rejects `OPENCLAW_PUSH_RELAY_BASE_URL` for iOS builds.
## Authentication and trust flow

View File

@@ -191,6 +191,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
| `name` | No | `string` | Human-readable plugin name. |
| `description` | No | `string` | Short summary shown in plugin surfaces. |
| `icon` | No | `string` | HTTPS image URL for marketplace/catalog cards. ClawHub accepts any valid `https://` URL and falls back to the default plugin icon when this is omitted or invalid. |
| `version` | No | `string` | Informational plugin version. |
| `uiHints` | No | `Record<string, object>` | UI labels, placeholders, and sensitivity hints for config fields. |

View File

@@ -67,7 +67,7 @@ Wraps papla.media TTS and sends results as Telegram voice notes (no annoying aut
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
</Card>
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/codexmonitor">
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/skills/codexmonitor">
**@odrobnik** • `devtools` `codex` `brew`
Homebrew-installed helper to list, inspect, and watch local OpenAI Codex sessions (CLI + VS Code).
@@ -75,7 +75,7 @@ Homebrew-installed helper to list, inspect, and watch local OpenAI Codex session
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawHub" />
</Card>
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/bambu-cli">
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/skills/bambu-cli">
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
@@ -83,7 +83,7 @@ Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibrati
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawHub" />
</Card>
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/wienerlinien">
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/skills/wienerlinien">
**@hjanuschka** • `travel` `transport` `skill`
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
@@ -97,7 +97,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
</Card>
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/skills/r2-upload">
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/julianengel/skills/r2-upload">
**@julianengel** • `files` `r2` `presigned-urls`
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
@@ -267,7 +267,7 @@ Speech-first entry points, phone bridges, and transcription-heavy workflows.
Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with your agent.
</Card>
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/openrouter-transcribe">
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/skills/openrouter-transcribe">
**@obviyus** • `transcription` `multilingual` `skill`
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
@@ -289,8 +289,8 @@ Packaging, deployment, and integrations that make OpenClaw easier to run and ext
OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persistent state.
</Card>
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/skills/homeassistant">
**ClawHub**`homeassistant` `skill` `automation`
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/homeofe/skills/openclaw-homeassistant">
**@homeofe** • `homeassistant` `skill` `automation`
Control and automate Home Assistant devices via natural language.
@@ -303,8 +303,8 @@ Control and automate Home Assistant devices via natural language.
Batteries-included nixified OpenClaw configuration for reproducible deployments.
</Card>
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/skills/caldav-calendar">
**ClawHub**`calendar` `caldav` `skill`
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/asleep123/skills/caldav-calendar">
**@asleep123** • `calendar` `caldav` `skill`
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.

View File

@@ -225,7 +225,7 @@ See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
metadata:
```bash
openclaw skills install clawhub-publish
openclaw skills install @openclaw/clawhub-publish
```
</Step>

View File

@@ -88,8 +88,9 @@ still returns one synthesized answer with citations rather than an N-result
list.
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
`date_after`/`date_before` range, into Gemini Google Search grounding's
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
`date_after`/`date_before` ranges set Gemini Google Search grounding's
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
## Model selection

View File

@@ -145,12 +145,12 @@ publish and sync.
| Action | Command |
| ---------------------------------- | ------------------------------------------------------ |
| Install a skill into the workspace | `openclaw skills install <slug>` |
| Install a skill into the workspace | `openclaw skills install @owner/<slug>` |
| Install from a Git repository | `openclaw skills install git:owner/repo@ref` |
| Install a local skill directory | `openclaw skills install ./path/to/skill --as my-tool` |
| Install for all local agents | `openclaw skills install <slug> --global` |
| Install for all local agents | `openclaw skills install @owner/<slug> --global` |
| Update all workspace skills | `openclaw skills update --all` |
| Update a shared managed skill | `openclaw skills update <slug> --global` |
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
| Update all shared managed skills | `openclaw skills update --all --global` |
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
@@ -179,7 +179,7 @@ publish and sync.
with detail pages for VirusTotal, ClawScan, and static analysis. The
command exits non-zero when ClawHub marks verification as failed. Publishers
recover false positives through the ClawHub dashboard or
`clawhub skill rescan <slug>`.
`clawhub skill rescan @owner/<slug>`.
</Accordion>
<Accordion title="Private archive installs">

View File

@@ -389,8 +389,8 @@ show the `x_search` prompt.
freshness ranges require both start and end dates.
Gemini, Grok, and Kimi return one synthesized answer with citations. They
accept `count` for shared-tool compatibility, but it does not change the
grounded answer shape. Gemini supports `freshness`, `date_after`, and
`date_before` by converting them to Google Search grounding time ranges.
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
freshness values and explicit dates set Google Search grounding time ranges.
Perplexity behaves the same way when you use the Sonar/OpenRouter
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
`model` or `OPENROUTER_API_KEY`).

View File

@@ -1,5 +1,6 @@
{
"id": "alibaba",
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "anthropic-vertex",
"name": "Anthropic Vertex",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "anthropic",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "brave",
"name": "Brave",
"description": "OpenClaw Brave Search provider plugin for web search.",
"icon": "https://cdn.simpleicons.org/brave/111111",
"activation": {
"onStartup": false
},

View File

@@ -15,8 +15,12 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
const require = createRequire(import.meta.url);
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const hashFile =
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>

View File

@@ -11,7 +11,9 @@ const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");

View File

@@ -1,5 +1,6 @@
{
"id": "cloudflare-ai-gateway",
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
"activation": {
"onStartup": false
},

View File

@@ -1102,362 +1102,6 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("marks delivered message-tool-only source replies as terminal", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "imessage-6264" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
const registry = createEmptyPluginRegistry();
registry.agentToolResultMiddlewares.push({
pluginId: "receipt-redactor",
pluginName: "Receipt redactor",
rawHandler: () => undefined,
handler: (event: { result: AgentToolResult<unknown> }) => ({
result: {
content: event.result.content,
details: { redacted: true },
},
}),
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
receipt: {
primaryPlatformMessageId: "imessage-6264",
platformMessageIds: ["imessage-6264"],
},
}),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "chat-1",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "chat-1",
text: "visible reply",
}),
]);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "853",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-857",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-857",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "857",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
expect.objectContaining({
tool: "message",
provider: "imessage",
to: "+12069106512",
text: "visible reply",
}),
]);
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
const receiptText = JSON.stringify({
ok: true,
messageId: "provider-message-1",
repliedTo: "provider-guid-861",
});
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
currentMessageId: "provider-guid-861",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "861",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText(receiptText));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "863",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelId: "imessage:any;-;+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "865",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("records message-tool-owned terminal replies as delivered source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
{
...textToolResult("Sent.", { ok: true }),
terminate: true,
} as AgentToolResult<unknown>,
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "+12069106512",
messageId: "867",
message: "visible reply",
buttons: [],
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBe(true);
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
expect(Object.keys(result)).not.toContain("terminate");
});
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
});
const result = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
const execute = vi
.fn()
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
});
const firstResult = await handleMessageToolCall(bridge, {
action: "send",
message: "visible reply",
});
const secondResult = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-2",
namespace: null,
tool: "message",
arguments: { action: "inspect" },
});
expect(firstResult.terminate).toBe(true);
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
expect(secondResult).toEqual(expectInputText("No message sent."));
expect(secondResult.terminate).toBeUndefined();
});
it("does not mark explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", { messageId: "other-chat-message" }),
{ sourceReplyDeliveryMode: "message_tool_only" },
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "channel:other",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
});
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "slack",
target: "+12069106512",
messageId: "853",
message: "cross-provider reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not let matching reply receipts override explicit non-source routes", async () => {
const bridge = createBridgeWithToolResult(
"message",
textToolResult("Sent.", {
ok: true,
messageId: "other-chat-message",
repliedTo: "provider-guid-853",
}),
{
sourceReplyDeliveryMode: "message_tool_only",
currentChannelProvider: "imessage",
currentChannelId: "imessage:+12069106512",
currentMessagingTarget: "+12069106512",
currentMessageId: "provider-guid-853",
},
);
const result = await handleMessageToolCall(bridge, {
action: "reply",
channel: "imessage",
target: "other-chat",
message: "cross-channel reply",
});
expect(result).toEqual(expectInputText("Sent."));
expect(result.terminate).toBeUndefined();
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
});
it("does not record messaging side effects when the send fails", async () => {
const tool = createTool({
name: "message",

View File

@@ -18,7 +18,6 @@ import {
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isDeliveredMessageToolOnlySourceReplyResult,
isReplaySafeToolCall,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
@@ -64,11 +63,9 @@ type CodexDynamicToolHookContext = {
currentChannelProvider?: string;
currentChannelId?: string;
currentMessagingTarget?: string;
currentMessageId?: string | number;
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
@@ -103,166 +100,6 @@ function applyCurrentMessageProvider(
return { ...args, provider };
}
function normalizeRouteToken(value: string | number | undefined): string | undefined {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : undefined;
}
const normalized = value?.trim().toLowerCase();
return normalized ? normalized : undefined;
}
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
const tokens = new Set<string>();
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
if (currentTarget) {
tokens.add(currentTarget);
}
if (currentChannel) {
tokens.add(currentChannel);
}
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
if (channelPrefixIndex >= 0 && currentChannel) {
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
for (const segment of unprefixedChannel.split(/[;,]/u)) {
const token = normalizeRouteToken(segment);
if (token) {
tokens.add(token);
}
}
}
}
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
if (unprefixedChannel) {
tokens.add(unprefixedChannel);
}
}
return tokens;
}
function routeTokenMatchesSource(
token: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
}
function routeProviderMatchesSource(
provider: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(provider);
if (!normalized) {
return false;
}
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
}
function routeTokenMatchesCurrentMessage(
token: string | undefined,
hookContext: CodexDynamicToolHookContext | undefined,
): boolean {
const normalized = normalizeRouteToken(token);
return (
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
);
}
function replyReceiptMatchesCurrentMessage(
value: unknown,
hookContext: CodexDynamicToolHookContext | undefined,
depth = 0,
): boolean {
if (depth > 4 || value === null) {
return false;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
return false;
}
try {
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
} catch {
return false;
}
}
if (typeof value !== "object") {
return false;
}
if (Array.isArray(value)) {
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
}
const record = value as Record<string, unknown>;
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
if (
routeTokenMatchesCurrentMessage(
typeof record[key] === "string" ? record[key] : undefined,
hookContext,
)
) {
return true;
}
}
for (const key of [
"content",
"details",
"payload",
"receipt",
"result",
"results",
"sendResult",
"text",
]) {
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
return true;
}
}
return false;
}
function hasExplicitNonSourceMessageRoute(
args: Record<string, unknown>,
hookContext: CodexDynamicToolHookContext | undefined,
messagingTarget: MessagingToolSend | undefined,
): boolean {
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
if (
provider &&
currentProvider !== provider &&
!routeProviderMatchesSource(provider, hookContext)
) {
return true;
}
}
const targetValues = [
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
typeof args[key] === "string" ? args[key] : undefined,
),
...(Array.isArray(args.targets)
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
: []),
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
if (targetValues.length === 0) {
return false;
}
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
return true;
}
return (
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
);
}
/** Runtime bridge returned to Codex app-server attempt code. */
export type CodexDynamicToolBridge = {
availableSpecs: CodexDynamicToolSpec[];
@@ -277,7 +114,6 @@ export type CodexDynamicToolBridge = {
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -296,8 +132,6 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
// spawn_agent remains the primary Codex subagent surface.
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
/**
@@ -342,7 +176,6 @@ export function createCodexDynamicToolBridge(params: {
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
const telemetry: CodexDynamicToolBridge["telemetry"] = {
didSendViaMessagingTool: false,
didDeliverSourceReplyViaMessageTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],
messagingToolSentTargets: [],
@@ -500,9 +333,10 @@ export function createCodexDynamicToolBridge(params: {
executedArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget = isMessagingTool(toolName)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
@@ -524,46 +358,12 @@ export function createCodexDynamicToolBridge(params: {
},
terminalType,
);
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
executedArgs,
params.hookContext,
confirmedMessagingTarget,
);
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
toolName,
args: executedArgs,
result,
hookResult: rawResult,
isError: resultIsError,
allowExplicitSourceRoute: !blocksSourceReplyTermination,
});
const receiptConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
normalizeRouteToken(
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
) === "reply" &&
!resultIsError &&
!blocksSourceReplyTermination &&
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
const toolConfirmedSourceReply =
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
toolName === "message" &&
!resultIsError &&
(rawResult.terminate === true || result.terminate === true);
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
telemetry.didDeliverSourceReplyViaMessageTool = true;
}
withDynamicToolTermination(
response,
rawResult.terminate === true ||
result.terminate === true ||
isToolResultYield(rawResult) ||
isToolResultYield(result) ||
deliveredSourceReply ||
receiptConfirmedSourceReply,
isToolResultYield(result),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
@@ -1003,7 +803,7 @@ function collectToolTelemetry(params: {
}
if (
!isMessagingTool(params.toolName) ||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
!isMessagingToolSendAction(params.toolName, params.args)
) {
return;
}

View File

@@ -794,19 +794,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.toolMediaUrls).toStrictEqual([]);
});
it("propagates message-tool-only source reply delivery telemetry", async () => {
const projector = await createProjector();
const result = projector.buildResult({
...buildEmptyToolTelemetry(),
didSendViaMessagingTool: true,
didDeliverSourceReplyViaMessageTool: true,
});
expect(result.didSendViaMessagingTool).toBe(true);
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
});
it("does not promote repeated tool progress text to the final assistant reply", async () => {
const onToolResult = vi.fn();
const projector = await createProjector({

View File

@@ -53,7 +53,6 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
export type CodexAppServerToolTelemetry = {
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
messagingToolSentTexts: string[];
messagingToolSentMediaUrls: string[];
messagingToolSentTargets: MessagingToolSend[];
@@ -413,8 +412,6 @@ export class CodexAppServerEventProjector {
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
didDeliverSourceReplyViaMessageTool:
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,

View File

@@ -841,11 +841,9 @@ export async function runCodexAppServerAttempt(
currentChannelProvider: resolveCodexMessageToolProvider(params),
currentChannelId: params.currentChannelId,
currentMessagingTarget: params.currentMessagingTarget,
currentMessageId: params.currentMessageId,
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},

View File

@@ -1,5 +1,6 @@
{
"id": "copilot-proxy",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "copilot",
"name": "GitHub Copilot agent runtime",
"description": "Registers the GitHub Copilot agent runtime.",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"version": "2026.6.2",
"activation": {
"onStartup": false,

View File

@@ -1,5 +1,6 @@
{
"id": "deepgram",
"icon": "https://cdn.simpleicons.org/deepgram/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "deepseek",
"icon": "https://cdn.simpleicons.org/deepseek/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "discord",
"name": "Discord",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"icon": "https://cdn.simpleicons.org/discord/111111",
"skills": ["./skills"],
"activation": {
"onStartup": false

View File

@@ -1,5 +1,6 @@
{
"id": "duckduckgo",
"icon": "https://cdn.simpleicons.org/duckduckgo/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "elevenlabs",
"icon": "https://cdn.simpleicons.org/elevenlabs/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "google-meet",
"name": "Google Meet",
"description": "OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.",
"icon": "https://cdn.simpleicons.org/googlemeet/111111",
"enabledByDefault": false,
"commandAliases": [{ "name": "googlemeet" }],
"activation": {

View File

@@ -1,5 +1,6 @@
{
"id": "google",
"icon": "https://cdn.simpleicons.org/google/111111",
"activation": {
"onStartup": false
},

View File

@@ -73,6 +73,8 @@ const GEMINI_FRESHNESS_DAYS: Record<GeminiFreshness, number> = {
year: 365,
};
const GEMINI_DAY_FRESHNESS_HINT = "Prioritize web sources published in the last 24 hours.";
// Gemini's google_search.time_range_filter accepts second-precision RFC 3339
// only. Despite the underlying google.protobuf.Timestamp type accepting "0, 3,
// 6 or 9 fractional digits", the Search grounding endpoint rejects any
@@ -99,11 +101,18 @@ function freshnessStartTime(freshness: GeminiFreshness, now: Date): string {
return toGeminiTimeRangeTimestamp(start);
}
function queryWithSoftFreshness(query: string, freshness?: "day"): string {
if (freshness !== "day") {
return query;
}
return `${query}\n\nSearch recency instruction: ${GEMINI_DAY_FRESHNESS_HINT} If no matching recent sources are available, state that limitation and use the most relevant available sources.`;
}
function resolveGeminiTimeRangeFilter(
args: Record<string, unknown>,
now = new Date(),
):
| { timeRangeFilter?: GeminiTimeRangeFilter }
| { timeRangeFilter?: GeminiTimeRangeFilter; freshness?: "day" }
| {
error:
| "invalid_freshness"
@@ -133,6 +142,13 @@ function resolveGeminiTimeRangeFilter(
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
if (freshness) {
// Gemini rejects 24-hour google_search.timeRangeFilter windows, while
// wider freshness windows still preserve the hard grounding contract.
if (freshness === "day") {
return {
freshness,
};
}
return {
timeRangeFilter: {
startTime: freshnessStartTime(freshness, now),
@@ -321,6 +337,7 @@ export async function executeGeminiSearch(
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
timeRange.freshness,
timeRange.timeRangeFilter?.startTime,
timeRange.timeRangeFilter?.endTime,
]);
@@ -331,7 +348,7 @@ export async function executeGeminiSearch(
const start = Date.now();
const result = await runGeminiSearch({
query,
query: queryWithSoftFreshness(query, timeRange.freshness),
apiKey,
baseUrl,
model,

View File

@@ -40,7 +40,8 @@ const GEMINI_TOOL_PARAMETERS = {
language: { type: "string", description: "Not supported by Gemini." },
freshness: {
type: "string",
description: "Limit Google Search grounding to recent results: day, week, month, or year.",
description:
"Filter Gemini search freshness: week, month, and year use hard Google Search time ranges; day prioritizes the last 24 hours as a recency hint.",
},
date_after: {
type: "string",

View File

@@ -10,10 +10,9 @@ type TestModelProviderConfig = NonNullable<
function installGeminiFetch() {
const mockFetch = vi.fn((_input?: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
Promise.resolve(
new Response(
JSON.stringify({
candidates: [
{
content: { parts: [{ text: "Grounded answer" }] },
@@ -23,7 +22,8 @@ function installGeminiFetch() {
},
],
}),
} as Response),
),
),
);
vi.stubGlobal("fetch", withFetchPreconnect(mockFetch));
return mockFetch;
@@ -66,6 +66,7 @@ function getGeminiFetchUrl(mockFetch: ReturnType<typeof installGeminiFetch>): st
}
function parseGeminiFetchBody(mockFetch: ReturnType<typeof installGeminiFetch>): {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
} {
const [, init] = requireFirstGeminiFetchCall(mockFetch);
@@ -74,6 +75,7 @@ function parseGeminiFetchBody(mockFetch: ReturnType<typeof installGeminiFetch>):
throw new Error("Expected Gemini fetch body string");
}
return JSON.parse(body) as {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
};
}
@@ -477,10 +479,37 @@ describe("google web search provider", () => {
);
});
it("passes freshness to Gemini Google Search grounding as a time range", async () => {
it("uses a soft recency hint for Gemini day freshness shortcuts instead of a 24-hour range", async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
},
},
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "pd" });
const body = parseGeminiFetchBody(mockFetch);
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(body.contents?.[0]?.parts?.[0]?.text).toContain(
"Prioritize web sources published in the last 24 hours.",
);
});
it("preserves hard Gemini time ranges for wider freshness values", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
// Use a wall-clock-realistic moment with non-zero milliseconds; the helper
// must strip them to avoid Gemini's "Granularity of nano is not supported".
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
@@ -504,13 +533,68 @@ describe("google web search provider", () => {
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "week" });
const body = parseGeminiFetchBody(mockFetch);
expect(body.contents?.[0]?.parts?.[0]?.text).toBe("latest ai news timestamp precision");
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
startTime: "2026-04-08T12:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
});
it("strips sub-second precision from freshness timestamps so Gemini accepts them", async () => {
it("partitions Gemini cache entries for soft day freshness, hard week freshness, and no freshness", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
},
},
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "same query cache partition", freshness: "day" });
await tool?.execute({ query: "same query cache partition", freshness: "week" });
await tool?.execute({ query: "same query cache partition" });
const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string");
expect(postCalls).toHaveLength(3);
const parsePostedBody = (call: (typeof postCalls)[number] | undefined) => {
const body = call?.[1]?.body;
if (typeof body !== "string") {
throw new Error("Expected Gemini fetch body to be a string");
}
return JSON.parse(body) as {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
};
};
const firstBody = parsePostedBody(postCalls[0]);
const secondBody = parsePostedBody(postCalls[1]);
const thirdBody = parsePostedBody(postCalls[2]);
expect(firstBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(firstBody.contents?.[0]?.parts?.[0]?.text).toContain(
"Prioritize web sources published in the last 24 hours.",
);
expect(secondBody.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
startTime: "2026-04-08T12:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
expect(secondBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
expect(thirdBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(thirdBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
});
it("strips sub-second precision from date-range timestamps so Gemini accepts them", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
// "now" with non-zero milliseconds. Without stripping, toISOString() emits
// "2026-04-15T12:00:00.123Z", which Gemini's google_search.time_range_filter
@@ -535,7 +619,7 @@ describe("google web search provider", () => {
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "latest ai news", freshness: "week" });
await tool?.execute({ query: "latest ai news", date_after: "2026-04-01" });
const body = parseGeminiFetchBody(mockFetch);
const filter = body.tools?.[0]?.google_search?.timeRangeFilter as
@@ -544,7 +628,7 @@ describe("google web search provider", () => {
expect(filter?.startTime).not.toMatch(/\.\d+Z$/);
expect(filter?.endTime).not.toMatch(/\.\d+Z$/);
expect(filter).toEqual({
startTime: "2026-04-08T12:00:00Z",
startTime: "2026-04-01T00:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
});

View File

@@ -2,6 +2,7 @@
"id": "googlechat",
"name": "Google Chat",
"description": "OpenClaw Google Chat channel plugin for spaces and direct messages.",
"icon": "https://cdn.simpleicons.org/googlechat/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "huggingface",
"icon": "https://cdn.simpleicons.org/huggingface/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "imessage",
"icon": "https://cdn.simpleicons.org/imessage/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "line",
"name": "LINE",
"description": "OpenClaw LINE channel plugin for LINE Bot API chats.",
"icon": "https://cdn.simpleicons.org/line/111111",
"activation": {
"onStartup": false
},

View File

@@ -7,7 +7,7 @@
"": {
"name": "@openclaw/llama-cpp-provider",
"version": "2026.6.9",
"dependencies": {
"optionalDependencies": {
"node-llama-cpp": "3.18.1"
}
},
@@ -16,6 +16,7 @@
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -25,6 +26,7 @@
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^7.0.4"
},
@@ -37,6 +39,7 @@
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "^4.1.1"
}
@@ -45,7 +48,8 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/@node-llama-cpp/linux-arm64": {
"version": "3.18.1",
@@ -411,13 +415,15 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
"integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/@simple-git/argv-parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz",
"integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@simple-git/args-pathspec": "^1.0.3"
}
@@ -427,6 +433,7 @@
"resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz",
"integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.17.0"
},
@@ -440,6 +447,7 @@
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
"integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14.16"
},
@@ -452,6 +460,7 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -464,6 +473,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -476,6 +486,7 @@
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
"license": "MIT",
"optional": true,
"dependencies": {
"retry": "0.13.1"
}
@@ -485,6 +496,7 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.8"
}
@@ -494,6 +506,7 @@
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
@@ -505,13 +518,15 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chmodrp/-/chmodrp-1.0.2.tgz",
"integrity": "sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -527,6 +542,7 @@
}
],
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -536,6 +552,7 @@
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"restore-cursor": "^5.0.0"
},
@@ -551,6 +568,7 @@
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
},
@@ -563,6 +581,7 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
@@ -577,6 +596,7 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -586,6 +606,7 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -595,6 +616,7 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -609,6 +631,7 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -621,6 +644,7 @@
"resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz",
"integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "^4.4.3",
"fs-extra": "^11.3.3",
@@ -644,6 +668,7 @@
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -655,13 +680,15 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
@@ -671,6 +698,7 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"optional": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -684,13 +712,15 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
"license": "ISC",
"optional": true
},
"node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"optional": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -706,6 +736,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -723,6 +754,7 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
@@ -731,13 +763,15 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/env-var": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/env-var/-/env-var-7.5.0.tgz",
"integrity": "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
}
@@ -747,6 +781,7 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
@@ -755,13 +790,15 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/filename-reserved-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz",
"integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
@@ -774,6 +811,7 @@
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz",
"integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"filename-reserved-regex": "^3.0.0"
},
@@ -789,6 +827,7 @@
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"license": "MIT",
"optional": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -803,6 +842,7 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"optional": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@@ -812,6 +852,7 @@
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -823,13 +864,15 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
"license": "ISC",
"optional": true
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -838,13 +881,15 @@
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC",
"optional": true
},
"node_modules/ipull": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/ipull/-/ipull-3.9.5.tgz",
"integrity": "sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tinyhttp/content-disposition": "^2.2.0",
"async-retry": "^1.3.3",
@@ -884,13 +929,15 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-2.1.0.tgz",
"integrity": "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/ipull/node_modules/parse-ms": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz",
"integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -903,6 +950,7 @@
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz",
"integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"parse-ms": "^3.0.0"
},
@@ -918,6 +966,7 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
@@ -934,6 +983,7 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
@@ -949,6 +999,7 @@
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -961,6 +1012,7 @@
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -973,6 +1025,7 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=20"
}
@@ -982,6 +1035,7 @@
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -993,19 +1047,22 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-3.1.1.tgz",
"integrity": "sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-unicode-supported": "^2.0.0",
"yoctocolors": "^2.1.1"
@@ -1022,6 +1079,7 @@
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
"integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==",
"license": "MIT",
"optional": true,
"dependencies": {
"steno": "^4.0.2"
},
@@ -1037,6 +1095,7 @@
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1049,6 +1108,7 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -1058,6 +1118,7 @@
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -1067,6 +1128,7 @@
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^7.1.2"
},
@@ -1078,7 +1140,8 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "5.1.11",
@@ -1091,6 +1154,7 @@
}
],
"license": "MIT",
"optional": true,
"bin": {
"nanoid": "bin/nanoid.js"
},
@@ -1103,6 +1167,7 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^18 || ^20 || >= 21"
}
@@ -1111,7 +1176,8 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz",
"integrity": "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/node-llama-cpp": {
"version": "3.18.1",
@@ -1119,6 +1185,7 @@
"integrity": "sha512-w0zfuy/IKS2fhrbed5SylZDXJHTVz4HnkwZ4UrFPgSNwJab3QIPwIl4lyCKHHy9flLrtxsAuV5kXfH3HZ6bb8w==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@huggingface/jinja": "^0.5.6",
"async-retry": "^1.3.3",
@@ -1189,6 +1256,7 @@
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-function": "^5.0.0"
},
@@ -1204,6 +1272,7 @@
"resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz",
"integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chalk": "^5.6.2",
"cli-cursor": "^5.0.0",
@@ -1226,6 +1295,7 @@
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
"integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18.20"
},
@@ -1238,6 +1308,7 @@
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1250,6 +1321,7 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1259,6 +1331,7 @@
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
@@ -1271,6 +1344,7 @@
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
"integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"parse-ms": "^4.0.0"
},
@@ -1286,6 +1360,7 @@
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"optional": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
@@ -1297,6 +1372,7 @@
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -1306,6 +1382,7 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@ -1321,6 +1398,7 @@
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1330,6 +1408,7 @@
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"license": "MIT",
"optional": true,
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
@@ -1346,6 +1425,7 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=14"
},
@@ -1358,6 +1438,7 @@
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -1367,6 +1448,7 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -1379,6 +1461,7 @@
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"optional": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -1391,6 +1474,7 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1399,13 +1483,15 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
"license": "ISC",
"optional": true
},
"node_modules/simple-git": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
"integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -1422,13 +1508,15 @@
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz",
"integrity": "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
@@ -1445,6 +1533,7 @@
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1457,6 +1546,7 @@
"resolved": "https://registry.npmjs.org/stdout-update/-/stdout-update-4.0.1.tgz",
"integrity": "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-escapes": "^6.2.0",
"ansi-styles": "^6.2.1",
@@ -1471,13 +1561,15 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/stdout-update/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
@@ -1495,6 +1587,7 @@
"resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz",
"integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1507,6 +1600,7 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
@@ -1523,6 +1617,7 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^6.2.2"
},
@@ -1538,6 +1633,7 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1547,6 +1643,7 @@
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"license": "BlueOak-1.0.0",
"optional": true,
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@@ -1563,6 +1660,7 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -1571,13 +1669,15 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT"
"license": "MIT",
"optional": true
},
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
"integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==",
"license": "ISC",
"optional": true,
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
@@ -1587,6 +1687,7 @@
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
"license": "ISC",
"optional": true,
"dependencies": {
"isexe": "^4.0.0"
},
@@ -1602,6 +1703,7 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@@ -1619,6 +1721,7 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1628,6 +1731,7 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -1643,6 +1747,7 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1652,6 +1757,7 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -1666,6 +1772,7 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -1678,6 +1785,7 @@
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
@@ -1687,6 +1795,7 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -1696,6 +1805,7 @@
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
@@ -1714,6 +1824,7 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@@ -1723,6 +1834,7 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1732,6 +1844,7 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1741,6 +1854,7 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -1755,6 +1869,7 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -1767,6 +1882,7 @@
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},

View File

@@ -7,7 +7,7 @@
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"dependencies": {
"optionalDependencies": {
"node-llama-cpp": "3.18.1"
},
"devDependencies": {

View File

@@ -1,5 +1,6 @@
{
"id": "lmstudio",
"icon": "https://cdn.simpleicons.org/lmstudio/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "matrix",
"name": "Matrix",
"description": "OpenClaw Matrix channel plugin for rooms and direct messages.",
"icon": "https://cdn.simpleicons.org/matrix/111111",
"commandAliases": [{ "name": "matrix" }],
"activation": {
"onStartup": false,

View File

@@ -1,5 +1,6 @@
{
"id": "mattermost",
"icon": "https://cdn.simpleicons.org/mattermost/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "minimax",
"icon": "https://cdn.simpleicons.org/minimax/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "mistral",
"icon": "https://cdn.simpleicons.org/mistralai/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "nextcloud-talk",
"name": "Nextcloud Talk",
"description": "OpenClaw Nextcloud Talk channel plugin for conversations.",
"icon": "https://cdn.simpleicons.org/nextcloud/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "nvidia",
"icon": "https://cdn.simpleicons.org/nvidia/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "ollama",
"icon": "https://cdn.simpleicons.org/ollama/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "opencode-go",
"icon": "https://cdn.simpleicons.org/opencode/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "opencode",
"icon": "https://cdn.simpleicons.org/opencode/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "openrouter",
"icon": "https://cdn.simpleicons.org/openrouter/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "perplexity",
"icon": "https://cdn.simpleicons.org/perplexity/111111",
"activation": {
"onStartup": false
},

View File

@@ -11,6 +11,11 @@ import {
validateQaScenarioExecutionConfig,
} from "./scenario-catalog.js";
type CatalogScenario = ReturnType<typeof readQaScenarioPack>["scenarios"][number];
type FlowCatalogScenario = CatalogScenario & {
execution: Extract<CatalogScenario["execution"], { kind: "flow" }>;
};
function listScenarioMarkdownPaths(dir = "qa/scenarios"): string[] {
return fs
.readdirSync(dir, { withFileTypes: true })
@@ -24,6 +29,32 @@ function listScenarioMarkdownPaths(dir = "qa/scenarios"): string[] {
.toSorted();
}
function isFlowScenario(scenario: CatalogScenario): scenario is FlowCatalogScenario {
return scenario.execution.kind === "flow";
}
function requireFlowScenario(scenario: CatalogScenario): FlowCatalogScenario {
expect(scenario.execution.kind).toBe("flow");
if (!isFlowScenario(scenario)) {
throw new Error(`expected ${scenario.id} to be a flow scenario`);
}
return scenario;
}
function flowContainsCall(value: unknown, callName: string): boolean {
if (Array.isArray(value)) {
return value.some((entry) => flowContainsCall(entry, callName));
}
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
record.call === callName ||
Object.values(record).some((entry) => flowContainsCall(entry, callName))
);
}
describe("qa scenario catalog", () => {
const dottedCoverageIdPattern = /^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/;
@@ -144,6 +175,34 @@ describe("qa scenario catalog", () => {
expect(fanoutConfig?.expectedReplyGroups?.flat()).toContain("subagent-2: ok");
});
it("loads explicit suite isolation metadata from per-scenario YAML", () => {
const staleLinks = requireFlowScenario(readQaScenarioById("subagent-stale-child-links"));
const kitchenSink = requireFlowScenario(readQaScenarioById("kitchen-sink-live-openai"));
expect(staleLinks.execution.suiteIsolation).toBe("isolated");
expect(staleLinks.execution.isolationReason).toContain("gateway session");
expect(kitchenSink.execution.suiteIsolation).toBe("isolated");
expect(kitchenSink.execution.isolationReason).toContain("plugin/channel/tool config");
});
it("requires explicit suite isolation for gateway state restart scenarios", () => {
const scenarios = readQaScenarioPack()
.scenarios.filter(isFlowScenario)
.filter((scenario) =>
flowContainsCall(scenario.execution.flow, "env.gateway.restartAfterStateMutation"),
);
expect(scenarios.map((scenario) => scenario.id).toSorted()).toEqual([
"kitchen-sink-live-openai",
"subagent-stale-child-links",
]);
expect(
scenarios
.filter((scenario) => scenario.execution.suiteIsolation !== "isolated")
.map((scenario) => scenario.id),
).toEqual([]);
});
it("loads scenario-declared gateway runtime options from YAML", () => {
const scenario = readQaScenarioById("control-ui-qa-channel-image-roundtrip");
const otelStdout = readQaScenarioById("otel-stdout-log-smoke");
@@ -552,7 +611,7 @@ describe("qa scenario catalog", () => {
"this seeded scenario is mock-openai only",
);
expect(heartbeatFlow).toContain("sessionKey");
expect(heartbeatFlow).toContain("targetOutbound.length === 0");
expect(heartbeatFlow).toContain("commitmentOutbound.length === 0");
expect(heartbeatFlow).not.toContain("waitForNoOutbound");
});

View File

@@ -70,6 +70,8 @@ const qaFlowScenarioExecutionSchema = z.object({
kind: z.literal("flow").default("flow"),
summary: z.string().trim().min(1).optional(),
channel: qaScenarioChannelSchema.optional(),
suiteIsolation: z.literal("isolated").optional(),
isolationReason: z.string().trim().min(1).optional(),
config: qaScenarioConfigSchema.optional(),
});

View File

@@ -51,26 +51,27 @@ describe("qa suite runtime launcher", () => {
beforeEach(() => {
runQaFlowSuite.mockReset();
runQaTestFileScenarios.mockReset();
runQaFlowSuite.mockImplementation(async (params: { outputDir?: string } | undefined) => {
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
return {
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
report: "# QA Suite Report\n",
scenarios: [
{
name: "channel-chat-baseline",
runQaFlowSuite.mockImplementation(
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
report: "# QA Suite Report\n",
scenarios: scenarioIds.map((scenarioId) => ({
name: scenarioId,
status: "pass",
steps: [],
},
],
watchUrl: "http://127.0.0.1:43124",
};
});
})),
watchUrl: "http://127.0.0.1:43124",
};
},
);
runQaTestFileScenarios.mockImplementation(
async (params: {
outputDir: string;
@@ -98,6 +99,7 @@ describe("qa suite runtime launcher", () => {
});
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(
tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })),
);
@@ -171,6 +173,60 @@ describe("qa suite runtime launcher", () => {
).toEqual([{ id: "control-ui-chat-flow-playwright", kind: "playwright" }]);
});
it("serializes test-file runner partitions in one checkout", async () => {
const repoRoot = await makeTempRepo("qa-suite-test-file-serial-");
let releaseVitest!: () => void;
let markVitestStarted!: () => void;
const vitestStarted = new Promise<void>((resolve) => {
markVitestStarted = resolve;
});
const vitestBlocked = new Promise<void>((resolve) => {
releaseVitest = resolve;
});
runQaTestFileScenarios.mockImplementationOnce(
async (params: {
outputDir: string;
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
}) => {
markVitestStarted();
await vitestBlocked;
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const scenario = params.scenarios[0];
if (!scenario) {
throw new Error("expected scenario");
}
return {
outputDir: params.outputDir,
executionKind: scenario.execution.kind,
evidencePath,
results: params.scenarios.map((scenarioItem) => ({
durationMs: 1,
logPath: path.join(params.outputDir, `${scenarioItem.id}.log`),
scenario: scenarioItem,
status: "pass",
})),
};
},
);
const runPromise = runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/test-file-serial",
concurrency: 8,
scenarioIds: ["gateway-smoke", "control-ui-chat-flow-playwright"],
});
await vitestStarted;
await Promise.resolve();
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
releaseVitest();
await runPromise;
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(2);
});
it("runs mixed flow and Vitest/Playwright scenarios as one suite", async () => {
const repoRoot = await makeTempRepo("qa-suite-mixed-");
const result = await runQaSuite({
@@ -220,6 +276,426 @@ describe("qa suite runtime launcher", () => {
);
});
it("keeps channel-driver unified flow partitions serial by default", async () => {
const repoRoot = await makeTempRepo("qa-suite-crabline-serial-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/crabline-serial",
channelDriverSelection: {
capabilityMatrixPath: "crabline-fake-provider-capabilities.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-fake-provider-smoke.json",
},
scenarioIds: ["channel-chat-baseline", "dm-chat-baseline", "control-ui-chat-flow-playwright"],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "crabline-serial");
expect(runQaFlowSuite).toHaveBeenCalledTimes(1);
expect(runQaFlowSuite).toHaveBeenCalledWith(
expect.objectContaining({
outputDir: path.join(outputDir, "flow"),
concurrency: 1,
scenarioIds: ["channel-chat-baseline", "dm-chat-baseline"],
}),
);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("respects serial concurrency across unified suite partitions", async () => {
const repoRoot = await makeTempRepo("qa-suite-serial-");
let releaseFlow!: () => void;
let markFlowStarted!: () => void;
const flowStarted = new Promise<void>((resolve) => {
markFlowStarted = resolve;
});
const flowBlocked = new Promise<void>((resolve) => {
releaseFlow = resolve;
});
runQaFlowSuite.mockImplementationOnce(
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
markFlowStarted();
await flowBlocked;
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
report: "# QA Suite Report\n",
scenarios: scenarioIds.map((scenarioId) => ({
name: scenarioId,
status: "pass",
steps: [],
})),
watchUrl: "http://127.0.0.1:43124",
};
},
);
const runPromise = runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/serial",
concurrency: 1,
scenarioIds: [
"channel-chat-baseline",
"group-visible-reply-tool",
"control-ui-chat-flow-playwright",
],
});
await flowStarted;
await Promise.resolve();
expect(runQaFlowSuite).toHaveBeenCalledTimes(1);
expect(runQaTestFileScenarios).not.toHaveBeenCalled();
releaseFlow();
await runPromise;
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("keeps multiple isolated flow scenarios in separate serial partitions", async () => {
const repoRoot = await makeTempRepo("qa-suite-serial-isolated-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/serial-isolated",
concurrency: 1,
scenarioIds: [
"group-visible-reply-tool",
"runtime-tool-image-generate",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "serial-isolated");
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "isolated-1"),
concurrency: 1,
workerStartStaggerMs: 0,
scenarioIds: ["group-visible-reply-tool"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "isolated-2"),
concurrency: 1,
workerStartStaggerMs: 0,
scenarioIds: ["runtime-tool-image-generate"],
}),
);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("accounts for isolated flow worker weight in unified suite concurrency", async () => {
const repoRoot = await makeTempRepo("qa-suite-weighted-");
let releaseShared!: () => void;
let markSharedStarted!: () => void;
const sharedStarted = new Promise<void>((resolve) => {
markSharedStarted = resolve;
});
const sharedBlocked = new Promise<void>((resolve) => {
releaseShared = resolve;
});
runQaFlowSuite.mockImplementationOnce(
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
markSharedStarted();
await sharedBlocked;
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
report: "# QA Suite Report\n",
scenarios: scenarioIds.map((scenarioId) => ({
name: scenarioId,
status: "pass",
steps: [],
})),
watchUrl: "http://127.0.0.1:43124",
};
},
);
const runPromise = runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/weighted",
concurrency: 3,
scenarioIds: [
"channel-chat-baseline",
"group-visible-reply-tool",
"control-ui-chat-flow-playwright",
],
});
await sharedStarted;
await Promise.resolve();
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
await vi.waitFor(() => {
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
releaseShared();
await runPromise;
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("waits for already-started partitions before rejecting a unified suite", async () => {
const repoRoot = await makeTempRepo("qa-suite-reject-settle-");
let releaseTestFile!: () => void;
let markTestFileStarted!: () => void;
const testFileStarted = new Promise<void>((resolve) => {
markTestFileStarted = resolve;
});
const testFileBlocked = new Promise<void>((resolve) => {
releaseTestFile = resolve;
});
runQaFlowSuite.mockRejectedValueOnce(new Error("flow partition failed"));
runQaTestFileScenarios.mockImplementationOnce(
async (params: {
outputDir: string;
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
}) => {
markTestFileStarted();
await testFileBlocked;
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
return {
outputDir: params.outputDir,
executionKind: params.scenarios[0]?.execution.kind ?? "playwright",
evidencePath,
results: params.scenarios.map((scenarioItem) => ({
durationMs: 1,
logPath: path.join(params.outputDir, `${scenarioItem.id}.log`),
scenario: scenarioItem,
status: "pass",
})),
};
},
);
const runPromise = runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/reject-settle",
concurrency: 2,
scenarioIds: ["channel-chat-baseline", "control-ui-chat-flow-playwright"],
});
let rejected = false;
void runPromise.catch(() => {
rejected = true;
});
await testFileStarted;
await Promise.resolve();
expect(rejected).toBe(false);
releaseTestFile();
await expect(runPromise).rejects.toThrow("flow partition failed");
expect(rejected).toBe(true);
});
it("shares ordinary flow scenarios and isolates flow scenarios with config patches", async () => {
const repoRoot = await makeTempRepo("qa-suite-partition-");
const result = await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/smoke",
concurrency: 8,
scenarioIds: [
"channel-chat-baseline",
"group-visible-reply-tool",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "smoke");
expect(result.executionKind).toBe("suite");
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared"),
concurrency: 1,
scenarioIds: ["channel-chat-baseline"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "isolated"),
concurrency: 1,
workerStartStaggerMs: 0,
scenarioIds: ["group-visible-reply-tool"],
}),
);
const summary = JSON.parse(
await fs.readFile(path.join(outputDir, "qa-suite-summary.json"), "utf8"),
) as {
scenarios?: Array<{ name?: unknown; status?: unknown }>;
};
expect(summary.scenarios).toMatchObject([
{ name: "channel-chat-baseline", status: "pass" },
{ name: "group-visible-reply-tool", status: "pass" },
{ name: "Control UI chat flow Playwright coverage", status: "pass" },
]);
});
it("spreads ordinary flow scenarios across bounded shared batches", async () => {
const repoRoot = await makeTempRepo("qa-suite-shared-batches-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/smoke",
concurrency: 8,
scenarioIds: [
"channel-chat-baseline",
"dm-chat-baseline",
"thread-follow-up",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "smoke");
expect(runQaFlowSuite).toHaveBeenCalledTimes(3);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared-1"),
concurrency: 1,
scenarioIds: ["channel-chat-baseline"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared-2"),
concurrency: 1,
scenarioIds: ["dm-chat-baseline"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared-3"),
concurrency: 1,
scenarioIds: ["thread-follow-up"],
}),
);
});
it("isolates flow scenarios that mutate shared runtime state", async () => {
const repoRoot = await makeTempRepo("qa-suite-shared-state-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/smoke",
concurrency: 8,
scenarioIds: [
"channel-chat-baseline",
"runtime-tool-image-generate",
"runtime-inventory-drift-check",
"session-memory-ranking",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "smoke");
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared"),
concurrency: 1,
scenarioIds: ["channel-chat-baseline"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "isolated"),
concurrency: 3,
workerStartStaggerMs: 500,
scenarioIds: [
"runtime-tool-image-generate",
"runtime-inventory-drift-check",
"session-memory-ranking",
],
}),
);
});
it("isolates flow scenarios that restart after state mutations", async () => {
const repoRoot = await makeTempRepo("qa-suite-gateway-state-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/gateway-state",
concurrency: 8,
scenarioIds: [
"channel-chat-baseline",
"subagent-stale-child-links",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "gateway-state");
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "shared"),
scenarioIds: ["channel-chat-baseline"],
}),
);
expect(runQaFlowSuite).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
outputDir: path.join(outputDir, "flow", "isolated"),
scenarioIds: ["subagent-stale-child-links"],
}),
);
});
it("preserves configured isolated worker start stagger overrides", async () => {
vi.stubEnv("OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS", "2500");
const repoRoot = await makeTempRepo("qa-suite-stagger-env-");
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/stagger-env",
concurrency: 8,
scenarioIds: [
"runtime-tool-image-generate",
"runtime-inventory-drift-check",
"session-memory-ranking",
"control-ui-chat-flow-playwright",
],
});
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "stagger-env");
expect(runQaFlowSuite).toHaveBeenCalledWith(
expect.objectContaining({
outputDir: path.join(outputDir, "flow"),
concurrency: 3,
workerStartStaggerMs: 2500,
scenarioIds: [
"runtime-tool-image-generate",
"runtime-inventory-drift-check",
"session-memory-ranking",
],
}),
);
});
it("rejects runtime-pair requests for Vitest/Playwright scenarios", async () => {
await expect(
runQaSuite({

View File

@@ -12,12 +12,21 @@ import {
} from "./evidence-summary.js";
import { isQaFastModeEnabled } from "./model-selection.js";
import { DEFAULT_QA_PROVIDER_MODE } from "./providers/index.js";
import {
defaultQaSuiteConcurrencyForTransport,
normalizeQaTransportId,
} from "./qa-transport-registry.js";
import { defaultQaModelForMode, normalizeQaProviderMode } from "./run-config.js";
import {
readQaBootstrapScenarioCatalog,
type QaSeedScenarioWithSource,
} from "./scenario-catalog.js";
import { normalizeQaSuiteConcurrency, resolveQaSuiteOutputDir } from "./suite-planning.js";
import {
normalizeQaSuiteConcurrency,
resolveQaSuiteOutputDir,
resolveQaSuiteWorkerStartStaggerMs,
scenarioRequiresIsolatedQaSuiteWorker,
} from "./suite-planning.js";
import {
buildQaSuiteSummaryJson,
type QaSuiteResult,
@@ -63,11 +72,40 @@ type QaSuiteExecutionPlan =
testFileScenariosByKind: Map<QaTestFileExecutionKind, QaTestFileScenario[]>;
};
const MAX_SHARED_FLOW_PARTITIONS = 4;
const MAX_ISOLATED_FLOW_CONCURRENCY = 8;
const ISOLATED_FLOW_WORKER_START_STAGGER_MS = 500;
type QaUnifiedPartitionResult = {
evidenceSummaries: QaEvidenceSummaryJson[];
scenarioResults: Array<{
result: QaSuiteScenarioResult;
scenarioId: string;
}>;
};
type QaUnifiedPartitionTask = {
run: () => Promise<QaUnifiedPartitionResult>;
weight: number;
};
async function loadQaLabServerRuntime() {
const { startQaLabServer } = await import("./lab-server.js");
return startQaLabServer;
}
async function loadQaFlowSuiteRuntime() {
const [{ runQaFlowSuite }, startLab] = await Promise.all([
import("./suite.js"),
loadQaLabServerRuntime(),
]);
return async (params: QaSuiteRunParams | undefined) =>
await runQaFlowSuite({
...params,
startLab: params?.startLab ?? startLab,
});
}
function resolveRequestedScenarios(params: {
scenarioIds: readonly string[];
scenarios: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"];
@@ -156,6 +194,100 @@ function suitePartitionOutputDir(outputDir: string, kind: "flow" | QaTestFileExe
return path.join(outputDir, kind);
}
function flowSuitePartitionOutputDir(outputDir: string, partition: string) {
return path.join(suitePartitionOutputDir(outputDir, "flow"), partition);
}
function partitionSharedFlowScenarios(
scenarios: readonly QaSeedScenarioWithSource[],
concurrency: number,
) {
const partitionCount = Math.min(
Math.max(1, Math.floor(concurrency)),
MAX_SHARED_FLOW_PARTITIONS,
scenarios.length,
);
const partitions = Array.from({ length: partitionCount }, (): QaSeedScenarioWithSource[] => []);
for (const [index, scenario] of scenarios.entries()) {
const partition = partitions[index % partitionCount];
if (!partition) {
throw new Error("failed to partition shared QA flow scenarios");
}
partition.push(scenario);
}
return partitions.filter((partition) => partition.length > 0);
}
async function runWeightedUnifiedPartitionTasks(
tasks: readonly QaUnifiedPartitionTask[],
maxWeight: number,
) {
if (tasks.length === 0) {
return [];
}
const limit = Math.max(1, Math.floor(maxWeight));
const results: QaUnifiedPartitionResult[] = [];
let activeWeight = 0;
let settled = 0;
let nextIndex = 0;
return await new Promise<QaUnifiedPartitionResult[]>((resolve, reject) => {
let firstError: Error | undefined;
let finished = false;
const finishIfSettled = () => {
if (finished || activeWeight > 0) {
return;
}
finished = true;
if (firstError) {
reject(firstError);
return;
}
resolve(results);
};
const launch = () => {
if (firstError) {
finishIfSettled();
return;
}
while (nextIndex < tasks.length) {
const task = tasks[nextIndex];
if (!task) {
return;
}
const taskWeight = Math.max(1, Math.min(limit, Math.floor(task.weight)));
if (activeWeight > 0 && activeWeight + taskWeight > limit) {
return;
}
const index = nextIndex;
nextIndex += 1;
activeWeight += taskWeight;
task.run().then(
(result) => {
results[index] = result;
activeWeight -= taskWeight;
settled += 1;
if (settled === tasks.length) {
finishIfSettled();
return;
}
launch();
},
(error: unknown) => {
firstError = error instanceof Error ? error : new Error(String(error));
activeWeight -= taskWeight;
settled += 1;
finishIfSettled();
},
);
}
if (settled === tasks.length) {
finishIfSettled();
}
};
launch();
});
}
async function readQaSuiteEvidenceSummary(evidencePath: string) {
return validateQaEvidenceSummaryJson(JSON.parse(await fs.readFile(evidencePath, "utf8")));
}
@@ -297,48 +429,134 @@ async function runUnifiedQaSuite(params: {
typeof params.runParams?.fastMode === "boolean"
? params.runParams.fastMode
: isQaFastModeEnabled({ primaryModel, alternateModel });
const transportId = normalizeQaTransportId(params.runParams?.transportId);
const defaultConcurrency = params.runParams?.channelDriverSelection
? 1
: defaultQaSuiteConcurrencyForTransport(transportId);
const concurrency = normalizeQaSuiteConcurrency(
params.runParams?.concurrency,
params.plan.scenarios.length,
defaultConcurrency,
);
const evidenceSummaries: QaEvidenceSummaryJson[] = [];
const scenarioResultsById = new Map<string, QaSuiteScenarioResult>();
const partitionTasks: QaUnifiedPartitionTask[] = [];
if (params.plan.flowScenarios.length > 0) {
const flowResult = await runQaFlowSuiteFromRuntime({
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, "flow"),
providerMode,
primaryModel,
alternateModel,
fastMode,
scenarioIds: params.plan.flowScenarios.map((scenario) => scenario.id),
});
for (const [index, scenario] of params.plan.flowScenarios.entries()) {
const result = flowResult.scenarios[index];
if (result) {
scenarioResultsById.set(scenario.id, result);
}
const sharedFlowScenarios = params.plan.flowScenarios.filter(
(scenario) => !scenarioRequiresIsolatedQaSuiteWorker(scenario),
);
const isolatedFlowScenarios = params.plan.flowScenarios.filter(
scenarioRequiresIsolatedQaSuiteWorker,
);
const sharedFlowPartitions = partitionSharedFlowScenarios(sharedFlowScenarios, concurrency);
const isolatedFlowConcurrency = Math.min(
concurrency,
MAX_ISOLATED_FLOW_CONCURRENCY,
isolatedFlowScenarios.length,
);
const isolatedFlowPartitions =
isolatedFlowConcurrency === 1 && isolatedFlowScenarios.length > 1
? isolatedFlowScenarios.map((scenario, index) => ({
kind: `isolated-${index + 1}`,
scenarios: [scenario],
concurrency: 1,
}))
: [
{
kind: "isolated",
scenarios: isolatedFlowScenarios,
concurrency: isolatedFlowConcurrency,
},
];
const flowPartitions = [
...sharedFlowPartitions.map((scenarios, index) => ({
kind: sharedFlowPartitions.length === 1 ? "shared" : `shared-${index + 1}`,
scenarios,
concurrency: 1,
})),
...isolatedFlowPartitions,
].filter((partition) => partition.scenarios.length > 0);
const runFlowSuite = await loadQaFlowSuiteRuntime();
for (const partition of flowPartitions) {
const isolatedPartition =
partition.kind === "isolated" || partition.kind.startsWith("isolated-");
partitionTasks.push({
weight: partition.concurrency,
run: async () => {
const result = await runFlowSuite({
...params.runParams,
outputDir:
flowPartitions.length === 1
? suitePartitionOutputDir(outputDir, "flow")
: flowSuitePartitionOutputDir(outputDir, partition.kind),
providerMode,
primaryModel,
alternateModel,
fastMode,
concurrency: partition.concurrency,
workerStartStaggerMs: isolatedPartition
? (params.runParams?.workerStartStaggerMs ??
resolveQaSuiteWorkerStartStaggerMs(
partition.concurrency,
process.env,
ISOLATED_FLOW_WORKER_START_STAGGER_MS,
))
: params.runParams?.workerStartStaggerMs,
scenarioIds: partition.scenarios.map((scenario) => scenario.id),
});
const scenarioResults: QaUnifiedPartitionResult["scenarioResults"] = [];
for (const [index, scenario] of partition.scenarios.entries()) {
const scenarioResult = result.scenarios[index];
if (scenarioResult) {
scenarioResults.push({ scenarioId: scenario.id, result: scenarioResult });
}
}
return {
evidenceSummaries: [await readQaSuiteEvidenceSummary(result.evidencePath)],
scenarioResults,
};
},
});
}
evidenceSummaries.push(await readQaSuiteEvidenceSummary(flowResult.evidencePath));
}
for (const [kind, testFileScenarios] of params.plan.testFileScenariosByKind) {
const result = await runQaTestFileSuiteFromRuntime({
runParams: {
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, kind),
providerMode,
primaryModel,
scenarioIds: testFileScenarios.map((scenario) => scenario.id),
if (params.plan.testFileScenariosByKind.size > 0) {
partitionTasks.push({
weight: 1,
run: async () => {
const testFileEvidenceSummaries: QaEvidenceSummaryJson[] = [];
const testFileScenarioResults: QaUnifiedPartitionResult["scenarioResults"] = [];
for (const [kind, testFileScenarios] of params.plan.testFileScenariosByKind) {
const result = await runQaTestFileSuiteFromRuntime({
runParams: {
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, kind),
providerMode,
primaryModel,
scenarioIds: testFileScenarios.map((scenario) => scenario.id),
},
scenarios: testFileScenarios,
});
testFileEvidenceSummaries.push(await readQaSuiteEvidenceSummary(result.evidencePath));
testFileScenarioResults.push(
...result.results.map((scenarioResult) => ({
scenarioId: scenarioResult.scenario.id,
result: testFileScenarioResultToSuiteScenario(scenarioResult, repoRoot),
})),
);
}
return {
evidenceSummaries: testFileEvidenceSummaries,
scenarioResults: testFileScenarioResults,
};
},
scenarios: testFileScenarios,
});
for (const scenarioResult of result.results) {
scenarioResultsById.set(
scenarioResult.scenario.id,
testFileScenarioResultToSuiteScenario(scenarioResult, repoRoot),
);
}
const partitionResults = await runWeightedUnifiedPartitionTasks(partitionTasks, concurrency);
for (const partitionResult of partitionResults) {
for (const scenarioResult of partitionResult.scenarioResults) {
scenarioResultsById.set(scenarioResult.scenarioId, scenarioResult.result);
}
evidenceSummaries.push(await readQaSuiteEvidenceSummary(result.evidencePath));
evidenceSummaries.push(...partitionResult.evidenceSummaries);
}
const finishedAt = new Date();
const evidence = mergeQaEvidenceSummaries({
@@ -400,10 +618,7 @@ export async function runQaSuite(...args: [QaSuiteRunParams?]): Promise<QaSuiteR
export async function runQaFlowSuiteFromRuntime(
...args: [QaSuiteRunParams?]
): Promise<QaSuiteResult> {
const { runQaFlowSuite } = await import("./suite.js");
const params = args[0];
return await runQaFlowSuite({
...params,
startLab: params?.startLab ?? (await loadQaLabServerRuntime()),
});
return await (
await loadQaFlowSuiteRuntime()
)(args[0]);
}

View File

@@ -14,6 +14,7 @@ import {
resolveQaSuiteWorkerStartStaggerMs,
resolveQaSuiteOutputDir,
scenarioRequiresControlUi,
scenarioRequiresIsolatedQaSuiteWorker,
selectQaFlowSuiteScenarios,
shouldUseIsolatedQaSuiteScenarioWorkers,
} from "./suite-planning.js";
@@ -202,6 +203,16 @@ describe("qa suite planning helpers", () => {
}),
).toBe(1500);
}
expect(resolveQaSuiteWorkerStartStaggerMs(4, {}, 500)).toBe(500);
expect(
resolveQaSuiteWorkerStartStaggerMs(
4,
{
OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS: "25",
},
500,
),
).toBe(25);
});
it("keeps explicitly requested provider-specific scenarios", () => {
@@ -283,6 +294,15 @@ describe("qa suite planning helpers", () => {
).toThrow("Selected QA scenarios require multiple channels");
});
it("isolates flow scenarios with explicit suite isolation metadata", () => {
expect(
scenarioRequiresIsolatedQaSuiteWorker(
makeQaSuiteTestScenario("explicit-isolated", { suiteIsolation: "isolated" }),
),
).toBe(true);
expect(scenarioRequiresIsolatedQaSuiteWorker(makeQaSuiteTestScenario("plain"))).toBe(false);
});
it("collects unique scenario-declared bundled plugins in encounter order", () => {
const scenarios = [
makeQaSuiteTestScenario("generic", { plugins: ["active-memory", "memory-wiki"] }),

View File

@@ -12,6 +12,12 @@ import { applyQaMergePatch, isQaMergePatchObject } from "./suite-merge-patch.js"
const DEFAULT_QA_SUITE_CONCURRENCY = 64;
const DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS = 1_500;
const QA_IMPLICIT_ISOLATION_FLOW_CALLS = new Set([
"ensureImageGenerationConfigured",
"forceMemoryIndex",
"patchConfig",
"writeWorkspaceSkill",
]);
type QaSeedScenario = ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number];
@@ -209,6 +215,35 @@ function shouldUseIsolatedQaSuiteScenarioWorkers(params: {
);
}
function scenarioRequiresIsolatedQaSuiteWorker(scenario: QaSeedScenario) {
if (scenario.execution.kind !== "flow") {
return false;
}
return (
scenario.execution.suiteIsolation === "isolated" ||
isQaMergePatchObject(scenario.gatewayConfigPatch) ||
scenario.gatewayRuntime !== undefined ||
(Array.isArray(scenario.plugins) && scenario.plugins.length > 0) ||
normalizeLowercaseStringOrEmpty(scenario.surface) === "memory" ||
scenario.execution.config?.ensureImageGeneration === true ||
flowContainsImplicitIsolationCall(scenario.execution.flow)
);
}
function flowContainsImplicitIsolationCall(value: unknown): boolean {
if (Array.isArray(value)) {
return value.some(flowContainsImplicitIsolationCall);
}
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
if (typeof record.call === "string" && QA_IMPLICIT_ISOLATION_FLOW_CALLS.has(record.call)) {
return true;
}
return Object.values(record).some(flowContainsImplicitIsolationCall);
}
function scenarioRequiresControlUi(scenario: QaSeedScenario) {
return normalizeLowercaseStringOrEmpty(scenario.surface) === "control-ui";
}
@@ -231,17 +266,18 @@ function normalizeQaSuiteConcurrency(
function resolveQaSuiteWorkerStartStaggerMs(
concurrency: number,
env: NodeJS.ProcessEnv = process.env,
defaultStaggerMs = DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS,
) {
if (concurrency <= 1) {
return 0;
}
const raw = env.OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS;
if (raw === undefined) {
return DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS;
return defaultStaggerMs;
}
const parsed = parseStrictNonNegativeInteger(raw);
if (parsed === undefined) {
return DEFAULT_QA_SUITE_WORKER_START_STAGGER_MS;
return defaultStaggerMs;
}
return parsed;
}
@@ -329,6 +365,7 @@ export {
resolveQaSuiteWorkerStartStaggerMs,
resolveQaSuiteOutputDir,
scenarioRequiresControlUi,
scenarioRequiresIsolatedQaSuiteWorker,
scenarioMatchesQaProviderLane,
selectQaFlowSuiteScenarios,
shouldUseIsolatedQaSuiteScenarioWorkers,

View File

@@ -12,6 +12,7 @@ export function makeQaSuiteTestScenario(
gatewayConfigPatch?: Record<string, unknown>;
gatewayRuntime?: { forwardHostHome?: boolean; preserveDebugArtifacts?: boolean };
runtimeParityTier?: QaSuiteTestScenario["runtimeParityTier"];
suiteIsolation?: "isolated";
surface?: string;
} = {},
): QaSuiteTestScenario {
@@ -29,6 +30,7 @@ export function makeQaSuiteTestScenario(
execution: {
kind: "flow",
...(params.channel ? { channel: params.channel } : {}),
...(params.suiteIsolation ? { suiteIsolation: params.suiteIsolation } : {}),
...(params.config ? { config: params.config } : {}),
flow: { steps: [{ name: "noop", actions: [{ assert: "true" }] }] },
},

View File

@@ -150,6 +150,7 @@ export type QaSuiteRunParams = {
enabledPluginIds?: string[];
controlUiEnabled?: boolean;
transportReadyTimeoutMs?: number;
workerStartStaggerMs?: number;
forcedRuntime?: RuntimeId;
runtimePair?: [RuntimeId, RuntimeId];
captureRuntimeParityCell?: boolean;
@@ -467,6 +468,7 @@ function buildQaIsolatedScenarioWorkerParams(params: {
startLab: params.startLab,
controlUiEnabled: scenarioRequiresControlUi(params.scenario),
transportReadyTimeoutMs: params.input?.transportReadyTimeoutMs,
workerStartStaggerMs: params.input?.workerStartStaggerMs,
forcedRuntime: params.input?.forcedRuntime,
};
}
@@ -1287,7 +1289,8 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
try {
updateScenarioRun();
const workerStartStaggerMs = resolveQaSuiteWorkerStartStaggerMs(concurrency);
const workerStartStaggerMs =
params?.workerStartStaggerMs ?? resolveQaSuiteWorkerStartStaggerMs(concurrency);
writeQaSuiteProgress(progressEnabled, `scenario start stagger=${workerStartStaggerMs}ms`);
const scenarios: QaSuiteScenarioResult[] = await mapQaSuiteWithConcurrency(
selectedScenarios,

View File

@@ -1,5 +1,6 @@
{
"id": "qianfan",
"icon": "https://cdn.simpleicons.org/baidu/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "qqbot",
"name": "QQ Bot",
"description": "OpenClaw QQ Bot channel plugin for group and direct-message workflows.",
"icon": "https://cdn.simpleicons.org/qq/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "qwen",
"icon": "https://cdn.simpleicons.org/qwen/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "searxng",
"icon": "https://cdn.simpleicons.org/searxng/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "signal",
"icon": "https://cdn.simpleicons.org/signal/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "synology-chat",
"name": "Synology Chat",
"description": "Synology Chat channel plugin for OpenClaw channels and direct messages.",
"icon": "https://cdn.simpleicons.org/synology/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "telegram",
"icon": "https://cdn.simpleicons.org/telegram/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "twitch",
"name": "Twitch",
"description": "OpenClaw Twitch channel plugin for chat and moderation workflows.",
"icon": "https://cdn.simpleicons.org/twitch/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,5 +1,6 @@
{
"id": "vercel-ai-gateway",
"icon": "https://cdn.simpleicons.org/vercel/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "whatsapp",
"name": "WhatsApp",
"description": "OpenClaw WhatsApp channel plugin for WhatsApp Web chats.",
"icon": "https://cdn.simpleicons.org/whatsapp/111111",
"skills": ["./skills"],
"activation": {
"onStartup": false

View File

@@ -1,5 +1,6 @@
{
"id": "xiaomi",
"icon": "https://cdn.simpleicons.org/xiaomi/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "zalo",
"name": "Zalo",
"description": "OpenClaw Zalo channel plugin for bot and webhook chats.",
"icon": "https://cdn.simpleicons.org/zalo/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,6 +2,7 @@
"id": "zalouser",
"name": "Zalo Personal",
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration.",
"icon": "https://cdn.simpleicons.org/zalo/111111",
"activation": {
"onStartup": false
},

View File

@@ -1509,6 +1509,8 @@
"build:plugin-sdk:strict-smoke": "pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm build:plugin-sdk:dts && node --experimental-strip-types scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"canvas:a2ui:bundle": "node scripts/bundle-a2ui.mjs",
"canvas:a2ui:native:check": "node scripts/sync-native-a2ui.mjs --check",
"canvas:a2ui:native:sync": "node scripts/sync-native-a2ui.mjs --write",
"changed:lanes": "node scripts/changed-lanes.mjs",
"check": "node scripts/check.mjs",
"check:architecture": "pnpm check:import-cycles && pnpm check:madge-import-cycles && pnpm check:deprecated-api-usage && pnpm check:deprecated-jsdoc && pnpm db:kysely:check && pnpm lint:kysely && pnpm check:database-first-legacy-stores",
@@ -1613,7 +1615,6 @@
"ios:build": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && xcodebuild -project OpenClaw.xcodeproj -scheme OpenClaw -destination \"${IOS_DEST:-platform=iOS Simulator,name=iPhone 17}\" -configuration Debug build'",
"ios:gen": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate'",
"ios:open": "bash -lc './scripts/ios-configure-signing.sh && ./scripts/ios-write-version-xcconfig.sh && cd apps/ios && xcodegen generate && open OpenClaw.xcodeproj'",
"ios:release": "bash scripts/ios-release.sh",
"ios:release:archive": "bash scripts/ios-release-archive.sh",
"ios:release:prepare": "bash scripts/ios-release-prepare.sh",
"ios:release:signing:check": "bash -lc 'source ./scripts/lib/ios-fastlane.sh && cd apps/ios && run_ios_fastlane ios signing_check'",

2
pnpm-lock.yaml generated
View File

@@ -929,7 +929,7 @@ importers:
version: link:../../packages/plugin-sdk
extensions/llama-cpp:
dependencies:
optionalDependencies:
node-llama-cpp:
specifier: 3.18.1
version: 3.18.1(typescript@6.0.3)

View File

@@ -134,7 +134,7 @@ allowBuilds:
"@discordjs/opus": false
esbuild: true
koffi: false
node-llama-cpp: true
node-llama-cpp: false
protobufjs: true
tree-sitter-bash: false
openclaw: true

View File

@@ -24,6 +24,8 @@ scenario:
- extensions/qa-lab/src/gateway-child.ts
execution:
kind: flow
suiteIsolation: isolated
isolationReason: Seeds persisted gateway session/subagent state and restarts the gateway.
summary: Seed stale subagent session state on disk, restart the real gateway, then assert sessions.list filters only the stale child links.
flow:

View File

@@ -29,6 +29,9 @@ title: OpenClaw QA Scenario Pack
# - use `scenario.execution.kind: vitest`, `playwright`, or `script`
# plus `scenario.execution.path` for native tests or evidence producers that
# provide evidence without a top-level `flow`
# - use `scenario.execution.suiteIsolation: isolated` for flow scenarios that
# mutate gateway/runtime state in non-obvious ways; add `isolationReason`
# so reviewers know why the suite scheduler must not share the worker
# - use `runtimeParityTier` for runtime-pair gate membership: `standard`,
# `optional`, `live-only`, or `soak`
# - treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid

View File

@@ -14,7 +14,7 @@ scenario:
- Scenario runs through qa-channel and a real gateway child.
- A due commitment exists for the qa agent and qa-channel conversation.
- A heartbeat wake runs after the commitment is due.
- No qa-channel outbound message is sent while heartbeat target is none.
- No commitment/check-in qa-channel outbound message is sent while heartbeat target is none.
- The commitment remains pending and unattempted after the heartbeat.
docsRefs:
- docs/concepts/commitments.md
@@ -35,7 +35,7 @@ scenario:
target: none
execution:
kind: flow
summary: Seed a due commitment, wake heartbeat, and assert target none sends no qa-channel message.
summary: Seed a due commitment, wake heartbeat, and assert target none sends no commitment message.
config:
conversationId: commitments-target-none-room
commitmentId: cm_qa_target_none
@@ -55,7 +55,7 @@ flow:
- call: reset
- set: beforeHeartbeatTs
value:
expr: "((await env.gateway.call('last-heartbeat', {}, { timeoutMs: 5000 }))?.ts ?? 0)"
expr: "((await env.gateway.call('last-heartbeat', {}, { timeoutMs: liveTurnTimeoutMs(env, 30000) }))?.ts ?? 0)"
- set: sessionKey
value:
expr: "`agent:qa:qa-channel:${config.conversationId}`"
@@ -106,7 +106,7 @@ flow:
args:
- lambda:
async: true
expr: "(async () => { const last = await env.gateway.call('last-heartbeat', {}, { timeoutMs: 5000 }); return last && last.ts > beforeHeartbeatTs ? last : undefined; })()"
expr: "(async () => { const last = await env.gateway.call('last-heartbeat', {}, { timeoutMs: liveTurnTimeoutMs(env, 30000) }); return last && last.ts > beforeHeartbeatTs ? last : undefined; })()"
- expr: liveTurnTimeoutMs(env, 45000)
- 250
- call: sleep
@@ -115,10 +115,13 @@ flow:
- set: targetOutbound
value:
expr: "state.getSnapshot().messages.slice(messageCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === config.conversationId)"
- set: commitmentOutbound
value:
expr: "targetOutbound.filter((message) => normalizeLowercaseStringOrEmpty(message.text) !== 'heartbeat_ok')"
- assert:
expr: "targetOutbound.length === 0"
expr: "commitmentOutbound.length === 0"
message:
expr: "`expected no qa-channel outbound messages for target none, saw ${JSON.stringify(targetOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; recent=${recentOutboundSummary(state)}`"
expr: "`expected no qa-channel commitment messages for target none, saw ${JSON.stringify(commitmentOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; allTargetOutbound=${JSON.stringify(targetOutbound.map((message) => ({ conversationId: message.conversation.id, text: message.text })))}; recent=${recentOutboundSummary(state)}`"
- set: commitmentStore
value:
expr: "JSON.parse(await fs.readFile(commitmentStorePath, 'utf8'))"

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