mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-20 13:42:02 +08:00
Compare commits
1 Commits
qa-fold-ht
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fad07635 |
@@ -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
4
.github/labeler.yml
vendored
@@ -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:
|
||||
|
||||
151
.github/pull_request_template.md
vendored
151
.github/pull_request_template.md
vendored
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
4
.github/workflows/ci-check-arm-testbox.yml
vendored
4
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/ci-check-testbox.yml
vendored
4
.github/workflows/ci-check-testbox.yml
vendored
@@ -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"
|
||||
|
||||
4
.github/workflows/clawsweeper-dispatch.yml
vendored
4
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/dependency-guard.yml
vendored
4
.github/workflows/dependency-guard.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ report_include:
|
||||
- Sources/**
|
||||
- ShareExtension/**
|
||||
- ActivityWidget/**
|
||||
- WatchApp/Sources/**
|
||||
- WatchExtension/Sources/**
|
||||
build_arguments:
|
||||
- -destination
|
||||
- generic/platform=iOS Simulator
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -184,8 +184,7 @@ final class ShareViewController: UIViewController {
|
||||
clientId: clientId,
|
||||
clientMode: "node",
|
||||
clientDisplayName: "OpenClaw Share",
|
||||
deviceIdentityProfile: .shareExtension,
|
||||
includeDeviceIdentity: true)
|
||||
includeDeviceIdentity: false)
|
||||
}
|
||||
|
||||
do {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
6
apps/ios/WatchExtension/Assets.xcassets/Contents.json
Normal file
6
apps/ios/WatchExtension/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
32
apps/ios/WatchExtension/Info.plist
Normal file
32
apps/ios/WatchExtension/Info.plist
Normal 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>
|
||||
@@ -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
|
||||
@@ -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:)
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "|")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1194,9 +1194,5 @@
|
||||
{
|
||||
"source": "cohere",
|
||||
"target": "cohere"
|
||||
},
|
||||
{
|
||||
"source": "Zalo ClawBot",
|
||||
"target": "Zalo ClawBot"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
26
docs/ci.md
26
docs/ci.md
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user