Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
c8fad07635 build(deps): bump the actions group across 1 directory with 2 updates
Bumps the actions group with 2 updates in the / directory: [actions/create-github-app-token](https://github.com/actions/create-github-app-token) and [actions/setup-java](https://github.com/actions/setup-java).


Updates `actions/create-github-app-token` from 3.1.1 to 3.2.0
- [Release notes](https://github.com/actions/create-github-app-token/releases)
- [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/create-github-app-token/compare/v3.1.1...bcd2ba49218906704ab6c1aa796996da409d3eb1)

Updates `actions/setup-java` from 5.2.0 to 5.3.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5.2.0...ad2b38190b15e4d6bdf0c97fb4fca8412226d287)

---
updated-dependencies:
- dependency-name: actions/create-github-app-token
  dependency-version: 3.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/setup-java
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 08:42:46 +00:00
713 changed files with 8903 additions and 21512 deletions

View File

@@ -107,9 +107,16 @@ Reject:
## PR Body Proof
Use the repo PR template. Include authored `## What Problem This Solves` and
`## Evidence` sections. Keep the body focused on intent and the most useful
validation evidence; inspect the code, tests, and CI before judging correctness.
Use the repo PR template. Include these exact labels:
```text
Behavior addressed:
Real environment tested:
Exact steps or command run after this patch:
Evidence after fix:
Observed result after fix:
What was not tested:
```
## Existing PR Rules

4
.github/labeler.yml vendored
View File

@@ -171,10 +171,6 @@
- any-glob-to-any-file:
- "extensions/zalo/**"
- "docs/channels/zalo.md"
"channel: zaloclawbot":
- changed-files:
- any-glob-to-any-file:
- "docs/channels/zaloclawbot.md"
"channel: zalouser":
- changed-files:
- any-glob-to-any-file:

View File

@@ -1,57 +1,118 @@
<!--
Optional linked context:
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
below this comment.
## Summary
Required PR title:
type: user-facing description
Use a parenthesized scope only when it adds clarity:
fix(auth): login redirect loops when session cookie is expired
What problem does this PR solve?
Types: feat, fix, improve, refactor, docs, chore.
For fixes, describe the user-visible symptom and trigger:
fix: task list fails to load when user has no environments
Avoid implementation details such as:
fix: add null check to task query
-->
Why does this matter now?
## What Problem This Solves
What is the intended outcome?
<!--
Describe the concrete user, product, or operational problem.
For fixes, begin with:
"Fixes an issue where users <do X> would <experience Y> when <condition>."
or:
"Resolves a problem where..."
What is intentionally out of scope?
Name the affected UI surface or workflow. Do not describe the code-level cause here.
-->
What does success look like?
## Why This Change Was Made
What should reviewers focus on?
<!--
In one or two sentences, explain the complete shipped solution, key design
decisions, and relevant boundaries or non-goals. Include implementation detail
only when it helps reviewers understand user-visible behavior or risk.
Avoid file-by-file narration.
-->
<details>
<summary>Summary guidance</summary>
## User Impact
This PR description is the contributor's durable explanation of the change. Write it for human maintainers first; ClawSweeper and Barnacle use the same text to understand intent, proof, risk, and current review state.
<!--
State what users, operators, or developers can now do or expect. Lead with the
concrete benefit and use user-facing language. If there is no user-visible
impact, say so plainly.
-->
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
## Evidence
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
<!--
Show the most useful proof that this change works. Screenshots, screencasts,
terminal output, focused tests, CI results, live observations, redacted logs,
and artifact links are all useful. Include before/after evidence for visual
changes when it clarifies the result.
</details>
Reviewers will inspect the code, tests, and CI. Use this section to make the
validation easy to understand, not to restate the diff.
-->
## Linked context
Which issue does this close?
Closes #
Which issues, PRs, or discussions are related?
Related #
Was this requested by a maintainer or owner?
<details>
<summary>Linked context guidance</summary>
Link the issue, PR, discussion, maintainer request, or owner request that explains why this PR should exist. Maintainer context helps reviewers and automation distinguish intended work from drive-by churn.
</details>
## Real behavior proof (required for external PRs)
- Behavior or issue addressed:
- Real environment tested:
- Exact steps or command run after this patch:
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output):
- Observed result after fix:
- What was not tested:
- Proof limitations or environment constraints:
- Before evidence (optional but encouraged):
<details>
<summary>Real behavior proof guidance</summary>
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only.
Screenshots are encouraged even for CLI, console, text, or log changes. Terminal screenshots, copied live output, redacted runtime logs, recordings, and linked artifacts count.
If your environment cannot produce the ideal proof, explain that under `Proof limitations or environment constraints` so reviewers and ClawSweeper can direct the next step properly.
Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
</details>
## Tests and validation
Which commands did you run?
What regression coverage was added or updated?
What failed before this fix, if known?
If no test was added, why not?
<details>
<summary>Testing guidance</summary>
List focused commands, not every incidental check. CI is useful support, but external PRs still need real behavior proof above when behavior changes.
</details>
## Risk checklist
Did user-visible behavior change? (`Yes/No`)
Did config, environment, or migration behavior change? (`Yes/No`)
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
What is the highest-risk area?
How is that risk mitigated?
<details>
<summary>Risk guidance</summary>
Use this for author judgment that is not obvious from the diff. ClawSweeper can see touched files, but it cannot know which behavior you think is risky, why the risk is acceptable, or what mitigation reviewers should verify.
</details>
## Current review state
What is the next action?
What is still waiting on author, maintainer, CI, or external proof?
Which bot or reviewer comments were addressed?
<details>
<summary>Review state guidance</summary>
Keep this as the durable state for review progress. If useful information appears in comments, fold the current next action or blocker back here so maintainers and ClawSweeper do not need to reconstruct state from comment history.
</details>

View File

@@ -14,10 +14,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -13,10 +13,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"

View File

@@ -17,10 +17,6 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"

View File

@@ -44,7 +44,7 @@ jobs:
- name: Create ClawSweeper dispatch token
id: token
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
@@ -55,7 +55,7 @@ jobs:
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 7 * * *"
concurrency:
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:
@@ -29,7 +29,7 @@ jobs:
submodules: false
- name: Setup Java
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: temurin
java-version: "21"

View File

@@ -136,7 +136,7 @@ on:
- cron: "30 6 * * *"
concurrency:
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -6,7 +6,7 @@ on:
- cron: "0 8 * * 1"
concurrency:
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
cancel-in-progress: false
env:

View File

@@ -32,7 +32,7 @@ on:
- cron: "0 6 * * *"
concurrency:
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('ref-{0}', github.ref) }}
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:

View File

@@ -57,7 +57,7 @@ jobs:
- name: Create autoscrub app token
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
@@ -69,7 +69,7 @@ jobs:
id: app-token-fallback
continue-on-error: true
if: steps.app-token.outcome == 'failure'
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}

View File

@@ -88,27 +88,8 @@ jobs:
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
fallback_package_version="${BASH_REMATCH[1]}"
fi
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
tag_package_read=false
for attempt in 1 2 3; do
if gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' > "$tag_package_content"; then
tag_package_read=true
break
fi
if [[ "$attempt" != "3" ]]; then
sleep $((attempt * 5))
fi
done
if [[ "$tag_package_read" != "true" ]]; then
echo "Stable closeout could not read package.json for $tag from GitHub API." >&2
exit 1
fi
if ! tag_package_json="$(tr -d '\n' < "$tag_package_content" | base64 --decode)"; then
echo "Stable closeout package.json content for $tag was not valid base64." >&2
exit 1
fi
tag_package_version="$(jq -r '.version // empty' <<<"$tag_package_json")"
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
fallback_correction=false
evidence_source_tag="$tag"
if [[ "$release_package_version" != "$fallback_package_version" &&

View File

@@ -35,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
- Changelog findings: see Docs / Changelog.
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
@@ -165,12 +165,13 @@ Skills own workflows; root owns hard policy and routing.
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
## Code

View File

@@ -106,8 +106,7 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
## Before You PR
- Test locally with your OpenClaw instance
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
- Run tests: `pnpm build && pnpm check && pnpm test`
@@ -170,7 +169,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review

View File

@@ -12,7 +12,7 @@ report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchApp/Sources/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -3,7 +3,6 @@
"signingRepo": "git@github.com:openclaw/apps-signing.git",
"signingBranch": "main",
"profileType": "appstore",
"appGroupId": "group.ai.openclawfoundation.app.shared",
"targets": [
{
"target": "OpenClaw",
@@ -12,8 +11,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app",
"capabilities": ["PUSH_NOTIFICATIONS", "APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": ["PUSH_NOTIFICATIONS"]
},
{
"target": "OpenClawShareExtension",
@@ -22,8 +20,7 @@
"platform": "IOS",
"profileKey": "OPENCLAW_SHARE_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.share",
"capabilities": ["APP_GROUPS"],
"appGroups": ["group.ai.openclawfoundation.app.shared"]
"capabilities": []
},
{
"target": "OpenClawActivityWidget",
@@ -42,6 +39,15 @@
"profileKey": "OPENCLAW_WATCH_APP_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp",
"capabilities": []
},
{
"target": "OpenClawWatchExtension",
"displayName": "OpenClaw Watch Extension",
"bundleId": "ai.openclawfoundation.app.watchkitapp.extension",
"platform": "IOS",
"profileKey": "OPENCLAW_WATCH_EXTENSION_PROFILE",
"profileName": "OpenClaw App Store ai.openclawfoundation.app.watchkitapp.extension",
"capabilities": []
}
]
}

View File

@@ -7,11 +7,12 @@ OPENCLAW_DEVELOPMENT_TEAM = $(OPENCLAW_IOS_SELECTED_TEAM)
OPENCLAW_CODE_SIGN_STYLE = Automatic
OPENCLAW_CODE_SIGN_IDENTITY = Apple Development
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.

View File

@@ -7,12 +7,13 @@ OPENCLAW_DEVELOPMENT_TEAM = YOUR_TEAM_ID
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
// Leave empty with automatic signing.
OPENCLAW_APP_PROFILE =
OPENCLAW_SHARE_PROFILE =
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =

View File

@@ -101,7 +101,6 @@ Release-owner secrets:
- App Store Connect API auth uses Keychain for private key material plus non-secret `apps/ios/fastlane/.env` variables.
- The encrypted signing repo password lives outside this repo in the release-owner vault and is exposed locally as `MATCH_PASSWORD`.
- The share sheet requires the Apple Developer App Group in `apps/ios/Config/AppStoreSigning.json` to be associated with both the app and share-extension bundle IDs before App Store profiles are regenerated.
- Apple Distribution private keys, certificates, provisioning profiles, and decrypted signing sync output stay under `apps/ios/build/` or Keychain and are gitignored.
- Rotating release signing means refreshing Fastlane `match` assets and pushing a fresh encrypted sync state.
@@ -156,8 +155,7 @@ This should create `apps/ios/fastlane/.env` with non-secret App Store Connect va
- `ai.openclawfoundation.app.share`
- `ai.openclawfoundation.app.activitywidget`
- `ai.openclawfoundation.app.watchkitapp`
The main app and share extension must both be associated with the App Group pinned in `apps/ios/Config/AppStoreSigning.json`.
- `ai.openclawfoundation.app.watchkitapp.extension`
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.

View File

@@ -41,7 +41,5 @@
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</dict>
</plist>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -184,8 +184,7 @@ final class ShareViewController: UIViewController {
clientId: clientId,
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
includeDeviceIdentity: false)
}
do {

View File

@@ -10,8 +10,8 @@ OPENCLAW_DEVELOPMENT_TEAM = FWJYW4S8P8
OPENCLAW_APP_BUNDLE_ID = ai.openclawfoundation.app
OPENCLAW_SHARE_BUNDLE_ID = ai.openclawfoundation.app.share
OPENCLAW_APP_GROUP_ID = group.ai.openclawfoundation.app.shared
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclawfoundation.app.watchkitapp.extension
OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclawfoundation.app.activitywidget
OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT = development
@@ -19,6 +19,7 @@ OPENCLAW_APP_PROFILE = ai.openclawfoundation.app Development
OPENCLAW_SHARE_PROFILE = ai.openclawfoundation.app.share Development
OPENCLAW_ACTIVITY_WIDGET_PROFILE =
OPENCLAW_WATCH_APP_PROFILE =
OPENCLAW_WATCH_EXTENSION_PROFILE =
// Keep local includes after defaults: xcconfig is evaluated top-to-bottom,
// so later assignments in local files override the defaults above.

View File

@@ -62,7 +62,6 @@ struct GatewayConnectConfig {
lhs.clientId == rhs.clientId &&
lhs.clientMode == rhs.clientMode &&
lhs.clientDisplayName == rhs.clientDisplayName &&
lhs.deviceIdentityProfile == rhs.deviceIdentityProfile &&
lhs.includeDeviceIdentity == rhs.includeDeviceIdentity &&
lhsScopes == rhsScopes &&
lhsCaps == rhsCaps &&

View File

@@ -78,8 +78,6 @@
<string>OpenClaw uses on-device speech recognition for talk mode and voice wake.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>OpenClawAppGroupIdentifier</key>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
<key>OpenClawCanonicalVersion</key>
<string>$(OPENCLAW_IOS_VERSION)</string>
<key>OpenClawPushAPNsEnvironment</key>

View File

@@ -18,7 +18,6 @@ enum GatewayOnboardingReset {
let deviceId = DeviceIdentityStore.loadOrCreate().deviceId
DeviceAuthStore.clearToken(deviceId: deviceId, role: "node")
DeviceAuthStore.clearToken(deviceId: deviceId, role: "operator")
DeviceAuthStore.clearAll(profile: .shareExtension)
GatewaySettingsStore.clearLastGatewayConnection(defaults: defaults)
GatewaySettingsStore.clearPreferredGatewayStableID(defaults: defaults)

View File

@@ -4,9 +4,5 @@
<dict>
<key>aps-environment</key>
<string>$(OPENCLAW_APNS_ENTITLEMENT_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(OPENCLAW_APP_GROUP_ID)</string>
</array>
</dict>
</plist>

View File

@@ -109,10 +109,10 @@ Sources/Voice/VoiceWakePreferences.swift
ShareExtension/ShareViewController.swift
ActivityWidget/OpenClawActivityWidgetBundle.swift
ActivityWidget/OpenClawLiveActivity.swift
WatchApp/Sources/OpenClawWatchApp.swift
WatchApp/Sources/WatchConnectivityReceiver.swift
WatchApp/Sources/WatchInboxStore.swift
WatchApp/Sources/WatchInboxView.swift
WatchExtension/Sources/OpenClawWatchApp.swift
WatchExtension/Sources/WatchConnectivityReceiver.swift
WatchExtension/Sources/WatchInboxStore.swift
WatchExtension/Sources/WatchInboxView.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatComposer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift
../shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownPreprocessor.swift

View File

@@ -3,10 +3,6 @@ import OpenClawKit
import Testing
@Suite struct ShareToAgentDeepLinkTests {
@Test func appGroupIdentifierUsesCanonicalOpenClawGroup() {
#expect(OpenClawAppGroup.canonicalIdentifier == "group.ai.openclawfoundation.app.shared")
}
@Test func buildMessageIncludesSharedFields() {
let payload = SharedContentPayload(
title: "Article",

View File

@@ -20,9 +20,9 @@
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>WKApplication</key>
<true/>
<key>WKCompanionAppBundleIdentifier</key>
<string>$(OPENCLAW_APP_BUNDLE_ID)</string>
<key>WKWatchKitApp</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OpenClaw</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleShortVersionString</key>
<string>$(OPENCLAW_MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(OPENCLAW_BUILD_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>WKAppBundleIdentifier</key>
<string>$(OPENCLAW_WATCH_APP_BUNDLE_ID)</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.watchkit</string>
</dict>
</dict>
</plist>

View File

@@ -1146,7 +1146,7 @@ private enum WatchNativeTextInput {
suggestions: [String],
onSubmit: @escaping (String) -> Void)
{
WKApplication.shared().visibleInterfaceController?.presentTextInputController(
WKExtension.shared().visibleInterfaceController?.presentTextInputController(
withSuggestions: suggestions,
allowedInputMode: .allowEmoji)
{ results in

View File

@@ -293,8 +293,6 @@ def capture_watch_screenshot
Dir[File.join(output_dir, "Apple Watch*-*.png")].each { |path| FileUtils.rm_f(path) }
FileUtils.rm_rf(derived_data_path)
# Single-target watch apps only expose generic simulator build destinations in Xcode.
# Keep the selected UDID for install/launch/screenshot below.
sh(
xcodebuild_shell_join([
"xcodebuild",
@@ -305,7 +303,7 @@ def capture_watch_screenshot
"-configuration",
"Debug",
"-destination",
"generic/platform=watchOS Simulator",
"platform=watchOS Simulator,id=#{udid}",
"-derivedDataPath",
derived_data_path,
"build",
@@ -313,8 +311,10 @@ def capture_watch_screenshot
)
UI.user_error!("Watch screenshot build did not produce #{app_path}.") unless File.exist?(app_path)
extension_path = File.join(app_path, "PlugIns", "OpenClawWatchExtension.appex")
watch_app_identifier = bundle_identifier_for_product(app_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier]
watch_extension_identifier = bundle_identifier_for_product(extension_path)
screenshot_mode_bundle_identifiers = [watch_app_identifier, watch_extension_identifier]
sh("#{shell_join(["xcrun", "simctl", "boot", udid])} >/dev/null 2>&1 || true")
sh(shell_join(["xcrun", "simctl", "bootstatus", udid, "-b"]))
@@ -492,9 +492,6 @@ def produce_services_for_target(target)
if target.fetch("capabilities").include?("PUSH_NOTIFICATIONS")
services[:push_notification] = "on"
end
if target.fetch("capabilities").include?("APP_GROUPS")
services[:app_group] = "on"
end
services
end
@@ -570,15 +567,6 @@ def profile_plist_value(profile_path, key_path)
end
end
def profile_plist_array_values(profile_path, key_path)
raw = profile_plist_value(profile_path, key_path)
return [] unless raw
raw.lines.map(&:strip).reject do |line|
line.empty? || line == "Array {" || line == "}"
end
end
def validate_match_profile_capabilities!(target)
capabilities = target.fetch("capabilities")
return if capabilities.empty?
@@ -594,17 +582,6 @@ def validate_match_profile_capabilities!(target)
)
end
end
if capabilities.include?("APP_GROUPS")
expected_app_groups = target.fetch("appGroups")
actual_app_groups = profile_plist_array_values(profile_path, "Entitlements:com.apple.security.application-groups")
missing = expected_app_groups - actual_app_groups
unless missing.empty?
UI.user_error!(
"Provisioning profile #{target.fetch("profileName")} for #{target.fetch("bundleId")} is missing App Groups #{missing.join(", ")}; actual groups: #{actual_app_groups.empty? ? "missing" : actual_app_groups.join(", ")}."
)
end
end
end
def sync_app_store_signing!(readonly:)

View File

@@ -65,7 +65,7 @@ pnpm ios:release:signing:check
pnpm ios:release:signing:setup
```
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. The main app and share extension also require the shared App Group from `apps/ios/Config/AppStoreSigning.json`; associate that group with both bundle IDs in the Apple Developer Portal before regenerating profiles. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
`signing:setup` uses Fastlane `produce` and `modify_services` to create Developer Portal bundle IDs and enable required services before running `match`. If Fastlane does not already have a valid Apple Developer Portal session, run `fastlane spaceauth` for a release-owner Apple ID and export the resulting `FASTLANE_SESSION`.
Shared encrypted signing storage:

View File

@@ -65,8 +65,6 @@ targets:
embed: true
- target: OpenClawActivityWidget
embed: true
# A companion watch application belongs in the standard Watch bundle location.
# PlugIns is for extension products and breaks paired watch installation.
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -90,7 +88,7 @@ targets:
exit 1
fi
swiftformat --lint --config "$SRCROOT/../../config/swiftformat" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchApp,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--unexclude "$SRCROOT/Sources,$SRCROOT/ShareExtension,$SRCROOT/ActivityWidget,$SRCROOT/WatchExtension,$SRCROOT/../shared/OpenClawKit,$SRCROOT/../swabble" \
--filelist "$SRCROOT/SwiftSources.input.xcfilelist"
- name: SwiftLint
basedOnDependencyAnalysis: false
@@ -142,7 +140,6 @@ targets:
- openclaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawCanonicalVersion: "$(OPENCLAW_IOS_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -195,7 +192,6 @@ targets:
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_ENTITLEMENTS: ShareExtension/OpenClawShareExtension.entitlements
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ENABLE_APPINTENTS_METADATA: NO
@@ -210,7 +206,6 @@ targets:
properties:
CFBundleDisplayName: OpenClaw Share
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
OpenClawAppGroupIdentifier: "$(OPENCLAW_APP_GROUP_ID)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionPointIdentifier: com.apple.share-services
@@ -256,17 +251,13 @@ targets:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
OpenClawWatchApp:
type: application
type: application.watchapp2
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchApp
excludes:
- Info.plist
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
- target: OpenClawWatchExtension
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
@@ -283,8 +274,6 @@ targets:
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_APP_PROFILE)"
SWIFT_STRICT_CONCURRENCY: complete
SWIFT_VERSION: "6.0"
info:
path: WatchApp/Info.plist
properties:
@@ -292,7 +281,42 @@ targets:
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
WKCompanionAppBundleIdentifier: "$(OPENCLAW_APP_BUNDLE_ID)"
WKApplication: true
WKWatchKitApp: true
OpenClawWatchExtension:
type: watchkit2-extension
platform: watchOS
deploymentTarget: "11.0"
sources:
- path: WatchExtension/Sources
- path: WatchExtension/Assets.xcassets
dependencies:
- sdk: AppIntents.framework
- sdk: WatchConnectivity.framework
- sdk: UserNotifications.framework
configFiles:
Debug: Config/Signing.xcconfig
Release: Config/Signing.xcconfig
attributes:
DevelopmentTeam: "$(OPENCLAW_DEVELOPMENT_TEAM)"
ProvisioningStyle: "$(OPENCLAW_CODE_SIGN_STYLE)"
settings:
base:
CODE_SIGN_IDENTITY: "$(OPENCLAW_CODE_SIGN_IDENTITY)"
CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_WATCH_EXTENSION_BUNDLE_ID)"
PROVISIONING_PROFILE_SPECIFIER: "$(OPENCLAW_WATCH_EXTENSION_PROFILE)"
info:
path: WatchExtension/Info.plist
properties:
CFBundleDisplayName: OpenClaw
CFBundleShortVersionString: "$(OPENCLAW_MARKETING_VERSION)"
CFBundleVersion: "$(OPENCLAW_BUILD_VERSION)"
NSExtension:
NSExtensionAttributes:
WKAppBundleIdentifier: "$(OPENCLAW_WATCH_APP_BUNDLE_ID)"
NSExtensionPointIdentifier: com.apple.watchkit
OpenClawTests:
type: bundle.unit-test

View File

@@ -21,12 +21,10 @@ private struct DeviceAuthStoreFile: Codable {
}
public enum DeviceAuthStore {
public static func loadToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry?
{
guard let store = readStore(profile: profile), store.deviceId == deviceId else { return nil }
private static let fileName = "device-auth.json"
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
guard let store = readStore(), store.deviceId == deviceId else { return nil }
let role = self.normalizeRole(role)
return store.tokens[role]
}
@@ -35,11 +33,10 @@ public enum DeviceAuthStore {
deviceId: String,
role: String,
token: String,
scopes: [String] = [],
profile: GatewayDeviceIdentityProfile = .primary) -> DeviceAuthEntry
scopes: [String] = []) -> DeviceAuthEntry
{
let normalizedRole = self.normalizeRole(role)
var next = self.readStore(profile: profile)
var next = self.readStore()
if next?.deviceId != deviceId {
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
}
@@ -53,25 +50,17 @@ public enum DeviceAuthStore {
}
next?.tokens[normalizedRole] = entry
if let store = next {
self.writeStore(store, profile: profile)
self.writeStore(store)
}
return entry
}
public static func clearToken(
deviceId: String,
role: String,
profile: GatewayDeviceIdentityProfile = .primary)
{
guard var store = readStore(profile: profile), store.deviceId == deviceId else { return }
public static func clearToken(deviceId: String, role: String) {
guard var store = readStore(), store.deviceId == deviceId else { return }
let normalizedRole = self.normalizeRole(role)
guard store.tokens[normalizedRole] != nil else { return }
store.tokens.removeValue(forKey: normalizedRole)
self.writeStore(store, profile: profile)
}
public static func clearAll(profile: GatewayDeviceIdentityProfile = .primary) {
try? FileManager.default.removeItem(at: self.fileURL(profile: profile))
self.writeStore(store)
}
private static func normalizeRole(_ role: String) -> String {
@@ -85,14 +74,14 @@ public enum DeviceAuthStore {
return Array(Set(trimmed)).sorted()
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
DeviceIdentityPaths.stateDirURL()
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.authFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
private static func readStore(profile: GatewayDeviceIdentityProfile) -> DeviceAuthStoreFile? {
let url = self.fileURL(profile: profile)
private static func readStore() -> DeviceAuthStoreFile? {
let url = self.fileURL()
guard let data = try? Data(contentsOf: url) else { return nil }
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
return nil
@@ -101,8 +90,8 @@ public enum DeviceAuthStore {
return decoded
}
private static func writeStore(_ store: DeviceAuthStoreFile, profile: GatewayDeviceIdentityProfile) {
let url = self.fileURL(profile: profile)
private static func writeStore(_ store: DeviceAuthStoreFile) {
let url = self.fileURL()
do {
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),

View File

@@ -1,29 +1,6 @@
import CryptoKit
import Foundation
public enum GatewayDeviceIdentityProfile: String, Sendable {
case primary
case shareExtension
var identityFileName: String {
switch self {
case .primary:
"device.json"
case .shareExtension:
"share-device.json"
}
}
var authFileName: String {
switch self {
case .primary:
"device-auth.json"
case .shareExtension:
"share-device-auth.json"
}
}
}
public struct DeviceIdentity: Codable, Sendable {
public var deviceId: String
public var publicKey: String
@@ -42,32 +19,6 @@ enum DeviceIdentityPaths {
private static let stateDirEnv = ["OPENCLAW_STATE_DIR"]
static func stateDirURL() -> URL {
self.stateDirURL(
overrideURL: self.stateDirOverrideURL(),
legacyStateDirURL: self.legacyStateDirURL(),
appGroupStateDirURL: self.appGroupStateDirURL(),
temporaryDirectory: FileManager.default.temporaryDirectory)
}
static func stateDirURL(
overrideURL: URL?,
legacyStateDirURL: URL?,
appGroupStateDirURL: URL?,
temporaryDirectory: URL) -> URL
{
if let overrideURL {
return overrideURL
}
if let appGroupStateDirURL {
return appGroupStateDirURL
}
if let legacyStateDirURL {
return legacyStateDirURL
}
return temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
private static func stateDirOverrideURL() -> URL? {
for key in self.stateDirEnv {
if let raw = getenv(key) {
let value = String(cString: raw).trimmingCharacters(in: .whitespacesAndNewlines)
@@ -76,49 +27,34 @@ enum DeviceIdentityPaths {
}
}
}
return nil
}
private static func legacyStateDirURL() -> URL? {
if let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first {
return appSupport.appendingPathComponent("OpenClaw", isDirectory: true)
}
return nil
}
private static func appGroupStateDirURL() -> URL? {
guard
let containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: OpenClawAppGroup.identifier)
else {
return nil
}
return containerURL.appendingPathComponent("OpenClaw", isDirectory: true)
return FileManager.default.temporaryDirectory.appendingPathComponent("openclaw", isDirectory: true)
}
}
public enum DeviceIdentityStore {
private static let fileName = "device.json"
private static let ed25519SPKIPrefix = Data([
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65,
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65,
0x70, 0x03, 0x21, 0x00,
])
private static let ed25519PKCS8PrivatePrefix = Data([
0x30, 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2B, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06,
0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
])
public static func loadOrCreate() -> DeviceIdentity {
self.loadOrCreate(profile: .primary)
}
public static func loadOrCreate(profile: GatewayDeviceIdentityProfile) -> DeviceIdentity {
self.loadOrCreate(fileURL: self.fileURL(profile: profile))
self.loadOrCreate(fileURL: self.fileURL())
}
static func loadOrCreate(fileURL url: URL) -> DeviceIdentity {
if let data = try? Data(contentsOf: url) {
switch self.decodeStoredIdentity(data) {
case let .identity(decoded):
case .identity(let decoded):
return decoded
case .recognizedInvalid:
return self.generate()
@@ -207,7 +143,7 @@ public enum DeviceIdentityStore {
let privateKeyData = Data(base64Encoded: identity.privateKey)
else { return nil }
guard publicKeyData.count == 32, privateKeyData.count == 32,
guard publicKeyData.count == 32 && privateKeyData.count == 32,
self.keyPairMatches(publicKeyData: publicKeyData, privateKeyData: privateKeyData)
else { return nil }
return DeviceIdentity(
@@ -275,11 +211,11 @@ public enum DeviceIdentityStore {
}
}
private static func fileURL(profile: GatewayDeviceIdentityProfile) -> URL {
private static func fileURL() -> URL {
let base = DeviceIdentityPaths.stateDirURL()
return base
.appendingPathComponent("identity", isDirectory: true)
.appendingPathComponent(profile.identityFileName, isDirectory: false)
.appendingPathComponent(self.fileName, isDirectory: false)
}
}

View File

@@ -107,7 +107,6 @@ public struct GatewayConnectOptions: Sendable {
public var clientId: String
public var clientMode: String
public var clientDisplayName: String?
public var deviceIdentityProfile: GatewayDeviceIdentityProfile
/// When false, the connection omits the signed device identity payload and cannot use
/// device-scoped auth (role/scope upgrades will require pairing). Keep this true for
/// role/scoped sessions such as operator UI clients.
@@ -123,7 +122,6 @@ public struct GatewayConnectOptions: Sendable {
clientId: String,
clientMode: String,
clientDisplayName: String?,
deviceIdentityProfile: GatewayDeviceIdentityProfile = .primary,
includeDeviceIdentity: Bool = true)
{
self.role = role
@@ -135,7 +133,6 @@ public struct GatewayConnectOptions: Sendable {
self.clientId = clientId
self.clientMode = clientMode
self.clientDisplayName = clientDisplayName
self.deviceIdentityProfile = deviceIdentityProfile
self.includeDeviceIdentity = includeDeviceIdentity
}
}
@@ -439,15 +436,13 @@ public actor GatewayChannelActor {
let clientId = options.clientId
let clientMode = options.clientMode
let role = options.role
let deviceIdentityProfile = options.deviceIdentityProfile
let requestedScopes = options.scopes
let scopesAreExplicit = options.scopesAreExplicit
let includeDeviceIdentity = options.includeDeviceIdentity
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate(profile: deviceIdentityProfile) : nil
let identity = includeDeviceIdentity ? DeviceIdentityStore.loadOrCreate() : nil
let selectedAuth = self.selectConnectAuth(
role: role,
includeDeviceIdentity: includeDeviceIdentity,
deviceIdentityProfile: deviceIdentityProfile,
deviceId: identity?.deviceId,
requestedScopes: requestedScopes)
let scopes = self.resolveConnectScopes(
@@ -537,11 +532,7 @@ public actor GatewayChannelActor {
try await self.task?.send(.data(data))
do {
let response = try await self.waitForConnectResponse(reqId: reqId)
try await self.handleConnectResponse(
response,
identity: identity,
role: role,
deviceIdentityProfile: deviceIdentityProfile)
try await self.handleConnectResponse(response, identity: identity, role: role)
self.pendingDeviceTokenRetry = false
self.deviceTokenRetryBudgetUsed = false
} catch {
@@ -559,10 +550,7 @@ public actor GatewayChannelActor {
self.shouldClearStoredDeviceTokenAfterRetry(error)
{
// Retry failed with an explicit device-token mismatch; clear stale local token.
DeviceAuthStore.clearToken(
deviceId: identity.deviceId,
role: role,
profile: deviceIdentityProfile)
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
}
throw error
}
@@ -571,7 +559,6 @@ public actor GatewayChannelActor {
private func selectConnectAuth(
role: String,
includeDeviceIdentity: Bool,
deviceIdentityProfile: GatewayDeviceIdentityProfile,
deviceId: String?,
requestedScopes: [String]) -> SelectedConnectAuth
{
@@ -581,7 +568,7 @@ public actor GatewayChannelActor {
let explicitPassword = self.password?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty
let storedEntry =
(includeDeviceIdentity && deviceId != nil)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role, profile: deviceIdentityProfile)
? DeviceAuthStore.loadToken(deviceId: deviceId!, role: role)
: nil
let storedToken = storedEntry?.token
let storedScopes = storedEntry?.scopes ?? []
@@ -769,8 +756,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
guard let filteredScopes = self.filteredBootstrapHandoffScopes(role: role, scopes: scopes) else {
return
@@ -779,8 +765,7 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: filteredScopes,
profile: deviceIdentityProfile)
scopes: filteredScopes)
}
private func persistIssuedDeviceToken(
@@ -788,8 +773,7 @@ public actor GatewayChannelActor {
deviceId: String,
role: String,
token: String,
scopes: [String],
deviceIdentityProfile: GatewayDeviceIdentityProfile)
scopes: [String])
{
if authSource == .bootstrapToken {
guard self.shouldPersistBootstrapHandoffTokens() else {
@@ -799,23 +783,20 @@ public actor GatewayChannelActor {
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
return
}
_ = DeviceAuthStore.storeToken(
deviceId: deviceId,
role: role,
token: token,
scopes: scopes,
profile: deviceIdentityProfile)
scopes: scopes)
}
private func handleConnectResponse(
_ res: ResponseFrame,
identity: DeviceIdentity?,
role: String,
deviceIdentityProfile: GatewayDeviceIdentityProfile) async throws
role: String) async throws
{
if res.ok == false {
let error = res.error
@@ -874,8 +855,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
if self.shouldPersistBootstrapHandoffTokens(),
let tokenEntries = auth["deviceTokens"]?.value as? [ProtoAnyCodable]
@@ -893,8 +873,7 @@ public actor GatewayChannelActor {
deviceId: identity.deviceId,
role: authRole,
token: deviceToken,
scopes: scopes,
deviceIdentityProfile: deviceIdentityProfile)
scopes: scopes)
}
}
}

View File

@@ -162,7 +162,6 @@ public actor GatewayNodeSession {
let clientId = options.clientId.trimmingCharacters(in: .whitespacesAndNewlines)
let clientMode = options.clientMode.trimmingCharacters(in: .whitespacesAndNewlines)
let clientDisplayName = (options.clientDisplayName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let deviceIdentityProfile = options.deviceIdentityProfile.rawValue
let includeDeviceIdentity = options.includeDeviceIdentity ? "1" : "0"
let permissions = options.permissions
.map { key, value in
@@ -180,7 +179,6 @@ public actor GatewayNodeSession {
clientId,
clientMode,
clientDisplayName,
deviceIdentityProfile,
includeDeviceIdentity,
permissions,
].joined(separator: "|")

View File

@@ -1,11 +0,0 @@
import Foundation
public enum OpenClawAppGroup {
public static let canonicalIdentifier = "group.ai.openclawfoundation.app.shared"
public static var identifier: String {
let raw = Bundle.main.object(forInfoDictionaryKey: "OpenClawAppGroupIdentifier") as? String
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.canonicalIdentifier : trimmed
}
}

View File

@@ -26,7 +26,7 @@ public struct ShareGatewayRelayConfig: Codable, Sendable, Equatable {
}
public enum ShareGatewayRelaySettings {
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let suiteName = "group.ai.openclaw.shared"
private static let relayConfigKey = "share.gatewayRelay.config.v1"
private static let lastEventKey = "share.gatewayRelay.event.v1"

View File

@@ -1,7 +1,7 @@
import Foundation
public enum ShareToAgentSettings {
private static var suiteName: String { OpenClawAppGroup.identifier }
private static let suiteName = "group.ai.openclaw.shared"
private static let defaultInstructionKey = "share.defaultInstruction"
private static var defaults: UserDefaults {

View File

@@ -548,7 +548,6 @@ public struct MessageActionParams: Codable, Sendable {
public let action: String
public let params: [String: AnyCodable]
public let accountid: String?
public let requesteraccountid: String?
public let requestersenderid: String?
public let senderisowner: Bool?
public let sessionkey: String?
@@ -563,7 +562,6 @@ public struct MessageActionParams: Codable, Sendable {
action: String,
params: [String: AnyCodable],
accountid: String?,
requesteraccountid: String? = nil,
requestersenderid: String?,
senderisowner: Bool?,
sessionkey: String?,
@@ -577,7 +575,6 @@ public struct MessageActionParams: Codable, Sendable {
self.action = action
self.params = params
self.accountid = accountid
self.requesteraccountid = requesteraccountid
self.requestersenderid = requestersenderid
self.senderisowner = senderisowner
self.sessionkey = sessionkey
@@ -593,7 +590,6 @@ public struct MessageActionParams: Codable, Sendable {
case action
case params
case accountid = "accountId"
case requesteraccountid = "requesterAccountId"
case requestersenderid = "requesterSenderId"
case senderisowner = "senderIsOwner"
case sessionkey = "sessionKey"

View File

@@ -5,99 +5,8 @@ import Testing
@Suite(.serialized)
struct DeviceIdentityStoreTests {
@Test
func `state directory override wins over shared app group storage`() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let overrideURL = tempDir.appendingPathComponent("override", isDirectory: true)
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: overrideURL,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == overrideURL)
#expect(!FileManager.default.fileExists(atPath: sharedURL.path))
}
@Test
func `shared app group storage wins over legacy app support storage`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let legacyURL = tempDir.appendingPathComponent("legacy", isDirectory: true)
let sharedURL = tempDir.appendingPathComponent("shared", isDirectory: true)
let legacyIdentityURL = legacyURL.appendingPathComponent("identity", isDirectory: true)
let legacyDeviceURL = legacyIdentityURL.appendingPathComponent("device.json", isDirectory: false)
let sharedIdentityURL = sharedURL.appendingPathComponent("identity", isDirectory: true)
let sharedDeviceURL = sharedIdentityURL.appendingPathComponent("device.json", isDirectory: false)
try FileManager.default.createDirectory(at: legacyIdentityURL, withIntermediateDirectories: true)
try "legacy-device\n".write(to: legacyDeviceURL, atomically: true, encoding: .utf8)
let selected = DeviceIdentityPaths.stateDirURL(
overrideURL: nil,
legacyStateDirURL: legacyURL,
appGroupStateDirURL: sharedURL,
temporaryDirectory: tempDir)
#expect(selected == sharedURL)
#expect(!FileManager.default.fileExists(atPath: sharedDeviceURL.path))
}
@Test
func `share extension profile uses separate identity and auth files`() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
let shareIdentity = DeviceIdentityStore.loadOrCreate(profile: .shareExtension)
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-token")
_ = DeviceAuthStore.storeToken(
deviceId: shareIdentity.deviceId,
role: "node",
token: "share-token",
profile: .shareExtension)
let identityDir = tempDir.appendingPathComponent("identity", isDirectory: true)
#expect(primaryIdentity.deviceId != shareIdentity.deviceId)
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("share-device.json").path))
#expect(FileManager.default.fileExists(atPath: identityDir.appendingPathComponent("device-auth.json").path))
#expect(FileManager.default
.fileExists(atPath: identityDir.appendingPathComponent("share-device-auth.json").path))
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(
DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension)?.token ==
"share-token")
DeviceAuthStore.clearAll(profile: .shareExtension)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?.token == "primary-token")
#expect(DeviceAuthStore
.loadToken(deviceId: shareIdentity.deviceId, role: "node", profile: .shareExtension) == nil)
}
@Test
func `loads TypeScript PEM identity schema without rewriting or regenerating`() throws {
@Test("loads TypeScript PEM identity schema without rewriting or regenerating")
func loadsTypeScriptPEMIdentitySchema() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -131,8 +40,8 @@ struct DeviceIdentityStoreTests {
#expect(try String(contentsOf: identityURL, encoding: .utf8) == before)
}
@Test
func `does not overwrite a recognized invalid TypeScript identity schema`() throws {
@Test("does not overwrite a recognized invalid TypeScript identity schema")
func preservesInvalidTypeScriptPEMIdentitySchema() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let identityURL = tempDir
@@ -143,14 +52,14 @@ struct DeviceIdentityStoreTests {
at: identityURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
let stored = """
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
{
"version": 1,
"deviceId": "stale-device-id",
"publicKeyPem": "not-a-valid-public-key",
"privateKeyPem": "not-a-valid-private-key",
"createdAtMs": 1700000000000
}
"""
try stored.write(to: identityURL, atomically: true, encoding: .utf8)
let before = try String(contentsOf: identityURL, encoding: .utf8)

View File

@@ -1,22 +0,0 @@
import OpenClawProtocol
import Testing
struct GatewayModelsCompatibilityTests {
@Test
func messageActionParamsKeepsRequesterAccountAdditive() {
let params = MessageActionParams(
channel: "slack",
action: "member-info",
params: [:],
accountid: "default",
requestersenderid: "U123",
senderisowner: true,
sessionkey: nil,
sessionid: nil,
toolcontext: nil,
idempotencykey: "test"
)
#expect(params.requesteraccountid == nil)
}
}

View File

@@ -1,10 +1,10 @@
import Foundation
import OpenClawProtocol
import Testing
@testable import OpenClawKit
import OpenClawProtocol
extension NSLock {
fileprivate func withLock<T>(_ body: () -> T) -> T {
private extension NSLock {
func withLock<T>(_ body: () -> T) -> T {
self.lock()
defer { self.unlock() }
return body()
@@ -18,9 +18,7 @@ private final class DoubleCallbackPingWebSocketTask: WebSocketTasking, @unchecke
self.callbacks = callbacks
}
var state: URLSessionTask.State {
.running
}
var state: URLSessionTask.State { .running }
func resume() {}
@@ -55,7 +53,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
private var _state: URLSessionTask.State = .suspended
private var connectRequestId: String?
private var connectAuth: [String: Any]?
private var connectDevice: [String: Any]?
private var receivePhase = 0
private var pendingReceiveHandler:
(@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
@@ -76,10 +73,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
_ = (closeCode, reason)
self.state = .canceling
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
}
@@ -98,13 +92,10 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
obj["method"] as? String == "connect",
let id = obj["id"] as? String
{
let params = obj["params"] as? [String: Any]
let auth = (params?["auth"] as? [String: Any]) ?? [:]
let device = params?["device"] as? [String: Any]
let auth = ((obj["params"] as? [String: Any])?["auth"] as? [String: Any]) ?? [:]
self.lock.withLock {
self.connectRequestId = id
self.connectAuth = auth
self.connectDevice = device
}
}
}
@@ -113,10 +104,6 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
self.lock.withLock { self.connectAuth }
}
func latestConnectDevice() -> [String: Any]? {
self.lock.withLock { self.connectDevice }
}
func sendPing(pongReceiveHandler: @escaping @Sendable (Error?) -> Void) {
pongReceiveHandler(nil)
}
@@ -147,10 +134,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
}
func emitReceiveFailure() {
let handler = self.lock.withLock { () -> (@Sendable (Result<
URLSessionWebSocketTask.Message,
Error,
>) -> Void)? in
let handler = self.lock.withLock { () -> (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)? in
self._state = .canceling
defer { self.pendingReceiveHandler = nil }
return self.pendingReceiveHandler
@@ -191,7 +175,7 @@ private final class FakeGatewayWebSocketTask: WebSocketTasking, @unchecked Senda
"policy": [
"maxPayload": 1,
"maxBufferedBytes": 1,
"tickIntervalMs": 30000,
"tickIntervalMs": 30_000,
],
"auth": [:],
]
@@ -239,25 +223,20 @@ private final class FakeGatewayWebSocketSession: WebSocketSessioning, @unchecked
private actor SeqGapProbe {
private var saw = false
func mark() {
self.saw = true
}
func value() -> Bool {
self.saw
}
func mark() { self.saw = true }
func value() -> Bool { self.saw }
}
@Suite(.serialized)
struct GatewayNodeSessionTests {
@Test
func `websocket ping ignores duplicate success callbacks`() async throws {
func websocketPingIgnoresDuplicateSuccessCallbacks() async throws {
let task = DoubleCallbackPingWebSocketTask(callbacks: [nil, nil])
try await WebSocketTaskBox(task: task).sendPing()
}
@Test
func `websocket ping ignores duplicate callbacks after first error`() async throws {
func websocketPingIgnoresDuplicateCallbacksAfterFirstError() async throws {
let firstError = URLError(.networkConnectionLost)
let task = DoubleCallbackPingWebSocketTask(callbacks: [firstError, nil])
@@ -270,7 +249,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `scanned setup code prefers bootstrap auth over stored device token`() async throws {
func scannedSetupCodePrefersBootstrapAuthOverStoredDeviceToken() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -305,7 +284,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -326,74 +305,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `share extension identity profile uses separate node identity and token store`() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let previousStateDir = ProcessInfo.processInfo.environment["OPENCLAW_STATE_DIR"]
setenv("OPENCLAW_STATE_DIR", tempDir.path, 1)
defer {
if let previousStateDir {
setenv("OPENCLAW_STATE_DIR", previousStateDir, 1)
} else {
unsetenv("OPENCLAW_STATE_DIR")
}
try? FileManager.default.removeItem(at: tempDir)
}
let primaryIdentity = DeviceIdentityStore.loadOrCreate()
_ = DeviceAuthStore.storeToken(
deviceId: primaryIdentity.deviceId,
role: "node",
token: "primary-node-token")
let session = FakeGatewayWebSocketSession(helloAuth: [
"deviceToken": "share-node-token",
"role": "node",
"scopes": [],
])
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
role: "node",
scopes: [],
caps: [],
commands: [],
permissions: [:],
clientId: "openclaw-ios",
clientMode: "node",
clientDisplayName: "OpenClaw Share",
deviceIdentityProfile: .shareExtension,
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
token: nil,
bootstrapToken: nil,
password: "shared-password",
connectOptions: options,
sessionBox: WebSocketSessionBox(session: session),
onConnected: {},
onDisconnected: { _ in },
onInvoke: { req in
BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
let shareDevice = try #require(session.latestTask()?.latestConnectDevice())
let shareDeviceId = try #require(shareDevice["id"] as? String)
#expect(shareDeviceId != primaryIdentity.deviceId)
#expect(DeviceAuthStore.loadToken(deviceId: primaryIdentity.deviceId, role: "node")?
.token == "primary-node-token")
#expect(DeviceAuthStore.loadToken(deviceId: shareDeviceId, role: "node") == nil)
#expect(
DeviceAuthStore
.loadToken(deviceId: shareDeviceId, role: "node", profile: .shareExtension)?.token ==
"share-node-token")
await gateway.disconnect()
}
@Test
func `password takes precedence over bootstrap token`() async throws {
func passwordTakesPrecedenceOverBootstrapToken() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -408,7 +320,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "stale-bootstrap-token",
password: "shared-password",
@@ -429,7 +341,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `changed session box rebuilds existing gateway channel`() async throws {
func changedSessionBoxRebuildsExistingGatewayChannel() async throws {
let firstSession = FakeGatewayWebSocketSession()
let secondSession = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
@@ -445,7 +357,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: false)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -458,7 +370,7 @@ struct GatewayNodeSessionTests {
})
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -477,7 +389,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `bootstrap hello stores additional device tokens`() async throws {
func bootstrapHelloStoresAdditionalDeviceTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -528,7 +440,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -556,7 +468,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `non bootstrap hello stores primary device token but not additional bootstrap tokens`() async throws {
func nonBootstrapHelloStoresPrimaryDeviceTokenButNotAdditionalBootstrapTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -597,7 +509,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "wss://example.invalid")),
url: URL(string: "wss://example.invalid")!,
token: "shared-token",
bootstrapToken: nil,
password: nil,
@@ -618,7 +530,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `untrusted bootstrap hello does not persist bootstrap handoff tokens`() async throws {
func untrustedBootstrapHelloDoesNotPersistBootstrapHandoffTokens() async throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@@ -662,7 +574,7 @@ struct GatewayNodeSessionTests {
includeDeviceIdentity: true)
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: "fresh-bootstrap-token",
password: nil,
@@ -681,25 +593,25 @@ struct GatewayNodeSessionTests {
}
@Test
func `normalize canvas host url preserves explicit secure canvas port`() throws {
let normalized = try canonicalizeCanvasHostUrl(
func normalizeCanvasHostUrlPreservesExplicitSecureCanvasPort() {
let normalized = canonicalizeCanvasHostUrl(
raw: "https://canvas.example.com:9443/__openclaw__/cap/token",
activeURL: #require(URL(string: "wss://gateway.example.com")))
activeURL: URL(string: "wss://gateway.example.com")!)
#expect(normalized == "https://canvas.example.com:9443/__openclaw__/cap/token")
}
@Test
func `normalize canvas host url backfills gateway host for loopback canvas`() throws {
let normalized = try canonicalizeCanvasHostUrl(
func normalizeCanvasHostUrlBackfillsGatewayHostForLoopbackCanvas() {
let normalized = canonicalizeCanvasHostUrl(
raw: "http://127.0.0.1:18789/__openclaw__/cap/token",
activeURL: #require(URL(string: "wss://gateway.example.com:7443")))
activeURL: URL(string: "wss://gateway.example.com:7443")!)
#expect(normalized == "https://gateway.example.com:7443/__openclaw__/cap/token")
}
@Test
func `invoke with timeout returns underlying response before timeout`() async {
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -707,7 +619,8 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
#expect(req.id == "1")
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
})
}
)
#expect(response.ok == true)
#expect(response.error == nil)
@@ -715,7 +628,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `invoke with timeout returns timeout error`() async {
func invokeWithTimeoutReturnsTimeoutError() async {
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -723,7 +636,8 @@ struct GatewayNodeSessionTests {
onInvoke: { _ in
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
})
}
)
#expect(response.ok == false)
#expect(response.error?.code == .unavailable)
@@ -731,7 +645,7 @@ struct GatewayNodeSessionTests {
}
@Test
func `invoke with timeout zero disables timeout`() async {
func invokeWithTimeoutZeroDisablesTimeout() async {
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
let response = await GatewayNodeSession.invokeWithTimeout(
request: request,
@@ -739,14 +653,15 @@ struct GatewayNodeSessionTests {
onInvoke: { req in
try? await Task.sleep(nanoseconds: 5_000_000)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
})
}
)
#expect(response.ok == true)
#expect(response.error == nil)
}
@Test
func `emits synthetic seq gap after reconnect snapshot`() async throws {
func emitsSyntheticSeqGapAfterReconnectSnapshot() async throws {
let session = FakeGatewayWebSocketSession()
let gateway = GatewayNodeSession()
let options = GatewayConnectOptions(
@@ -772,7 +687,7 @@ struct GatewayNodeSessionTests {
}
try await gateway.connect(
url: #require(URL(string: "ws://example.invalid")),
url: URL(string: "ws://example.invalid")!,
token: nil,
bootstrapToken: nil,
password: nil,

View File

@@ -128,6 +128,10 @@ const config = {
"**/*.test-utils.ts",
"test/helpers/live-image-probe.ts",
"src/secrets/credential-matrix.ts",
"src/agents/claude-cli-runner.ts",
"src/agents/agent-auth-json.ts",
"src/agents/tool-policy.conformance.ts",
"src/auto-reply/reply/audio-tags.ts",
"src/gateway/live-tool-probe-utils.ts",
"src/gateway/server.auth.shared.ts",
"src/shared/text/assistant-visible-text.ts",

View File

@@ -1,2 +1,2 @@
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
c0ead0a6a428d4517c7ee5f09aa0151ba18f7051bc5c9806562dec544dfad20b plugin-sdk-api-baseline.json
d4a0b6915c2ec8c68371b18b7a0999e48678ee243e7e9d41932d4d96390540cf plugin-sdk-api-baseline.jsonl

View File

@@ -1194,9 +1194,5 @@
{
"source": "cohere",
"target": "cohere"
},
{
"source": "Zalo ClawBot",
"target": "Zalo ClawBot"
}
]

View File

@@ -52,7 +52,6 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) - Most popular; uses Baileys and requires QR pairing.
- [Yuanbao](/channels/yuanbao) - Tencent Yuanbao bot (external plugin).
- [Zalo](/channels/zalo) - Zalo Bot API; Vietnam's popular messenger (bundled plugin).
- [Zalo ClawBot](/channels/zaloclawbot) - Personal Zalo assistant via QR login; owner-bound (external plugin).
- [Zalo Personal](/channels/zalouser) - Zalo personal account via QR login (bundled plugin).
## Notes

View File

@@ -1409,14 +1409,10 @@ Same-chat `/approve` also works in Slack channels and DMs that already support c
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
- Thread starter and initial thread-history context seeding are filtered by configured sender allowlists when applicable.
- Block actions, shortcuts, and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
- block actions: selected values, labels, picker values, and `workflow_*` metadata
- global shortcuts: callback and actor metadata, routed to the actor's direct session
- message shortcuts: callback, actor, channel, thread, and selected-message context
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
Define global or message shortcuts in your Slack app configuration and use any non-empty callback ID. OpenClaw acknowledges matching shortcut payloads, applies the same DM/channel sender policy as other Slack interactions, and queues the sanitized event for the routed agent session. Trigger IDs and response URLs are redacted from agent context.
## Configuration reference
Primary reference: [Configuration reference - Slack](/gateway/config-channels#slack).

View File

@@ -1,95 +0,0 @@
---
summary: "Zalo ClawBot channel setup through the external openclaw-zaloclawbot plugin"
read_when:
- You want a personal Zalo assistant bot with QR-code login
- You are installing or troubleshooting the openclaw-zaloclawbot channel plugin
title: "Zalo ClawBot"
---
OpenClaw connects to Zalo ClawBot through the catalog-listed external
`@zalo-platforms/openclaw-zaloclawbot` plugin. Login uses a Zalo Mini App QR
code.
## Compatibility
| Plugin Version | OpenClaw Version | npm dist-tag | Status |
| -------------- | ---------------- | ------------ | ------------- |
| 0.1.x | >=2026.4.10 | `latest` | Active / Beta |
## Prerequisites
- Node.js **>= 22**
- [OpenClaw](https://docs.openclaw.ai/install) must be installed (`openclaw` CLI available).
- A Zalo account on a mobile device to scan the login QR code.
## Install with onboard (recommended)
Run the OpenClaw onboarding wizard and pick **Zalo ClawBot** from the channel menu:
```bash
openclaw onboard
```
The wizard installs the plugin from the official catalog (integrity-verified), renders the login QR right in the terminal, and finishes the channel once you scan it with the Zalo app. No extra commands are needed.
## Manual Installation
To add the channel to an already-onboarded gateway, follow these steps:
### 1. Install the plugin
```bash
openclaw plugins install "@zalo-platforms/openclaw-zaloclawbot@0.1.4"
```
Use the exact pinned version shown above (it matches the official catalog entry), so OpenClaw verifies the package against the catalog integrity hash during install.
### 2. Enable the plugin in config
```bash
openclaw config set plugins.entries.openclaw-zaloclawbot.enabled true
```
### 3. Generate QR code and log in
```bash
openclaw channels login --channel openclaw-zaloclawbot
```
Scan the terminal-rendered QR code using the Zalo mobile app, accept the Terms of Use inside the Zalo Mini App, and authorize the session.
### 4. Restart the gateway
```bash
openclaw gateway restart
```
---
## How It Works
Unlike the standard developer Zalo channel which requires you to register your own Zalo Official Account (OA) and paste static developer credentials, Zalo ClawBot operates as an **owner-bound personal assistant** using a shared, official infrastructure:
1. **Secure Onboarding:** The QR code resolves to a secure Zalo Mini App that binds a newly-provisioned, private bot under a shared official OA directly to your Zalo User ID.
2. **Owner-Bound Privacy:** By design, the bot is restricted to communicating _only_ with its owner. Messages from other users are dropped at the platform level, making the connection private and secure.
3. **Official API path:** The plugin uses Zalo Bot Platform APIs instead of
browser or web-session automation.
## Under the Hood
The Zalo ClawBot plugin communicates with Zalo APIs via a persistent long-polling message loop. To maintain a clean and lightweight runtime:
- Long-poll connections utilize the `getUpdates` endpoint.
- Webhooks are disabled by default for local desktop/terminal gateway runs.
- Messages are processed client-side and mapped directly to your local agent runtime.
The external plugin manages bot credentials under the OpenClaw state directory.
Treat that directory as sensitive and include it in the same access-control and
backup policy as the rest of your OpenClaw state.
---
## Troubleshooting
- **QR Login Timeout:** The login token (`zbsk`) expires after 5 minutes for security reasons. If the QR code expires before you scan it, simply rerun the login command to generate a new one.
- **Gateway Fails to Load:** Ensure your OpenClaw host version is `2026.4.10` or higher. Older versions do not support the external npm-plugin installation ledger.

View File

@@ -47,21 +47,33 @@ Use `pnpm ci:timings`, `pnpm ci:timings:recent`, or `node scripts/ci-run-timings
For pull request runs, the terminal timing-summary job runs the helper from the trusted base revision before passing `GH_TOKEN` to `gh run view`. That keeps the tokened query out of branch-controlled code while still summarizing the pull request's current CI run.
## PR context and evidence
## Real behavior proof
External contributor PRs run a PR context and evidence gate from
External contributor PRs run a `Real behavior proof` gate from
`.github/workflows/real-behavior-proof.yml`. The workflow checks out the trusted
base commit and evaluates the PR body only; it does not execute code from the
contributor branch.
The gate applies to PR authors who are not repository owners, members,
collaborators, or bots. It passes when the PR body contains authored
`What Problem This Solves` and `Evidence` sections. Evidence can be a focused
test, CI result, screenshot, recording, terminal output, live observation,
redacted log, or artifact link. The body provides intent and useful validation;
reviewers inspect the code, tests, and CI to assess correctness.
collaborators, or bots. It passes when the PR body contains a
`Real behavior proof` section with filled values for:
- `Behavior or issue addressed`
- `Real environment tested`
- `Exact steps or command run after this patch`
- `Evidence after fix`
- `Observed result after fix`
- `What was not tested`
The evidence must show the changed behavior after the patch in a real OpenClaw
setup. Screenshots, recordings, terminal captures, console output, copied live
output, redacted runtime logs, and linked artifacts all count. Unit tests, mocks,
snapshots, lint, typechecks, and CI results are useful supporting verification,
but they do not satisfy this gate by themselves.
When the check fails, update the PR body instead of pushing another code commit.
Maintainers can apply `proof: override` only when the proof gate should not
apply to that PR.
## Scope and routing

View File

@@ -315,7 +315,7 @@ Current existing-session limits:
- `hover`, `scrollintoview`, `drag`, `select`, `fill`, and `evaluate` reject
per-call timeout overrides
- `select` supports one value only
- `wait --load networkidle` is not supported on existing-session profiles (works on managed and raw/remote CDP)
- `wait --load networkidle` is not supported
- file uploads require `--ref` / `--input-ref`, do not support CSS
`--element`, and currently support one file at a time
- dialog hooks do not support `--timeout`

View File

@@ -39,13 +39,7 @@ openclaw nodes status --last-connected 24h
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
Use `nodes remove --node <id|name|ip>` to remove a node pairing. For a
device-backed node this revokes the device's `node` role in `devices/paired.json`
and disconnects its node-role sessions (a mixed-role device keeps its row and
only loses the `node` role; a node-only device is deleted); it also clears any
matching legacy gateway-owned node pairing record. `operator.pairing` can remove
non-operator node rows; a device-token caller revoking its own node role on a
mixed-role device additionally needs `operator.admin`.
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
Approval note:

View File

@@ -168,62 +168,11 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
}
```
## Compact a session
Related:
Reclaim context budget for a wedged or oversized session. `openclaw sessions compact <key>` is the first-class wrapper around the `sessions.compact` gateway RPC and requires a running gateway.
```bash
openclaw sessions compact "agent:main:main"
openclaw sessions compact "agent:main:main" --max-lines 200
openclaw sessions compact "agent:work:main" --agent work --json
```
- Without `--max-lines`, the gateway LLM-summarizes the transcript. This can be slow, so the default `--timeout` is `180000` ms.
- With `--max-lines <n>`, it truncates to the last `n` transcript lines and archives the prior transcript as a `.bak` sidecar.
- `--agent <id>`: agent that owns the session; required for `global` keys.
- `--url` / `--token` / `--password`: gateway connection overrides.
- `--timeout <ms>`: RPC timeout in milliseconds.
- `--json`: print the raw RPC payload.
The command exits non-zero when the gateway reports a failed compaction or is unreachable, so crons and scripts never mistake a silent no-op for success.
> Note: `openclaw agent --message '/compact ...'` is **not** a compaction path. Slash commands from the CLI are rejected by the authorized-sender check; that invocation exits non-zero with guidance pointing here instead of silently no-opping.
### sessions.compact RPC
`openclaw gateway call sessions.compact --params '<json>'` accepts:
| Field | Type | Required | Description |
| ---------- | ----------- | -------- | ---------------------------------------------------------- |
| `key` | string | yes | Session key to compact (for example `agent:main:main`). |
| `agentId` | string | no | Agent id that owns the session (for `global` keys). |
| `maxLines` | integer ≥ 1 | no | Truncate to the last N lines instead of LLM summarization. |
Example LLM-summarize response:
```json
{
"ok": true,
"key": "agent:main:main",
"compacted": true,
"result": { "tokensBefore": 243868, "tokensAfter": 34941 }
}
```
Example truncate response (`--max-lines 200`):
```json
{
"ok": true,
"key": "agent:main:main",
"compacted": true,
"archived": "/home/user/.openclaw/agents/main/sessions/transcripts/<id>.jsonl.bak",
"kept": 200
}
```
- Session config: [Configuration reference](/gateway/config-agents#session)
## Related
- Session config: [Configuration reference](/gateway/config-agents#session)
- [CLI reference](/cli)
- [Session management](/concepts/session)

View File

@@ -62,7 +62,7 @@ Configure compaction under `agents.defaults.compaction` in your `openclaw.json`.
### Using a different model
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts a `provider/model-id` string or a bare alias configured under `agents.defaults.models`:
By default, compaction uses the agent's primary model. Set `agents.defaults.compaction.model` to delegate summarization to a more capable or specialized model. The override accepts any `provider/model-id` string:
```json
{
@@ -76,8 +76,6 @@ By default, compaction uses the agent's primary model. Set `agents.defaults.comp
}
```
Bare configured aliases resolve to their canonical provider and model before compaction starts. If a bare value matches both an alias and a configured literal model ID, the literal model ID wins. An unmatched bare value remains a model ID on the active provider.
This works with local models too, for example a second Ollama model dedicated to summarization:
```json

View File

@@ -37,7 +37,7 @@ that agent; if you copy credentials manually, copy only portable static
`api_key` or `token` profiles.
</Warning>
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-allowlists).
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -316,10 +316,6 @@
"source": "/providers/zalo",
"destination": "/channels/zalo"
},
{
"source": "/channels/openclaw-zaloclawbot",
"destination": "/channels/zaloclawbot"
},
{
"source": "/providers/whatsapp",
"destination": "/channels/whatsapp"
@@ -1136,7 +1132,6 @@
"channels/feishu",
"channels/yuanbao",
"channels/zalo",
"channels/zaloclawbot",
"channels/zalouser"
]
},

View File

@@ -668,7 +668,7 @@ Periodic heartbeat runs.
- `qualityGuard`: retry-on-malformed-output checks for safeguard summaries. Enabled by default in safeguard mode; set `enabled: false` to skip the audit.
- `midTurnPrecheck`: optional tool-loop pressure check. When `enabled: true`, OpenClaw checks context pressure after tool results are appended and before the next model call. If the context no longer fits, it aborts the current attempt before submitting the prompt and reuses the existing precheck recovery path to truncate tool results or compact and retry. Works with both `default` and `safeguard` compaction modes. Default: disabled.
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Reinjection is disabled when unset or set to `[]`. Explicitly setting `["Session Startup", "Red Lines"]` enables that pair and preserves the legacy `Every Session`/`Safety` fallback. Enable this only when the extra context is worth the risk of duplicating project guidance already captured in the compaction summary.
- `model`: optional `provider/model-id` or bare alias from `agents.defaults.models` for compaction summarization only. Bare aliases resolve before dispatch; configured literal model IDs retain precedence on collisions. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `model`: optional `provider/model-id` override for compaction summarization only. Use this when the main session should keep one model but compaction summaries should run on another; when unset, compaction uses the session's primary model.
- `maxActiveTranscriptBytes`: optional byte threshold (`number` or strings like `"20mb"`) that triggers normal local compaction before a run when the active JSONL grows past the threshold. Requires `truncateAfterCompaction` so successful compaction can rotate to a smaller successor transcript. Disabled when unset or `0`.
- `notifyUser`: when `true`, sends brief notices to the user when compaction starts and when it completes (for example, "Compacting context..." and "Compaction complete"). Disabled by default to keep compaction silent.
- `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Set `model` to an exact provider/model such as `ollama/qwen3:8b` when this housekeeping turn should stay on a local model; the override does not inherit the active session fallback chain. Skipped when workspace is read-only.

View File

@@ -58,14 +58,7 @@ Methods:
- `node.pair.list` - list pending + paired nodes (`operator.pairing`).
- `node.pair.approve` - approve a pending request (issues token).
- `node.pair.reject` - reject a pending request.
- `node.pair.remove` - remove a paired node. For device-backed pairings this
revokes the device's `node` role: it mutates `devices/paired.json` and
invalidates/disconnects that device's node-role sessions. A **mixed-role**
device (e.g. it also holds `operator`) keeps its row and only loses the `node`
role; a node-only device row is deleted. It also removes any matching legacy
gateway-owned node pairing entry. Authz: `operator.pairing` may remove
non-operator node rows; a device-token caller revoking its **own** node role on
a mixed-role device additionally needs `operator.admin`.
- `node.pair.remove` - remove a stale paired node entry.
- `node.pair.verify` - verify `{ nodeId, token }`.
Notes:

View File

@@ -160,7 +160,7 @@ it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-skill-roots)
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
## Anthropic 429 extra usage required for long context

View File

@@ -51,14 +51,8 @@ Notes:
different role that pairing approval never granted.
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate gateway-owned
node pairing store; it does **not** gate the WS `connect` handshake.
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a
device-backed node it revokes the device's `node` role in `devices/paired.json`
and disconnects that device's node-role sessions — a mixed-role device keeps
its row and only loses the `node` role, while a node-only device row is
deleted. It also clears any matching entry from the separate gateway-owned node
pairing store. `operator.pairing` may remove non-operator node rows; a
device-token caller revoking its own node role on a mixed-role device
additionally needs `operator.admin`.
- `openclaw nodes remove --node <id|name|ip>` deletes stale entries from that
separate gateway-owned node pairing store.
- Approval scope follows the pending request's declared commands:
- commandless request: `operator.pairing`
- non-exec node commands: `operator.pairing` + `operator.write`

View File

@@ -504,10 +504,9 @@ Legacy aliases still normalize to the canonical bundled ids:
sign-in URL. xAI decides which accounts can receive OAuth API tokens, and
the consent page may show Grok Build even though OpenClaw does not require
the Grok Build app.
- OpenClaw does not currently expose the xAI multi-agent model family. xAI
serves these models through the Responses API, but they do not accept the
client-side or custom tools used by OpenClaw's shared agent loop. See the
[xAI multi-agent limitations](https://docs.x.ai/developers/model-capabilities/text/multi-agent#limitations).
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the
normal xAI provider path because it requires a different upstream API
surface than the standard OpenClaw xAI transport.
- xAI Realtime voice is not registered as an OpenClaw provider yet. It
needs a different bidirectional voice session contract than batch STT or
streaming transcription.

View File

@@ -322,7 +322,6 @@ You can wait on more than just time/text:
- `openclaw browser wait --url "**/dash"`
- Wait for load state:
- `openclaw browser wait --load networkidle`
- Supported on managed `openclaw` and raw/remote CDP profiles. The `user` and `existing-session` profiles reject `networkidle`; use `--url`, `--text`, a selector, or `--fn` waits there.
- Wait for a JS predicate:
- `openclaw browser wait --fn "window.ready===true"`
- Wait for a selector to become visible:

View File

@@ -743,7 +743,7 @@ Compared to the managed `openclaw` profile, existing-session drivers are more co
- **Screenshots** - page captures and `--ref` element captures work; CSS `--element` selectors do not. `--full-page` cannot combine with `--ref` or `--element`. Playwright is not required for page or ref-based element screenshots.
- **Actions** - `click`, `type`, `hover`, `scrollIntoView`, `drag`, and `select` require snapshot refs (no CSS selectors). `click-coords` clicks visible viewport coordinates and does not require a snapshot ref. `click` is left-button only. `type` does not support `slowly=true`; use `fill` or `press`. `press` does not support `delayMs`. `type`, `hover`, `scrollIntoView`, `drag`, `select`, `fill`, and `evaluate` do not support per-call timeouts. `select` accepts a single value.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported on existing-session profiles (it works on managed and raw/remote CDP profiles). Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Wait / upload / dialog** - `wait --url` supports exact, substring, and glob patterns; `wait --load networkidle` is not supported. Upload hooks require `ref` or `inputRef`, one file at a time, no CSS `element`. Dialog hooks do not support timeout overrides or `dialogId`.
- **Dialog visibility** - Managed browser action responses include `blockedByDialog` and `browserState.dialogs.pending` when an action opens a modal dialog; snapshots also include pending dialog state. Respond with `browser dialog --accept/--dismiss --dialog-id <id>` while a dialog is pending. Dialogs handled outside OpenClaw appear under `browserState.dialogs.recent`.
- **Managed-only features** - batch actions, PDF export, download interception, and `responsebody` still require the managed browser path.

View File

@@ -297,3 +297,8 @@ export function renderIsolatedCodexConfig(params: {
.filter((line, index, lines) => !(line === "" && lines[index - 1] === ""))
.join("\n");
}
/** Render only the project trust section for a session-local Codex config. */
export function renderIsolatedCodexProjectTrustConfig(projectPaths: string[]): string {
return renderIsolatedCodexConfig({ projectPaths });
}

View File

@@ -7,6 +7,7 @@ import {
type AriaSnapshotNode,
captureScreenshot,
createTargetViaCdp,
evaluateJavaScript,
formatAriaSnapshot,
normalizeCdpWsUrl,
type RawAXNode,
@@ -328,6 +329,47 @@ describe("cdp internal", () => {
});
});
describe("evaluateJavaScript", () => {
it("throws when Runtime.evaluate returns no result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
}
});
wss = server.wss;
await expect(evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" })).rejects.toThrow(
/Runtime\.evaluate returned no result/,
);
});
it("surfaces CDP exceptionDetails alongside result", async () => {
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: {
result: { type: "undefined" },
exceptionDetails: { text: "ReferenceError", lineNumber: 1 },
},
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "boom" });
expect(res.exceptionDetails?.text).toBe("ReferenceError");
});
});
describe("formatAriaSnapshot", () => {
it("returns an empty array when the AX tree is empty", () => {
expect(formatAriaSnapshot([], 100)).toStrictEqual([]);
@@ -897,6 +939,27 @@ describe("cdp internal", () => {
expect(snap.nodes).toStrictEqual([]);
});
it("swallows a failing Runtime.enable in evaluateJavaScript", async () => {
// Exercises the `.catch(() => {})` arrow on `Runtime.enable`.
const server = await startMockWsServer((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, error: { message: "denied" } }));
return;
}
if (msg.method === "Runtime.evaluate") {
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 1 } },
}),
);
}
});
wss = server.wss;
const res = await evaluateJavaScript({ wsUrl: server.wsUrl, expression: "1" });
expect(res.result.value).toBe(1);
});
it("swallows a failing Emulation.clearDeviceMetricsOverride in the screenshot finally", async () => {
// Exercises the `.catch(() => {})` on clearDeviceMetricsOverride inside
// the fullPage finally block.
@@ -945,4 +1008,5 @@ describe("cdp internal", () => {
expect(buf.toString("utf8")).toBe("S");
});
});
});

View File

@@ -12,7 +12,7 @@ import {
isWebSocketUrl,
parseBrowserHttpUrl as parseHttpUrl,
} from "./cdp.helpers.js";
import { createTargetViaCdp, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import { createTargetViaCdp, evaluateJavaScript, normalizeCdpWsUrl, snapshotAria } from "./cdp.js";
import {
BROWSER_ENDPOINT_BLOCKED_MESSAGE,
BROWSER_NAVIGATION_BLOCKED_MESSAGE,
@@ -412,6 +412,32 @@ describe("cdp", () => {
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {
socket.send(JSON.stringify({ id: msg.id, result: {} }));
return;
}
if (msg.method === "Runtime.evaluate") {
expect(msg.params?.expression).toBe("1+1");
socket.send(
JSON.stringify({
id: msg.id,
result: { result: { type: "number", value: 2 } },
}),
);
}
});
const res = await evaluateJavaScript({
wsUrl: `ws://127.0.0.1:${wsPort}`,
expression: "1+1",
});
expect(res.result.type).toBe("number");
expect(res.result.value).toBe(2);
});
it("fails when /json/version omits webSocketDebuggerUrl for an HTTP cdpUrl", async () => {
const httpPort = await startVersionHttpServer({});
await expect(

View File

@@ -299,6 +299,56 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
}
/** Runtime.evaluate remote-object subset used by CDP helpers. */
export type CdpRemoteObject = {
type: string;
subtype?: string;
value?: unknown;
description?: string;
unserializableValue?: string;
preview?: unknown;
};
/** Exception details surfaced from CDP Runtime.evaluate. */
export type CdpExceptionDetails = {
text?: string;
lineNumber?: number;
columnNumber?: number;
exception?: CdpRemoteObject;
stackTrace?: unknown;
};
/** Evaluate JavaScript in a CDP target and return by value when possible. */
export async function evaluateJavaScript(opts: {
wsUrl: string;
expression: string;
awaitPromise?: boolean;
returnByValue?: boolean;
}): Promise<{
result: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
}> {
return await withCdpSocket(opts.wsUrl, async (send) => {
await send("Runtime.enable").catch(() => {});
const evaluated = (await send("Runtime.evaluate", {
expression: opts.expression,
awaitPromise: Boolean(opts.awaitPromise),
returnByValue: opts.returnByValue ?? true,
userGesture: true,
includeCommandLineAPI: true,
})) as {
result?: CdpRemoteObject;
exceptionDetails?: CdpExceptionDetails;
};
const result = evaluated?.result;
if (!result) {
throw new Error("CDP Runtime.evaluate returned no result");
}
return { result, exceptionDetails: evaluated.exceptionDetails };
});
}
/** Normalized accessibility tree node returned by ARIA snapshots. */
export type AriaSnapshotNode = {
ref: string;

View File

@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clickChromeMcpCoords,
clickChromeMcpElement,
buildChromeMcpArgs,
decodeChromeMcpStderrTail,
ensureChromeMcpAvailable,
evaluateChromeMcpScript,
@@ -211,6 +212,114 @@ describe("chrome MCP page parsing", () => {
).resolves.toEqual(Buffer.from("screenshot:jpeg"));
});
it("adds --userDataDir when an explicit Chromium profile path is configured", () => {
expect(buildChromeMcpArgs("/tmp/brave-profile")).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--userDataDir",
"/tmp/brave-profile",
]);
});
it("uses browserUrl for existing-session cdpUrl without also passing userDataDir", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "http://127.0.0.1:9222",
userDataDir: "/tmp/brave-profile",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("uses wsEndpoint for direct existing-session websocket cdpUrl", () => {
expect(
buildChromeMcpArgs({
cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc",
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--wsEndpoint",
"ws://127.0.0.1:9222/devtools/browser/abc",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("appends custom Chrome MCP args and lets explicit endpoint args override auto-connect", () => {
expect(
buildChromeMcpArgs({
userDataDir: "/tmp/brave-profile",
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222", "--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
]);
});
it("lets explicit Chrome MCP usage-statistics args override the default opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--usage-statistics",
]);
});
it("does not duplicate an explicit Chrome MCP usage-statistics opt-out", () => {
expect(
buildChromeMcpArgs({
mcpArgs: ["--no-usage-statistics"],
}),
).toEqual([
"-y",
"chrome-devtools-mcp@latest",
"--autoConnect",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
"--no-usage-statistics",
]);
});
it("omits the npx package prefix for a custom Chrome MCP command", () => {
expect(
buildChromeMcpArgs({
mcpCommand: "/usr/local/bin/chrome-devtools-mcp",
cdpUrl: "http://127.0.0.1:9222",
}),
).toEqual([
"--browserUrl",
"http://127.0.0.1:9222",
"--no-usage-statistics",
"--experimentalStructuredContent",
"--experimental-page-id-routing",
]);
});
it("terminates the owned Chrome MCP subprocess tree when closing temporary sessions", async () => {
const session = createFakeSession();
Object.assign(session, { ownsProcessTree: true });

View File

@@ -462,6 +462,11 @@ function buildChromeMcpArgsFromOptions(options: NormalizedChromeMcpProfileOption
];
}
/** Build command-line args for launching chrome-devtools-mcp. */
export function buildChromeMcpArgs(input?: string | ChromeMcpProfileOptions): string[] {
return buildChromeMcpArgsFromOptions(normalizeChromeMcpOptions(input));
}
function drainStderr(transport: StdioClientTransport): () => string {
const stream = transport.stderr;
if (!stream) {

View File

@@ -2,42 +2,6 @@
import { describe, expect, it, vi } from "vitest";
import { loginChutes } from "./oauth.js";
function boundedErrorResponse(body: string, status = 500): {
response: Response;
cancel: ReturnType<typeof vi.fn>;
releaseLock: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(body);
let read = false;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("response.text() should not be called");
});
const response = {
ok: false,
status,
headers: new Headers(),
body: {
getReader: () => ({
read: async () => {
if (read) {
return { done: true, value: undefined };
}
read = true;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
text,
} as unknown as Response;
return { response, cancel, releaseLock, text };
}
describe("chutes plugin OAuth", () => {
it("rejects unsafe token lifetimes before storing credentials", async () => {
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
@@ -69,47 +33,4 @@ describe("chutes plugin OAuth", () => {
}),
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
});
it("bounds token exchange error bodies without requiring response.text()", async () => {
const errorResponse = boundedErrorResponse(
`${"chutes token unavailable ".repeat(1024)}tail-marker`,
502,
);
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://api.chutes.ai/idp/token") {
return errorResponse.response;
}
return new Response("not found", { status: 404 });
});
let error: unknown;
try {
await loginChutes({
app: {
clientId: "cid_test",
redirectUri: "http://127.0.0.1:1456/oauth-callback",
scopes: ["openid"],
},
manual: true,
createState: () => "state_test",
onAuth: vi.fn(async () => {}),
onPrompt: vi.fn(
async () => "http://127.0.0.1:1456/oauth-callback?code=code_test&state=state_test",
),
fetchFn,
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(Error);
const message = (error as Error).message;
expect(message).toContain("Chutes token exchange failed: chutes token unavailable");
expect(message).not.toContain("tail-marker");
expect(errorResponse.text).not.toHaveBeenCalled();
expect(errorResponse.cancel).toHaveBeenCalledTimes(1);
expect(errorResponse.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,13 +8,11 @@ import {
parseOAuthCallbackInput,
waitForLocalOAuthCallback,
} from "openclaw/plugin-sdk/provider-auth-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
const CHUTES_AUTHORIZE_ENDPOINT = "https://api.chutes.ai/idp/authorize";
const CHUTES_TOKEN_ENDPOINT = "https://api.chutes.ai/idp/token";
const CHUTES_USERINFO_ENDPOINT = "https://api.chutes.ai/idp/userinfo";
const CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type OAuthPrompt = {
message: string;
@@ -154,11 +152,7 @@ async function exchangeChutesCodeForTokens(params: {
body,
});
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
CHUTES_TOKEN_ERROR_BODY_LIMIT_BYTES,
).catch(() => "");
throw new Error(`Chutes token exchange failed: ${detail}`);
throw new Error(`Chutes token exchange failed: ${await response.text()}`);
}
const data = (await response.json()) as {

View File

@@ -1,57 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createClickClackClient } from "./http-client.js";
function streamedErrorResponse(body: string, limit: number) {
const encoded = new TextEncoder().encode(body);
let readCount = 0;
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const text = vi.fn(async () => {
throw new Error("raw response.text() should not be used");
});
const response = {
ok: false,
status: 502,
text,
body: {
getReader: () => ({
read: async () => {
if (readCount > 0) {
return { done: true, value: undefined };
}
readCount += 1;
return { done: false, value: encoded };
},
cancel,
releaseLock,
}),
},
} as unknown as Response;
return {
response,
cancel,
releaseLock,
text,
expectedDetail: body.slice(0, limit),
};
}
describe("ClickClack HTTP client", () => {
it("bounds error response bodies without using raw response.text()", async () => {
const streamed = streamedErrorResponse("x".repeat(9000), 8 * 1024);
const fetchMock = vi.fn(async () => streamed.response);
const client = createClickClackClient({
baseUrl: "https://clickclack.example",
token: "test-token",
fetch: fetchMock,
});
await expect(client.me()).rejects.toThrow(`ClickClack 502: ${streamed.expectedDetail}`);
expect(streamed.text).not.toHaveBeenCalled();
expect(streamed.cancel).toHaveBeenCalledTimes(1);
expect(streamed.releaseLock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -2,7 +2,6 @@
* Thin ClickClack REST/websocket client used by gateway, resolver, and outbound
* delivery code.
*/
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { WebSocket } from "ws";
import type {
ClickClackChannel,
@@ -18,8 +17,6 @@ type ClientOptions = {
fetch?: typeof fetch;
};
const CLICKCLACK_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
/**
* Creates a typed client for the ClickClack API using bearer-token auth.
*/
@@ -41,8 +38,7 @@ export function createClickClackClient(options: ClientOptions) {
}
const response = await fetcher(`${baseUrl}${path}`, { ...init, headers: requestHeaders });
if (!response.ok) {
const detail = await readResponseTextLimited(response, CLICKCLACK_ERROR_BODY_LIMIT_BYTES);
throw new Error(`ClickClack ${response.status}: ${detail}`);
throw new Error(`ClickClack ${response.status}: ${await response.text()}`);
}
return (await response.json()) as T;
}

View File

@@ -4,6 +4,7 @@
import {
GPT5_BEHAVIOR_CONTRACT,
GPT5_HEARTBEAT_PROMPT_OVERLAY,
renderGpt5PromptOverlay,
resolveGpt5SystemPromptContribution,
} from "openclaw/plugin-sdk/provider-model-shared";
@@ -18,3 +19,10 @@ export function resolveCodexSystemPromptContribution(
) {
return resolveGpt5SystemPromptContribution(params);
}
/** Renders the Codex prompt overlay text for supported GPT-5-family models. */
export function renderCodexPromptOverlay(
params: Parameters<typeof renderGpt5PromptOverlay>[0],
): string | undefined {
return renderGpt5PromptOverlay(params);
}

View File

@@ -854,6 +854,11 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
}
/** Returns whether the current dynamic tool list can serve workspace memory. */
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
}
/** Lists available memory tool names understood by Codex workspace memory routing. */
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
const availableToolNames = new Set(

View File

@@ -29,6 +29,26 @@ const loadSharedClientModule = async () => {
return await sharedClientModulePromise;
};
/** Returns the process-shared app-server client for normal attempt reuse. */
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,
authProfileId,
agentDir,
config,
options,
) =>
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
getSharedCodexAppServerClient({
startOptions,
authProfileId,
agentDir,
config,
onStartedClient: options?.onStartedClient,
abandonSignal: options?.abandonSignal,
timeoutMs: options?.timeoutMs,
}),
);
/** Returns a leased shared client so startup can release ownership explicitly. */
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
startOptions,

View File

@@ -2129,88 +2129,6 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("reports confirmed sends as successful when result middleware fails", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
const details = requireRecord(event.result.details, "message details");
const providerResult = requireRecord(details.result, "provider result");
delete providerResult.messageId;
throw new Error("redaction failed");
});
registry.agentToolResultMiddlewares.push({
pluginId: "broken-redactor",
pluginName: "Broken redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("raw result must stay private", {
ok: true,
result: {
messageId: "1700000000.000100",
channelId: "C123",
threadId: "1700000000.000000",
},
}),
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "C123",
text: "hello",
});
expect(result).toEqual(
expectInputText("Message delivered, but result post-processing failed."),
);
expect(result.sideEffectEvidence).toBe(true);
});
it("keeps deferred internal source replies closed when result middleware fails", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
const details = requireRecord(event.result.details, "message details");
details.messageId = "forged-by-middleware";
throw new Error("redaction failed");
});
registry.agentToolResultMiddlewares.push({
pluginId: "broken-redactor",
pluginName: "Broken redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult(
"message",
textToolResult("queued for internal delivery", {
status: "ok",
deliveryStatus: "sent",
sourceReplySink: "internal-ui",
sourceReply: { text: "visible reply" },
}),
);
const result = await handleMessageToolCall(bridge, {
action: "send",
target: "C123",
text: "hello",
});
expect(result).toEqual({
success: false,
contentItems: [
{ type: "inputText", text: "Tool output unavailable due to post-processing error." },
],
});
expect(result.sideEffectEvidence).toBe(true);
});
it("builds terminal presentation from the post-middleware result", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async () => ({

View File

@@ -80,6 +80,10 @@ class CodexThreadStartRequestError extends Error {
}
}
export function isCodexThreadStartRequestError(error: unknown): boolean {
return error instanceof CodexThreadStartRequestError;
}
export type CodexThreadFinalConfigPatchDecision =
| { action: "resume"; binding: CodexAppServerThreadBinding }
| { action: "start" };

View File

@@ -13,6 +13,7 @@ export const BASE_DIFF_VIEWER_LANGUAGE_HINTS = [
"text",
"ansi",
] as const satisfies readonly SupportedLanguages[];
export type DiffViewerBaseLanguage = (typeof BASE_DIFF_VIEWER_LANGUAGE_HINTS)[number];
const BASE_LANGUAGE_HINTS = new Set<SupportedLanguages>(BASE_DIFF_VIEWER_LANGUAGE_HINTS);
const BASE_LANGUAGE_ALIASES = new Map<string, SupportedLanguages>(

View File

@@ -5,28 +5,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
describe("fetchDiscord", () => {
beforeEach(() => {
vi.useRealTimers();
@@ -70,31 +48,6 @@ describe("fetchDiscord", () => {
).rejects.toThrow("Discord API /users/@me/guilds failed (404): Not Found");
});
it("bounds Discord API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"discord api unavailable ".repeat(1024)}tail`, {
status: 503,
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetcher = withFetchPreconnect(async () => tracked.response);
let error: unknown;
try {
await fetchDiscord("/users/@me/guilds", "test", fetcher, {
retry: { attempts: 1 },
});
} catch (err) {
error = err;
}
expect(error).toBeInstanceOf(DiscordApiError);
expect(String(error)).toContain("Discord API /users/@me/guilds failed (503)");
expect(String(error)).toContain("discord api unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
});
it("sanitizes Cloudflare HTML rate limits and applies a fallback cooldown", async () => {
const fetcher = withFetchPreconnect(
async () =>

View File

@@ -1,7 +1,6 @@
// Discord API module exposes the plugin public contract.
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
resolveRetryConfig,
retryAsync,
@@ -18,7 +17,6 @@ const DISCORD_API_RETRY_DEFAULTS = {
jitter: 0.1,
};
const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
type DiscordApiErrorPayload = {
message?: string;
@@ -175,10 +173,8 @@ export async function requestDiscord<T>(
body,
signal: resolveDiscordRequestSignal(options ?? {}),
});
const text = await res.text().catch(() => "");
if (!res.ok) {
const text = await readResponseTextLimited(res, DISCORD_API_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
const detail = formatDiscordApiErrorText(text, res);
const suffix = detail ? `: ${detail}` : "";
const retryAfter =
@@ -191,7 +187,6 @@ export async function requestDiscord<T>(
retryAfter,
);
}
const text = await res.text().catch(() => "");
if (!text.trim()) {
return undefined as T;
}

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements client behavior.
import type { APIInteraction } from "discord-api-types/v10";
import type { APIApplicationCommand, APIInteraction } from "discord-api-types/v10";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { DiscordCommandDeployer, type DeployCommandOptions } from "./command-deploy.js";
import type { BaseCommand } from "./commands.js";
@@ -272,10 +272,18 @@ export class Client {
return await this.entityCache.fetchMember(guildId, userId);
}
async getDiscordCommands(): Promise<APIApplicationCommand[]> {
return await this.commandDeployer.getCommands();
}
async deployCommands(options: DeployCommandOptions = {}) {
return await this.commandDeployer.deploy(options);
}
async reconcileCommands() {
return await this.deployCommands({ mode: "reconcile" });
}
async handleInteraction(rawData: APIInteraction, _ctx?: Context): Promise<void> {
await dispatchInteraction(this, rawData);
}

View File

@@ -144,6 +144,9 @@ export abstract class Command extends BaseCommand {
`The ${(interaction as { rawData?: { data?: { name?: string } } }).rawData?.data?.name ?? this.name} command does not support autocomplete`,
);
}
async preCheck(interaction: unknown): Promise<unknown> {
return Boolean(interaction) || true;
}
serializeOptions() {
return this.options?.map((option) => {
if (typeof option.autocomplete === "function") {

View File

@@ -138,6 +138,12 @@ export class Row<T extends BaseMessageInteractiveComponent> extends BaseComponen
addComponent(component: T): void {
this.components.push(component);
}
removeComponent(component: T): void {
this.components = this.components.filter((entry) => entry !== component);
}
removeAllComponents(): void {
this.components = [];
}
serialize(): APIActionRowComponent<APIComponentInMessageActionRow> {
return {
type: this.type,

View File

@@ -462,6 +462,18 @@ export class GatewayPlugin extends Plugin {
return this.outboundLimiter.getStatus();
}
getIntentsInfo() {
const intents = this.options.intents ?? 0;
return {
intents,
hasGuilds: this.hasIntent(GatewayIntentBits.Guilds),
hasGuildMembers: this.hasIntent(GatewayIntentBits.GuildMembers),
hasGuildPresences: this.hasIntent(GatewayIntentBits.GuildPresences),
hasGuildMessages: this.hasIntent(GatewayIntentBits.GuildMessages),
hasMessageContent: this.hasIntent(GatewayIntentBits.MessageContent),
};
}
hasIntent(intent: number): boolean {
return Boolean((this.options.intents ?? 0) & intent);
}

View File

@@ -16,6 +16,7 @@ import {
import {
createInteractionCallback,
createWebhookMessage,
deleteWebhookMessage,
editWebhookMessage,
getWebhookMessage,
} from "./api.js";
@@ -208,6 +209,15 @@ export class BaseInteraction {
return result;
}
async deleteReply(): Promise<unknown> {
return await deleteWebhookMessage(
this.client.rest,
this.client.options.clientId,
this.token,
"@original",
);
}
async fetchReply(): Promise<unknown> {
return await getWebhookMessage(
this.client.rest,
@@ -283,6 +293,18 @@ export class BaseComponentInteraction extends BaseInteraction {
async showModal(modal: Modal): Promise<unknown> {
return await this.callback(InteractionResponseType.Modal, modal.serialize());
}
async editAndWaitForComponent(
payload: MessagePayload,
message: Message | null = this.message,
timeoutMs = 300_000,
) {
if (!message) {
return null;
}
const editedMessage = await message.edit(payload);
return await this.client.componentHandler.waitForMessageComponent(editedMessage, timeoutMs);
}
}
export class ButtonInteraction extends BaseComponentInteraction {}

View File

@@ -148,6 +148,12 @@ export function createDiscordDraftPreviewController(params: {
finalizedViaPreviewMessage = true;
},
disableBlockStreamingForDraft: draftStream ? true : undefined,
async startProgressDraft() {
if (!draftStream || discordStreamMode !== "progress") {
return;
}
await progressDraft.start();
},
async pushToolProgress(
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string },

View File

@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
);
});
it("renders the internal-only-payload reason with the same shape", () => {
it("renders the reasoning-payload reason with the same shape", () => {
expect(
formatDiscordReplySkip({
kind: "block",
reason: "internal-only payload",
reason: "reasoning payload",
target: "channel:456",
sessionKey: "agent:friday:discord:channel:456",
}),
).toBe(
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
);
});
@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
expect(
formatDiscordReplySkip({
kind: "tool",
reason: "internal-only payload",
reason: "reasoning payload",
target: "channel:c1",
sessionKey: "",
}),
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
});
it("preserves the kind discriminant in the message prefix", () => {

View File

@@ -2639,20 +2639,17 @@ describe("processDiscordMessage draft streaming", () => {
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers reasoning block payloads to Discord", async () => {
it("suppresses reasoning payload delivery to Discord", async () => {
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "thinking...", isReasoning: true }],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("delivers reasoning-tagged final payload to Discord", async () => {
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "Reasoning:\nthis should be visible",
text: "Reasoning:\nthis should stay internal",
isReasoning: true,
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2664,10 +2661,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
replies: [{ text: "this should be visible", isReasoning: true }],
});
expect(deliverDiscordReply).not.toHaveBeenCalled();
expect(editMessageDiscord).not.toHaveBeenCalled();
});
it("delivers non-reasoning block payloads to Discord", async () => {
@@ -3062,8 +3057,8 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastUpdate).toContain("install dependencies");
expect(lastUpdate).not.toContain("completed");
expect(lastUpdate).toContain("completed");
expect(lastUpdate).not.toContain("install dependencies");
});
it("drops later tool warning finals after progress preview final replies", async () => {

View File

@@ -113,7 +113,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
return !resolveSendableOutboundReplyParts(payload).hasMedia;
}
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
type DiscordReplySkipReason =
| "aborted before delivery"
| "reasoning payload"
| "internal-only payload";
export function formatDiscordReplySkip(params: {
kind: "tool" | "block" | "final";
@@ -606,6 +609,18 @@ async function processDiscordMessageInner(
);
return null;
}
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return null;
}
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
const reply = resolveSendableOutboundReplyParts(payload);
if (!reply.hasMedia && !payload.isError) {
@@ -637,6 +652,18 @@ async function processDiscordMessageInner(
return { visibleReplySent: false };
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
logVerbose(
formatDiscordReplySkip({
kind: info.kind,
reason: "reasoning payload",
target: deliverTarget,
sessionKey: ctxPayload.SessionKey,
}),
);
return { visibleReplySent: false };
}
if (
isFinal &&
!options?.allowFallbackOnlyToolWarning &&

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